package prc.autodoc;

import java.io.*;
import java.util.*;

import prc.autodoc.Main.SpellType;
//import java.util.regex.*;

/* Static import in order to let me use the enum constants in switches */
import static prc.autodoc.Main.SpellType.*;

import static prc.Main.*;
import static prc.autodoc.Main.*;

/**
 * This class contains the methods for generation of the raw manual page contents.
 *
 * @author Ornedan
 */
public class EntryGeneration {
    /**
     * Handles creation of the skill pages.
     */
    public static void doSkills() {
        String skillPath = contentPath + "skills" + fileSeparator;
        String name = null,
                text = null,
                path = null,
                icon = null;
        boolean errored;

        skills = new HashMap<Integer, GenericEntry>();
        Data_2da skills2da = twoDA.get("skills");

        for (int i = 0; i < skills2da.getEntryCount(); i++) {
            errored = false;
            // Get the name of the skill and check if it's valid
            name = tlk.get(skills2da.getEntry("Name", i));
            if (verbose) System.out.println("Generating entry data for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for skill " + i);
                errored = true;
            }

            // Same for description
            text = htmlizeTLK(tlk.get(skills2da.getEntry("Description", i)));
            // Again, check if we had a valid description
            if (tlk.get(skills2da.getEntry("Description", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for skill " + i + ": " + name);
                errored = true;
            }

            // And icon
            icon = skills2da.getEntry("Icon", i);
            if (icon.equals("****")) {
                err_pr.println("Error: Icon not defined for skill " + i + ": " + name);
                errored = true;
            }
            icon = Icons.buildIcon(icon);

            // Build the final path
            path = skillPath + i + ".html";

            // Check if we had any errors. If we did, and the error tolerance flag isn't up, skip generating this entry
            if (!errored || tolErr) {
                // Store a data structure represeting the skill into a hashmap
                skills.put(i, new GenericEntry(name, text, icon, path, i));
            } else
                err_pr.println("Error: Failed to generate entry for skill " + i + ": " + name);
        }
        // Force a clean-up of dead objects. This will keep discarded objects from slowing down the program as it
        // hits the memory limit
        System.gc();
    }

    /**
     * Handles creation of the crafting itemprop pages.
     */
    public static void doCrafting() {
        String craftPath = contentPath + "itemcrafting" + fileSeparator;

        String name = null,
                text = null,
                path = null,
                icon = null;
        boolean errored;

        craft_armour = new HashMap<Integer, GenericEntry>();
        craft_weapon = new HashMap<Integer, GenericEntry>();
        craft_ring = new HashMap<Integer, GenericEntry>();
        craft_wondrous = new HashMap<Integer, GenericEntry>();
        Data_2da craft_armour_2da = twoDA.get("craft_armour");
        Data_2da craft_weapon_2da = twoDA.get("craft_weapon");
        Data_2da craft_ring_2da = twoDA.get("craft_ring");
        Data_2da craft_wondrous_2da = twoDA.get("craft_wondrous");

        icon = "";

        for (int i = 0; i < craft_armour_2da.getEntryCount(); i++) {
            errored = false;
            // Get the name of the skill and check if it's valid
            name = tlk.get(craft_armour_2da.getEntry("Name", i));
            if (verbose) System.out.println("Generating entry data for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for property " + i);
                errored = true;
            }

            // Same for description
            text = htmlizeTLK(tlk.get(craft_armour_2da.getEntry("Description", i)));
            // Again, check if we had a valid description
            if (tlk.get(craft_armour_2da.getEntry("Description", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for property " + i + ": " + name);
                errored = true;
            }

            // Build the final path
            path = craftPath + "armour" + i + ".html";

            // Check if we had any errors. If we did, and the error tolerance flag isn't up, skip generating this entry
            if (!errored || tolErr) {
                // Store a data structure represeting the skill into a hashmap
                craft_armour.put(i, new GenericEntry(name, text, icon, path, i));
            } else
                err_pr.println("Error: Failed to generate entry for property " + i + ": " + name);
        }

        for (int i = 0; i < craft_weapon_2da.getEntryCount(); i++) {
            errored = false;
            // Get the name of the skill and check if it's valid
            name = tlk.get(craft_weapon_2da.getEntry("Name", i));
            if (verbose) System.out.println("Generating entry data for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for property " + i);
                errored = true;
            }

            // Same for description
            text = htmlizeTLK(tlk.get(craft_weapon_2da.getEntry("Description", i)));
            // Again, check if we had a valid description
            if (tlk.get(craft_weapon_2da.getEntry("Description", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for property " + i + ": " + name);
                errored = true;
            }

            // Build the final path
            path = craftPath + "weapon" + i + ".html";

            // Check if we had any errors. If we did, and the error tolerance flag isn't up, skip generating this entry
            if (!errored || tolErr) {
                // Store a data structure represeting the skill into a hashmap
                craft_weapon.put(i, new GenericEntry(name, text, icon, path, i));
            } else
                err_pr.println("Error: Failed to generate entry for property " + i + ": " + name);
        }

        for (int i = 0; i < craft_ring_2da.getEntryCount(); i++) {
            errored = false;
            // Get the name of the skill and check if it's valid
            name = tlk.get(craft_ring_2da.getEntry("Name", i));
            if (verbose) System.out.println("Generating entry data for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for property " + i);
                errored = true;
            }

            // Same for description
            text = htmlizeTLK(tlk.get(craft_ring_2da.getEntry("Description", i)));
            // Again, check if we had a valid description
            if (tlk.get(craft_ring_2da.getEntry("Description", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for property " + i + ": " + name);
                errored = true;
            }

            // Build the final path
            path = craftPath + "ring" + i + ".html";

            // Check if we had any errors. If we did, and the error tolerance flag isn't up, skip generating this entry
            if (!errored || tolErr) {
                // Store a data structure represeting the skill into a hashmap
                craft_ring.put(i, new GenericEntry(name, text, icon, path, i));
            } else
                err_pr.println("Error: Failed to generate entry for property " + i + ": " + name);
        }

        for (int i = 0; i < craft_wondrous_2da.getEntryCount(); i++) {
            errored = false;
            // Get the name of the skill and check if it's valid
            name = tlk.get(craft_wondrous_2da.getEntry("Name", i));
            if (verbose) System.out.println("Generating entry data for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for property " + i);
                errored = true;
            }

            // Same for description
            text = htmlizeTLK(tlk.get(craft_wondrous_2da.getEntry("Description", i)));
            // Again, check if we had a valid description
            if (tlk.get(craft_wondrous_2da.getEntry("Description", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for property " + i + ": " + name);
                errored = true;
            }

            // Build the final path
            path = craftPath + "wondrous" + i + ".html";

            // Check if we had any errors. If we did, and the error tolerance flag isn't up, skip generating this entry
            if (!errored || tolErr) {
                // Store a data structure represeting the skill into a hashmap
                craft_wondrous.put(i, new GenericEntry(name, text, icon, path, i));
            } else
                err_pr.println("Error: Failed to generate entry for property " + i + ": " + name);
        }
        // Force a clean-up of dead objects. This will keep discarded objects from slowing down the program as it
        // hits the memory limit
        System.gc();
    }

    /**
     * Prints normal & epic spells and psionic powers.
     * As of now, all of these are similar enough to share the same
     * template, so they can be done here together.
     * <p>
     * The enumeration class used here is found at the end of the file
     */
    public static void doSpells() {
        String spellPath = contentPath + "spells" + fileSeparator,
                epicPath = contentPath + "epic_spells" + fileSeparator,
                psiPath = contentPath + "psionic_powers" + fileSeparator,
                utterPath = contentPath + "utterances" + fileSeparator,
                invocPath = contentPath + "invocations" + fileSeparator,
                manPath = contentPath + "maneuvers" + fileSeparator;

        String path = null,
                name = null,
                text = null,
                icon = null,
                subradName = null,
                subradIcon = null;
        List<Tuple<String, String>> subradials = null;
        boolean errored;
        int subRadial;

        SpellType spelltype = NONE;

        spells = new HashMap<Integer, SpellEntry>();
        Data_2da spells2da = twoDA.get("spells");

        for (int i = 0; i < spells2da.getEntryCount(); i++) {
            spelltype = NONE;
            errored = false;

            if (isNormalSpell(spells2da, i)) spelltype = NORMAL;
            else if (isEpicSpell(spells2da, i)) spelltype = EPIC;
            else if (isPsionicPower(spells2da, i)) spelltype = PSIONIC;
            else if (isTruenameUtterance(spells2da, i)) spelltype = UTTERANCE;
            else if (isInvocation(spells2da, i)) spelltype = INVOCATION;
            else if (isManeuver(spells2da, i)) spelltype = MANEUVER;

            if (spelltype != NONE) {
                name = tlk.get(spells2da.getEntry("Name", i))
                        .replaceAll("/", " / "); // Let the UA insert line breaks if necessary
                if (verbose) System.out.println("Generating entry data for " + name);
                // Check the name for validity
                if (name.equals(badStrRef)) {
                    err_pr.println("Error: Invalid name for spell " + i);
                    errored = true;
                }

                // Handle the contents
                text = htmlizeTLK(tlk.get(spells2da.getEntry("SpellDesc", i)));
                // Check the description validity
                if (tlk.get(spells2da.getEntry("SpellDesc", i)).equals(badStrRef)) {
                    err_pr.println("Error: Invalid description for spell " + i + ": " + name);
                    errored = true;
                }

                // Do the icon
                icon = spells2da.getEntry("IconResRef", i);
                if (icon.equals("****")) {
                    err_pr.println("Error: Icon not defined for spell " + i + ": " + name);
                    errored = true;
                }
                icon = Icons.buildIcon(icon);

                // Handle subradials, if any
                subradials = null;
                // Assume that if there are any, the first slot is always non-****
                if (!spells2da.getEntry("SubRadSpell1", i).equals("****")) {
                    subradials = new ArrayList<Tuple<String, String>>(5);
                    for (int j = 1; j <= 5; j++) {
                        // Also assume that all the valid entries are in order, such that if Nth SubRadSpell entry
                        // is ****, all > N are also ****
                        if (spells2da.getEntry("SubRadSpell" + j, i).equals("****"))
                            break;
                        try {
                            subRadial = Integer.parseInt(spells2da.getEntry("SubRadSpell" + j, i));

                            // Try name
                            subradName = tlk.get(spells2da.getEntry("Name", subRadial))
                                    .replaceAll("/", " / ");
                            // Check the name for validity
                            if (subradName.equals(badStrRef)) {
                                err_pr.println("Error: Invalid Name entry for spell " + subRadial);
                                errored = true;
                            }

                            // Try icon
                            subradIcon = spells2da.getEntry("IconResRef", subRadial);
                            if (subradIcon.equals("****")) {
                                err_pr.println("Error: Icon not defined for spell " + subRadial + ": " + subradName);
                                errored = true;
                            }

                            // Build list
                            subradials.add(new Tuple<String, String>(subradName, Icons.buildIcon(subradIcon)));
                        } catch (NumberFormatException e) {
                            err_pr.println("Error: Spell " + i + ": " + name + " contains an invalid SubRadSpell" + j + " entry");
                            errored = true;
                        }
                    }
                }

                // Build the path
                switch (spelltype) {
                    case NORMAL:
                        path = spellPath + i + ".html";
                        break;
                    case EPIC:
                        path = epicPath + i + ".html";
                        break;
                    case PSIONIC:
                        path = psiPath + i + ".html";
                        break;
                    case UTTERANCE:
                        path = utterPath + i + ".html";
                        break;
                    case INVOCATION:
                        path = invocPath + i + ".html";
                        break;
                    case MANEUVER:
                        path = manPath + i + ".html";
                        break;

                    default:
                        throw new AssertionError("Unhandled spelltype: " + spelltype);
                }

                // Check if we had any errors. If we did, and the error tolerance flag isn't up, skip generating this entry
                if (!errored || tolErr) {
                    // Store a data structure represeting the entry into a hashmap
                    spells.put(i, new SpellEntry(name, text, icon, path, i, spelltype, subradials));
                } else
                    err_pr.println("Error: Failed to generate entry for spell " + i + ": " + name);
            }
        }
        System.gc();
    }

    /**
     * Creates a list of spells.2da rows that should contain a psionic power's class-specific
     * entry.
     */
    public static void listPsionicPowers() {
        // A map of power name to class-specific spells.2da entry
        psiPowMap = new HashMap<String, Integer>();

        // Load cls_psipw_*.2da
        String[] fileNames = new File("../../trunk/2das").list(new FilenameFilter() {
            public boolean accept(File dir, String name) {
                return name.toLowerCase().startsWith("cls_psipw_") &&
                        name.toLowerCase().endsWith(".2da");
            }
        });

        listAMSEntries(fileNames, psiPowMap);
    }

    /**
     * Creates a list of spells.2da rows that should contain a truenaming utterance's
     */
    public static void listTruenameUtterances() {
        // A map of power name to class-specific spells.2da entry
        utterMap = new HashMap<String, Integer>();

        // Load cls_*_utter.2da
        String[] fileNames = new File("../../trunk/2das").list(new FilenameFilter() {
            public boolean accept(File dir, String name) {
                return name.toLowerCase().startsWith("cls_") &&
                        name.toLowerCase().endsWith("_utter.2da");
            }
        });

        listAMSEntries(fileNames, utterMap);
    }

    /**
     * Creates a list of spells.2da rows that should contain invocations
     */
    public static void listInvocations() {
        // A map of power name to class-specific spells.2da entry
        invMap = new HashMap<String, Integer>();

        // Load cls_*_utter.2da
        String[] fileNames = new File("../../trunk/2das").list(new FilenameFilter() {
            public boolean accept(File dir, String name) {
                return name.toLowerCase().startsWith("cls_inv_") &&
                        name.toLowerCase().endsWith(".2da");
            }
        });

        listAMSEntries(fileNames, invMap);
    }

    /**
     * Creates a list of spells.2da rows that should contain maneuvers
     */
    public static void listManeuvers() {
        // A map of power name to class-specific spells.2da entry
        maneuverMap = new HashMap<String, Integer>();

        // Load cls_*_utter.2da
        String[] fileNames = new File("../../trunk/2das").list(new FilenameFilter() {
            public boolean accept(File dir, String name) {
                return name.toLowerCase().startsWith("cls_move_") &&
                        name.toLowerCase().endsWith(".2da");
            }
        });

        listAMSEntries(fileNames, maneuverMap);
    }

    /**
     * Does the actual list generation for listPsionicPowers() and
     * listTruenameUtterances().
     *
     * @param fileNames List of 2da files that contain the entries to be listed
     * @param storeMap  Map to store the entries in
     */
    private static void listAMSEntries(String[] fileNames, Map<String, Integer> storeMap) {
        Data_2da spells2da = twoDA.get("spells");
        Data_2da[] list2das = new Data_2da[fileNames.length];
        for (int i = 0; i < fileNames.length; i++)
            //Strip out the ".2da" from the filenames before loading, since the loader function assumes it's missing
            list2das[i] = twoDA.get(fileNames[i].replace(".2da", ""));

        // Parse the 2das
        for (Data_2da list2da : list2das) {
            for (int i = 0; i < list2da.getEntryCount(); i++) {
                // Column FeatID is used to determine if the row specifies the main entry of a power
                if (!list2da.getEntry("FeatID", i).equals("****")) {
                    try {
                        //look up spells.2da name of the realspellid if we don't have a name column
                        if (list2da.getEntry("Name", i) == null) {
                            storeMap.put(tlk.get(spells2da.getEntry("Name", list2da.getEntry("RealSpellID", i))), Integer.parseInt(list2da.getEntry("RealSpellID", i)));
                        } else {
                            storeMap.put(tlk.get(list2da.getEntry("Name", i)), Integer.parseInt(list2da.getEntry("SpellID", i)));
                        }
                    } catch (NumberFormatException e) {
                        err_pr.println("Error: Invalid SpellID entry in " + list2da.getName() + ", line " + i);
                    }
                }
            }
        }
    }

    /**
     * A small convenience method for wrapping all the normal spell checks into
     * one.
     *
     * @param spells2da the Data_2da entry containing spells.2da
     * @param entryNum  the line number to use for testing
     * @return <code>true</code> if any of the class spell level columns contain a number,
     * <code>false</code> otherwise
     */
    private static boolean isNormalSpell(Data_2da spells2da, int entryNum) {
        if (!spells2da.getEntry("Bard", entryNum).equals("****")) return true;
        if (!spells2da.getEntry("Cleric", entryNum).equals("****")) return true;
        if (!spells2da.getEntry("Druid", entryNum).equals("****")) return true;
        if (!spells2da.getEntry("Paladin", entryNum).equals("****")) return true;
        if (!spells2da.getEntry("Ranger", entryNum).equals("****")) return true;
        if (!spells2da.getEntry("Wiz_Sorc", entryNum).equals("****")) return true;

        return false;
    }


    /**
     * A small convenience method for testing if the given entry contains a
     * epic spell.
     *
     * @param spells2da the Data_2da entry containing spells.2da
     * @param entryNum  the line number to use for testing
     * @return <code>true</code> if the impactscript name starts with strings specified in settings,
     * <code>false</code> otherwise
     */
    private static boolean isEpicSpell(Data_2da spells2da, int entryNum) {
        for (String check : settings.epicspellSignatures)
            if (spells2da.getEntry("ImpactScript", entryNum).startsWith(check)) return true;
        return false;
    }

    /**
     * A small convenience method for testing if the given entry contains a
     * psionic power. This is determined by whether the power's id is
     * in the psiPowMap Map.
     *
     * @param spells2da the Data_2da entry containing spells.2da
     * @param entryNum  the line number to use for testing
     * @return <code>true</code> if entryNum in spells2da contains a psionic power,
     * <code>false</code> otherwise
     */
    private static boolean isPsionicPower(Data_2da spells2da, int entryNum) {
        return psiPowMap.containsValue(entryNum);
    }

    /**
     * A small convenience method for testing if the given entry contains a
     * truenaming utterance. This is determined by whether the power's id is
     * in the utterMap Map.
     *
     * @param spells2da the Data_2da entry containing spells.2da
     * @param entryNum  the line number to use for testing
     * @return <code>true</code> if entryNum in spells2da contains an utterance,
     * <code>false</code> otherwise
     */
    private static boolean isTruenameUtterance(Data_2da spells2da, int entryNum) {
        return utterMap.containsValue(entryNum);
    }

    /**
     * A small convenience method for testing if the given entry contains an
     * invocation. This is determined by whether the power's id is
     * in the utterMap Map.
     *
     * @param spells2da the Data_2da entry containing spells.2da
     * @param entryNum  the line number to use for testing
     * @return <code>true</code> if entryNum in spells2da contains an utterance,
     * <code>false</code> otherwise
     */
    private static boolean isInvocation(Data_2da spells2da, int entryNum) {
        return invMap.containsValue(entryNum);
    }

    /**
     * A small convenience method for testing if the given entry contains a
     * maneuver. This is determined by whether the power's id is
     * in the utterMap Map.
     *
     * @param spells2da the Data_2da entry containing spells.2da
     * @param entryNum  the line number to use for testing
     * @return <code>true</code> if entryNum in spells2da contains an utterance,
     * <code>false</code> otherwise
     */
    private static boolean isManeuver(Data_2da spells2da, int entryNum) {
        return maneuverMap.containsValue(entryNum);
    }


    /**
     * Build the preliminary list of master feats, without the child feats
     * linked in.
     */
    public static void preliMasterFeats() {
        String mFeatPath = contentPath + "master_feats" + fileSeparator;
        String name = null,
                text = null,
                path = null;
        FeatEntry entry = null;
        boolean errored;

        masterFeats = new HashMap<Integer, FeatEntry>();
        Data_2da masterFeats2da = twoDA.get("masterfeats");

        for (int i = 0; i < masterFeats2da.getEntryCount(); i++) {
            // Skip blank rows
            if (masterFeats2da.getEntry("LABEL", i).equals("****")) continue;
            errored = false;

            // Get the name and validate it
            name = tlk.get(masterFeats2da.getEntry("STRREF", i));
            if (verbose) System.out.println("Generating preliminary data for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for masterfeat " + i);
                errored = true;
            }

            // Build the entry data
            text = htmlizeTLK(tlk.get(masterFeats2da.getEntry("DESCRIPTION", i)));
            // Check the description validity
            if (tlk.get(masterFeats2da.getEntry("DESCRIPTION", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for masterfeat " + i + ": " + name);
                errored = true;
            }

            // Add in the icon
            String icon = masterFeats2da.getEntry("ICON", i);
            if (icon.equals("****")) {
                err_pr.println("Error: Icon not defined for masterfeat " + i + ": " + name);
                errored = true;
            }
            icon = Icons.buildIcon(icon);

            // Build the path
            path = mFeatPath + i + ".html";

            if (!errored || tolErr) {
                // Store the entry to wait for further processing
                // Masterfeats start as class feats and are converted into general feats if any child
                // is a general feat
                entry = new FeatEntry(name, text, icon, path, i, false, true, null);
                masterFeats.put(i, entry);
            } else
                err_pr.println("Error: Failed to generate entry data for masterfeat " + i + ": " + name);
        }
        System.gc();
    }


    /**
     * Build the preliminary list of feats, without master / successor / predecessor feats
     * linked in.
     */
    public static void preliFeats() {
        String featPath = contentPath + "feats" + fileSeparator,
                epicPath = contentPath + "epic_feats" + fileSeparator,
                classFeatPath = contentPath + "class_feats" + fileSeparator,
                classEpicPath = contentPath + "class_epic_feats" + fileSeparator;
        String name = null,
                text = null,
                icon = null,
                path = null,
                subradName = null,
                subradIcon = null;
        FeatEntry entry = null;
        List<Tuple<String, String>> subradials = null;
        boolean isEpic = false,
                isClassFeat = false;
        boolean errored;
        int featSpell,
                subRadial;

        feats = new HashMap<Integer, FeatEntry>();
        Data_2da feats2da = twoDA.get("feat");
        Data_2da spells2da = twoDA.get("spells");

        for (int i = 0; i < feats2da.getEntryCount(); i++) {
            // Skip blank rows and markers
            if (feats2da.getEntry("LABEL", i).equals("****") ||
                    feats2da.getEntry("FEAT", i).equals("****"))
                continue;
            // Skip the ISC & Epic spell markers
            if (feats2da.getEntry("LABEL", i).equals("ReservedForISCAndESS")) continue;
            errored = false;

            // Get the name and validate it
            name = tlk.get(feats2da.getEntry("FEAT", i));
            if (verbose) System.out.println("Generating preliminary data for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for feat " + i);
                errored = true;
            }

            // Build the entry data
            text = htmlizeTLK(tlk.get(feats2da.getEntry("DESCRIPTION", i)));
            // Check the description validity
            if (tlk.get(feats2da.getEntry("DESCRIPTION", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for feat " + i + ": " + name);
                errored = true;
            }
            // Add in the icon
            icon = feats2da.getEntry("ICON", i);
            if (icon.equals("****")) {
                err_pr.println("Error: Icon not defined for feat " + i + ": " + name);
                errored = true;
            }
            icon = Icons.buildIcon(icon);

            // Handle subradials, if any
            subradials = null;
            if (!feats2da.getEntry("SPELLID", i).equals("****")) {
                try {
                    featSpell = Integer.parseInt(feats2da.getEntry("SPELLID", i));
                    // Assume that if there are any, the first slot is always non-****
                    if (!spells2da.getEntry("SubRadSpell1", featSpell).equals("****")) {
                        subradials = new ArrayList<Tuple<String, String>>(5);
                        for (int j = 1; j <= 5; j++) {
                            // Also assume that all the valid entries are in order, such that if Nth SubRadSpell entry
                            // is ****, all > N are also ****
                            if (spells2da.getEntry("SubRadSpell" + j, featSpell).equals("****"))
                                break;
                            try {
                                subRadial = Integer.parseInt(spells2da.getEntry("SubRadSpell" + j, featSpell));

                                // Try name
                                subradName = tlk.get(spells2da.getEntry("Name", subRadial))
                                        .replaceAll("/", " / ");
                                // Check the name for validity
                                if (subradName.equals(badStrRef)) {
                                    err_pr.println("Error: Invalid Name entry for spell " + subRadial);
                                    errored = true;
                                }

                                // Try icon
                                subradIcon = spells2da.getEntry("IconResRef", subRadial);
                                if (subradIcon.equals("****")) {
                                    err_pr.println("Error: Icon not defined for spell " + subRadial + ": " + subradName);
                                    errored = true;
                                }

                                // Build list
                                subradials.add(new Tuple<String, String>(subradName, Icons.buildIcon(subradIcon)));
                            } catch (NumberFormatException e) {
                                err_pr.println("Error: Spell " + featSpell + ": " + name + " contains an invalid SubRadSpell" + j + " entry");
                                errored = true;
                            }
                        }
                    }
                } catch (NumberFormatException e) {
                    err_pr.println("Error: Invalid SPELLID for feat " + i + ": " + name);
                    errored = true;
                }
            }

            // Classification
            isEpic = feats2da.getEntry("PreReqEpic", i).equals("1");
            isClassFeat = !feats2da.getEntry("ALLCLASSESCANUSE", i).equals("1");

            // Get the path
            if (isEpic) {
                if (isClassFeat) path = classEpicPath + i + ".html";
                else path = epicPath + i + ".html";
            } else {
                if (isClassFeat) path = classFeatPath + i + ".html";
                else path = featPath + i + ".html";
            }

            if (!errored || tolErr) {
                // Create the entry data structure
                entry = new FeatEntry(name, text, icon, path, i, isEpic, isClassFeat, subradials);
                // Store the entry to wait for further processing
                feats.put(i, entry);
            } else
                err_pr.println("Error: Failed to generate entry data for feat " + i + ": " + name);
        }
        System.gc();
    }


    /**
     * Builds the master - child, predecessor - successor and prerequisite links
     * and modifies the entry texts accordingly.
     */
    public static void linkFeats() {
        FeatEntry other = null;
        Data_2da feats2da = twoDA.get("feat");
        boolean allChildrenEpic, allChildrenClassFeat;

        // Link normal feats to each other and to masterfeats
        for (FeatEntry check : feats.values()) {
            if (verbose) System.out.println("Linking feat " + check.name);
            // Link to master
            if (!feats2da.getEntry("MASTERFEAT", check.entryNum).equals("****")) {
                try {
                    other = masterFeats.get(Integer.parseInt(feats2da.getEntry("MASTERFEAT", check.entryNum)));
                    other.childFeats.put(check.name, check);
                    check.master = other;
                    if (check.isEpic) other.isEpic = true;
                    if (!check.isClassFeat) other.isClassFeat = false;
                } catch (NumberFormatException e) {
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " contains an invalid MASTERFEAT entry");
                } catch (NullPointerException e) {
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " MASTERFEAT points to a nonexistent masterfeat entry");
                }
            }

            // Handle prerequisites
            buildPrerequisites(check, feats2da);

            // Handle successor feat, if any
            if (!feats2da.getEntry("SUCCESSOR", check.entryNum).equals("****")) {
                try {
                    other = feats.get(Integer.parseInt(feats2da.getEntry("SUCCESSOR", check.entryNum)));
                    // Check for feats that have themselves as successor
                    if (other == check)
                        err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " has itself as successor");
                    other.isSuccessor = true;
                    check.successor = other;
                } catch (NumberFormatException e) {
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " contains an invalid SUCCESSOR entry");
                } catch (NullPointerException e) {
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " SUCCESSOR points to a nonexistent feat entry");
                }
            }
        }

        // Masterfeat categorisation
        for (FeatEntry check : masterFeats.values()) {
            if (verbose) System.out.println("Linking masterfeat " + check.name);
            allChildrenEpic = allChildrenClassFeat = true;
            for (FeatEntry child : check.childFeats.values()) {
                if (!child.isEpic) allChildrenEpic = false;
                if (!child.isClassFeat) allChildrenClassFeat = false;
            }

            check.allChildrenClassFeat = allChildrenClassFeat;
            check.allChildrenEpic = allChildrenEpic;
        }
        System.gc();
    }

    /**
     * Links a feat and it's prerequisites.
     * Separated from the linkFeats method for improved readability.
     *
     * @param check    the feat entry to be examined
     * @param feats2da the data structure representing feat.2da
     */
    private static void buildPrerequisites(FeatEntry check, Data_2da feats2da) {
        FeatEntry andReq1 = null, andReq2 = null, orReq = null;
        String andReq1Num = feats2da.getEntry("PREREQFEAT1", check.entryNum),
                andReq2Num = feats2da.getEntry("PREREQFEAT2", check.entryNum);
        String[] orReqs = {feats2da.getEntry("OrReqFeat0", check.entryNum),
                feats2da.getEntry("OrReqFeat1", check.entryNum),
                feats2da.getEntry("OrReqFeat2", check.entryNum),
                feats2da.getEntry("OrReqFeat3", check.entryNum),
                feats2da.getEntry("OrReqFeat4", check.entryNum)};

        /* Handle AND prerequisite feats */
        // Some paranoia about bad entries
        if (!andReq1Num.equals("****")) {
            try {
                andReq1 = feats.get(Integer.parseInt(andReq1Num));
                if (andReq1 == null)
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " PREREQFEAT1 points to a nonexistent feat entry");
                else if (andReq1 == check)
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " has itself as PREREQFEAT1");
            } catch (NumberFormatException e) {
                err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " contains an invalid PREREQFEAT1 entry");
            }
        }
        if (!andReq2Num.equals("****")) {
            try {
                andReq2 = feats.get(Integer.parseInt(andReq2Num));
                if (andReq2 == null)
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " PREREQFEAT2 points to a nonexistent feat entry");
                else if (andReq2 == check)
                    err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " has itself as PREREQFEAT2");
            } catch (NumberFormatException e) {
                err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " contains an invalid PREREQFEAT2 entry");
            }
        }

        // Check if we had at least one valid entry. If so, link
        if (andReq1 != null || andReq2 != null) {
            if (andReq1 != null) {
                check.andRequirements.put(andReq1.name, andReq1);
                andReq1.requiredForFeats.put(check.name, check);
            }
            if (andReq2 != null) {
                check.andRequirements.put(andReq2.name, andReq2);
                andReq2.requiredForFeats.put(check.name, check);
            }
        }


        /* Handle OR prerequisite feats */
        // First, check if there are any
        boolean hasOrReqs = false;
        for (String orReqCheck : orReqs)
            if (!orReqCheck.equals("****")) hasOrReqs = true;

        if (hasOrReqs) {
            // Loop through each req and see if it's a valid link
            for (int i = 0; i < orReqs.length; i++) {
                if (!orReqs[i].equals("****")) {
                    try {
                        orReq = feats.get(Integer.parseInt(orReqs[i]));
                    } catch (NumberFormatException e) {
                        err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " contains an invalid OrReqFeat" + i + " entry");
                    }
                    if (orReq != null) {
                        if (orReq == check)
                            err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " has itself as OrReqFeat" + i);
                        check.orRequirements.put(orReq.name, orReq);
                        orReq.requiredForFeats.put(check.name, check);
                    } else
                        err_pr.println("Error: Feat " + check.entryNum + ": " + check.name + " OrReqFeat" + i + " points to a nonexistent feat entry");
                }
            }
        }
    }


    /**
     * Handles creation of the domain pages.
     * Kills page generation on bad strref for name.
     * Other errors are logged and prevent page write
     */
    public static void doDomains() {
        String domainPath = contentPath + "domains" + fileSeparator;
        String name = null,
                text = null,
                icon = null,
                path = null;
        List<SpellEntry> spellList = null;
        FeatEntry grantedFeat = null;
        SpellEntry grantedSpell = null;
        boolean errored;

        domains = new HashMap<Integer, DomainEntry>();
        Data_2da domains2da = twoDA.get("domains");

        for (int i = 0; i < domains2da.getEntryCount(); i++) {
            // Skip blank rows
            if (domains2da.getEntry("LABEL", i).equals("****")) continue;
            errored = false;

            // Get the name and validate it
            name = tlk.get(domains2da.getEntry("Name", i));
            if (verbose) System.out.println("Printing page for " + name);
            if (name.equals(badStrRef)) {
                err_pr.println("Error: Invalid name for domain " + i);
                errored = true;
            }

            // Build the entry data
            text = htmlizeTLK(tlk.get(domains2da.getEntry("Description", i)));
            // Check the description validity
            if (tlk.get(domains2da.getEntry("Description", i)).equals(badStrRef)) {
                err_pr.println("Error: Invalid description for domain " + i + ": " + name);
                errored = true;
            }

            // Add in the icon
            icon = domains2da.getEntry("Icon", i);
            if (icon.equals("****")) {
                err_pr.println("Error: Icon not defined for domain " + i + ": " + name);
                errored = true;
            }
            icon = Icons.buildIcon(icon);

            // Add a link to the granted feat
            try {
                grantedFeat = feats.get(Integer.parseInt(domains2da.getEntry("GrantedFeat", i)));
            } catch (NumberFormatException e) {
                err_pr.println("Error: Invalid entry in GrantedFeat of domain " + i + ": " + name);
                errored = true;
            } catch (NullPointerException e) {
                err_pr.println("Error: GrantedFeat entry for domain " + i + ": " + name + " points to non-existent feat: " + domains2da.getEntry("GrantedFeat", i));
                errored = true;
            }

            // Add links to the granted spells
            spellList = new ArrayList<SpellEntry>();
            for (int j = 1; j <= 9; j++) {
                // Skip blanks
                if (domains2da.getEntry("Level_" + j, i).equals("****")) continue;
                try {
                    grantedSpell = spells.get(Integer.parseInt(domains2da.getEntry("Level_" + j, i)));
                    if (grantedSpell != null)
                        spellList.add(grantedSpell);
                    else {
                        err_pr.println("Error: Level_" + j + " entry for domain " + i + ": " + name + " points to non-existent spell: " + domains2da.getEntry("Level_" + j, i));
                        errored = true;
                    }
                } catch (NumberFormatException e) {
                    err_pr.println("Error: Invalid entry in Level_" + j + " of domain " + i + ": " + name);
                    errored = true;
                }
            }

            // Build path and print
            path = domainPath + i + ".html";
            if (!errored || tolErr) {
                domains.put(i, new DomainEntry(name, text, icon, path, i, grantedFeat, spellList));
            } else
                err_pr.println("Error: Failed to generate entry for domain " + i + ": " + name);
        }
        System.gc();
    }


    /**
     * Handles creation of the race pages.
     */
    public static void doRaces() {
        String racePath = contentPath + "races" + fileSeparator;
        String name = null,
                text = null,
                path = null;
        TreeMap<String, FeatEntry> featList = null;
        FeatEntry grantedFeat = null;
        Data_2da featTable = null;
        boolean errored;

        races = new HashMap<Integer, RaceEntry>();
        Data_2da racialtypes2da = twoDA.get("racialtypes");

        for (int i = 0; i < racialtypes2da.getEntryCount(); i++) {
            // Skip non-player races
            if (!racialtypes2da.getEntry("PlayerRace", i).equals("1")) continue;
            errored = false;
            try {
                // Get the name and validate it
                name = tlk.get(racialtypes2da.getEntry("Name", i));
                if (verbose) System.out.println("Printing page for " + name);
                if (name.equals(badStrRef)) {
                    err_pr.println("Error: Invalid name for race " + i);
                    errored = true;
                }

                // Build the entry data
                text = htmlizeTLK(tlk.get(racialtypes2da.getEntry("Description", i)));
                // Check the description validity
                if (tlk.get(racialtypes2da.getEntry("Description", i)).equals(badStrRef)) {
                    err_pr.println("Error: Invalid description for race " + i + ": " + name);
                    errored = true;
                }


                // Add links to the racial feats
                try {
                    featTable = twoDA.get(racialtypes2da.getEntry("FeatsTable", i));
                } catch (TwoDAReadException e) {
                    throw new PageGenerationException("Failed to read RACE_FEAT_*.2da for race " + i + ": " + name + ":\n" + e);
                }

                featList = new TreeMap<String, FeatEntry>();
                for (int j = 0; j < featTable.getEntryCount(); j++) {
                    try {
                        grantedFeat = feats.get(Integer.parseInt(featTable.getEntry("FeatIndex", j)));
                        featList.put(grantedFeat.name, grantedFeat);
                    } catch (NumberFormatException e) {
                        err_pr.println("Error: Invalid entry in FeatIndex line " + j + " of " + featTable.getName());
                        errored = true;
                    } catch (NullPointerException e) {
                        err_pr.println("Error: FeatIndex line " + j + " of " + featTable.getName() + " points to non-existent feat: " + featTable.getEntry("FeatIndex", j));
                        errored = true;
                    }
                }

                // Build path and print
                path = racePath + i + ".html";
                if (!errored || tolErr) {
                    races.put(i, new RaceEntry(name, text, path, i, featList));
                } else
                    throw new PageGenerationException("Error(s) encountered while creating page");
            } catch (PageGenerationException e) {
                err_pr.println("Error: Failed to print page for race " + i + ": " + name + ":\n" + e);
            }
        }
        System.gc();
    }


    /**
     * Handles creation of the class pages.
     * Subsections handled by several following methods.
     */
    public static void doClasses() {
        String baseClassPath = contentPath + "base_classes" + fileSeparator,
                prestigeClassPath = contentPath + "prestige_classes" + fileSeparator;
        String name = null,
                text = null,
                icon = null,
                path = null,
                temp = null;
        List<String[]> babSav = null;
        Tuple<TreeMap<String, GenericEntry>, TreeMap<String, GenericEntry>> skillList = null;
        Tuple<List<Integer>, Tuple<List<TreeMap<String, FeatEntry>>, List<TreeMap<String, FeatEntry>>>> featList = null;
        List<Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>> magics = null;

        boolean errored;

        classes = new HashMap<Integer, ClassEntry>();
        Data_2da classes2da = twoDA.get("classes");

        for (int i = 0; i < classes2da.getEntryCount(); i++) {
            // Skip non-player classes
            if (!classes2da.getEntry("PlayerClass", i).equals("1")) continue;
            errored = false;
            try {
                name = tlk.get(classes2da.getEntry("Name", i));
                if (verbose) System.out.println("Printing page for " + name);
                if (name.equals(badStrRef)) {
                    err_pr.println("Error: Invalid name for class " + i);
                    errored = true;
                }

                // Build the entry data
                text = htmlizeTLK(tlk.get(classes2da.getEntry("Description", i)));
                // Check the description validity
                if (tlk.get(classes2da.getEntry("Description", i)).equals(badStrRef)) {
                    err_pr.println("Error: Invalid description for class " + i + ": " + name);
                    errored = true;
                }

                // Add in the icon
                icon = classes2da.getEntry("Icon", i);
                if (icon.equals("****")) {
                    err_pr.println("Error: Icon not defined for class " + i + ": " + name);
                    errored = true;
                }
                icon = Icons.buildIcon(icon);

                // Add in the BAB and saving throws table
                babSav = buildBabAndSaveList(classes2da, i);

                // Add in the skills table
                skillList = buildSkillList(classes2da, i);

                // Add in the feat table
                featList = buildClassFeatList(classes2da, i);

                // Add in the spells / powers table
                magics = buildClassMagicList(classes2da, i);

                /* Check whether this is a base or a prestige class. No prestige
                 * class should give exp penalty (nor should any base class not give it),
                 * so it gan be used as an indicator.
                 */
                temp = classes2da.getEntry("XPPenalty", i);
                if (!(temp.equals("0") || temp.equals("1"))) {
                    if (tolErr) {
                        err_pr.println("Error: Invalid List XPPenalty in classes.2da on row " + i + ": " + temp);
                        continue;
                    } else
                        throw new PageGenerationException("Invalid XPPenalty entry in classes.2da on row " + i + ": " + temp);
                }
                if (temp.equals("1"))
                    path = baseClassPath + i + ".html";
                else
                    path = prestigeClassPath + i + ".html";

                if (!errored || tolErr) {
                    classes.put(i, new ClassEntry(name, text, icon, path, i, temp.equals("1"),
                            babSav, skillList, featList, magics));
                } else
                    throw new PageGenerationException("Error(s) encountered while creating page");
            } catch (PageGenerationException e) {
                err_pr.println("Error: Failed to print page for class " + i + ": " + name + ":\n" + e);
            }
        }
        System.gc();
    }

    /**
     * Constructs a list of arrays containing the BAB and saving throw values
     * for the given class.
     *
     * @param classes2da data structure wrapping classes.2da
     * @param entryNum   number of the entry to generate list for
     * @return List<String [ ]> containing the values. BAB first, then saving throws (Fort, Ref, Will).
     * @throws PageGenerationException if there is an error while generating the list and error tolerance is off
     */
    private static List<String[]> buildBabAndSaveList(Data_2da classes2da, int entryNum) {
        Data_2da babTable = null,
                saveTable = null;
        try {
            babTable = twoDA.get(classes2da.getEntry("AttackBonusTable", entryNum));
        } catch (TwoDAReadException e) {
            throw new PageGenerationException("Failed to read CLS_ATK_*.2da for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)) + ":\n" + e);
        }
        try {
            saveTable = twoDA.get(classes2da.getEntry("SavingThrowTable", entryNum));
        } catch (TwoDAReadException e) {
            throw new PageGenerationException("Failed to read CLS_SAVTHR_*.2da for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)) + ":\n" + e);
        }

        /* Determine maximum level to print bab & save values to
         * The maximum level of the class, or the last non-epic level
         * whichever is lower
         */
        int maxToPrint = 0, maxLevel = 0, epicLevel = 0;
        try {
            maxLevel = Integer.parseInt(classes2da.getEntry("MaxLevel", entryNum));
        } catch (NumberFormatException e) {
            if (tolErr)
                err_pr.println("Error: Invalid MaxLevel entry for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
            else
                throw new PageGenerationException("Invalid MaxLevel entry for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
        }
        try {
            epicLevel = Integer.parseInt(classes2da.getEntry("EpicLevel", entryNum));
        } catch (NumberFormatException e) {
            if (tolErr)
                err_pr.println("Error: Invalid EpicLevel entry for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
            else
                throw new PageGenerationException("Invalid EpicLevel entry for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
        }

        // base classes have special notation for the epic level limit
        if (epicLevel == -1)
            maxToPrint = 20;
        else
            maxToPrint = maxLevel > epicLevel ? epicLevel : maxLevel;

        // If the class has any pre-epic levels
        List<String[]> toReturn = new ArrayList<String[]>(maxToPrint);
        if (maxToPrint > 0) {
            // Start building the table
            for (int i = 0; i < maxToPrint; i++) {
                toReturn.add(new String[]{
                        babTable.getEntry("BAB", i),
                        saveTable.getEntry("FortSave", i),
                        saveTable.getEntry("RefSave", i),
                        saveTable.getEntry("WillSave", i)
                });
            }
        }

        return toReturn;
    }

    /**
     * Constructs a list of the class and cross-class skills of the
     * given class
     *
     * @param classes2da data structure wrapping classes.2da
     * @param entryNum   number of the entry to generate table for
     * @return Tuple<TreeMap < String, GenericEntry>, TreeMap<String, GenericEntry>>
     * containing the class and cross-class skills of the given class. The
     * first tuple member contains the class skills, the second cross-class.
     * The map key is the name of the skill.
     * @throws PageGenerationException if there is an error while generating the list and error tolerance is off
     */
    private static Tuple<TreeMap<String, GenericEntry>, TreeMap<String, GenericEntry>> buildSkillList(Data_2da classes2da, int entryNum) {
        Data_2da skillTable = null;
        try {
            skillTable = twoDA.get(classes2da.getEntry("SkillsTable", entryNum));
        } catch (TwoDAReadException e) {
            throw new PageGenerationException("Failed to read CLS_SKILL_*.2da for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)) + ":\n" + e);
        }

        Tuple<TreeMap<String, GenericEntry>, TreeMap<String, GenericEntry>> toReturn =
                new Tuple<TreeMap<String, GenericEntry>, TreeMap<String, GenericEntry>>(
                        new TreeMap<String, GenericEntry>(), new TreeMap<String, GenericEntry>());
        String skillNum = null;
        GenericEntry skillEntry = null;

        for (int i = 0; i < skillTable.getEntryCount(); i++) {
            skillNum = skillTable.getEntry("ClassSkill", i);
            // Yet more validity checking :P
            if (!(skillNum.equals("0") || skillNum.equals("1"))) {
                if (tolErr) {
                    err_pr.println("Error: Invalid ClassSkill entry in " + skillTable.getName() + " on row " + i);
                    continue;
                } else
                    throw new PageGenerationException("Invalid ClassSkill entry in " + skillTable.getName() + " on row " + i);
            }

            try {
                skillEntry = skills.get(Integer.parseInt(skillTable.getEntry("SkillIndex", i)));
            } catch (NumberFormatException e) {
                if (tolErr) {
                    err_pr.println("Error: Invalid SkillIndex entry in " + skillTable.getName() + " on row " + i);
                    continue;
                } else
                    throw new PageGenerationException("Invalid SkillIndex entry in " + skillTable.getName() + " on row " + i);
            }
            if (skillEntry == null) {
                if (tolErr) {
                    err_pr.println("Error: SkillIndex entry in " + skillTable.getName() + " on row " + i + " points to non-existent skill");
                    continue;
                } else
                    throw new PageGenerationException("SkillIndex entry in " + skillTable.getName() + " on row " + i + " points to non-existent skill");
            }

            if (skillNum.equals("1"))
                toReturn.e1.put(skillEntry.name, skillEntry); // Class skill
            else
                toReturn.e2.put(skillEntry.name, skillEntry); // Cross-class skill
        }

        return toReturn;
    }

    /**
     * Constructs a pair of granted feat, selectable feat lists for
     * the given class.
     *
     * @param classes2da data structure wrapping classes.2da
     * @param entryNum   number of the entry to generate list for
     * @return Tuple<List < TreeMap < String, FeatEntry>>, List<TreeMap<String, FeatEntry>>>.
     * The first list contains the granted feats, the second selectable feats.
     * Each list consists of TreeMaps containing the feats that are related to
     * the list indexth (+1) level, keyed by feat name.
     * @throws PageGenerationException if there is an error while generating the lists and error tolerance is off
     */
    private static Tuple<List<Integer>, Tuple<List<TreeMap<String, FeatEntry>>, List<TreeMap<String, FeatEntry>>>>
    buildClassFeatList(Data_2da classes2da, int entryNum) {
        Data_2da featTable = null,
                bonusFeatTable = null;
        ArrayList<TreeMap<String, FeatEntry>> grantedFeatList = new ArrayList<TreeMap<String, FeatEntry>>(0),
                selectableFeatList = new ArrayList<TreeMap<String, FeatEntry>>(0);
        ArrayList<Integer> bonusFeatCounts = new ArrayList<Integer>();
        HashSet<FeatEntry> masterFeatsUsed = new HashSet<FeatEntry>();
        String listNum = null;
        FeatEntry classFeat = null;
        int maxLevel, epicLevel, grantedLevel;

        // Attempt to load the class feats table
        try {
            featTable = twoDA.get(classes2da.getEntry("FeatsTable", entryNum));
        } catch (TwoDAReadException e) {
            throw new PageGenerationException("Failed to read CLS_FEAT_*.2da for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)) + ":\n" + e);
        }
        // Attempt to load the class bonus feat slots table
        try {
            bonusFeatTable = twoDA.get(classes2da.getEntry("BonusFeatsTable", entryNum));
        } catch (TwoDAReadException e) {
            throw new PageGenerationException("Failed to read CLS_BFEAT_*.2da for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)) + ":\n" + e);
        }
        // Attempt to read the class epic level
        try {
            epicLevel = Integer.parseInt(classes2da.getEntry("EpicLevel", entryNum));
        } catch (NumberFormatException e) {
            throw new PageGenerationException("Invalid EpicLevel entry for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
        }
        // Attempt to read the class maximum level
        try {
            if (epicLevel == -1)
                maxLevel = 40;
            else
                maxLevel = Integer.parseInt(classes2da.getEntry("MaxLevel", entryNum));
        } catch (NumberFormatException e) {
            throw new PageGenerationException("Invalid MaxLevel entry for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
        }

        // Base classes have EpicLevel defined as -1, but become epic at L20
        if (epicLevel == -1) epicLevel = 20;
            // Sanity check
        else if (epicLevel > maxLevel) {
            if (tolErr) {
                err_pr.println("Error: EpicLevel value(" + epicLevel + ") greater than MaxLevel value(" + maxLevel + ") for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
                epicLevel = maxLevel;
            } else
                throw new PageGenerationException("EpicLevel value(" + epicLevel + ") greater than MaxLevel value(" + maxLevel + ") for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
        }


        // Init the lists
        for (int i = 0; i < maxLevel; i++) grantedFeatList.add(null);
        for (int i = 0; i < maxLevel; i++) selectableFeatList.add(null);


        // Build a level-sorted list of feats
        for (int i = 0; i < featTable.getEntryCount(); i++) {
            // Skip empty rows and comments
            if (featTable.getEntry("FeatLabel", i).equals("****") ||
                    featTable.getEntry("FeatIndex", i).equals("****"))
                continue;

            // Read the list number and validate
            listNum = featTable.getEntry("List", i);
            if (!(listNum.equals("0") || listNum.equals("1") || listNum.equals("2") || listNum.equals("3"))) {
                if (tolErr) {
                    err_pr.println("Error: Invalid List entry in " + featTable.getName() + " on row " + i + ": " + listNum);
                    continue;
                } else
                    throw new PageGenerationException("Invalid List entry in " + featTable.getName() + " on row " + i + ": " + listNum);
            }

            // Read the level granted on and validate
            try {
                grantedLevel = Integer.parseInt(featTable.getEntry("GrantedOnLevel", i));
            } catch (NumberFormatException e) {
                if (tolErr) {
                    err_pr.println("Error: Invalid GrantedOnLevel entry in " + featTable.getName() + " on row " + i + ": " + featTable.getEntry("GrantedOnLevel", i));
                    continue;
                } else
                    throw new PageGenerationException("Invalid GrantedOnLevel entry in " + featTable.getName() + " on row " + i + ": " + featTable.getEntry("GrantedOnLevel", i));
            }

            // Complain about a semantic error
            if (listNum.equals("3") && grantedLevel == -1) {
                if (tolErr) {
                    err_pr.println("Error: List value '3' combined with GrantedOnLevel value '-1' in " + featTable.getName() + " on row " + i);
                    continue;
                } else
                    throw new PageGenerationException("List value '3' combined with GrantedOnLevel value '-1' in " + featTable.getName() + " on row " + i);
            }

            // Get the feat on this row and validate
            try {
                classFeat = feats.get(Integer.parseInt(featTable.getEntry("FeatIndex", i)));
            } catch (NumberFormatException e) {
                if (tolErr) {
                    err_pr.println("Error: Invalid FeatIndex entry in " + featTable.getName() + " on row " + i + ": " + featTable.getEntry("FeatIndex", i));
                    continue;
                } else
                    throw new PageGenerationException("Invalid FeatIndex entry in " + featTable.getName() + " on row " + i + ": " + featTable.getEntry("FeatIndex", i));
            }
            if (classFeat == null) {
                if (tolErr) {
                    err_pr.println("Error: FeatIndex entry in " + featTable.getName() + " on row " + i + " points to non-existent feat: " + featTable.getEntry("FeatIndex", i));
                    continue;
                } else
                    throw new PageGenerationException("FeatIndex entry in " + featTable.getName() + " on row " + i + " points to non-existent feat: " + featTable.getEntry("FeatIndex", i));
            }


            // Skip feats that can never be gotten
            if (grantedLevel > 40) continue;
            if (grantedLevel > maxLevel) {
                // This is never a fatal error. It's merely bad practice to place the feat outside reachable bounds, but not obviously so (ex, value of 99)
                err_pr.println("Error: GrantedOnLevel entry in " + featTable.getName() + " on row " + i + " is greater than the class's maximum level, but not obviously unreachable: " + grantedLevel + " vs. " + maxLevel);
                continue;
            }

            // If the feat has a master, replace it with the master in the listing to prevent massive spammage
            if (classFeat.master != null) {
                // Only add masterfeats to the list once.
                if (masterFeatsUsed.contains(classFeat.master)) continue;
                masterFeatsUsed.add(classFeat.master);
                classFeat = classFeat.master;
            }

            // Freely selectable feats become available at L1
            if (grantedLevel == -1) {
                if (classFeat.isEpic) {
                    // Epic feats should be shown to become available on the level that is the first after the class's normal progression ends
                    // Sanity check here against bad class entries causing index violations
                    grantedLevel = Math.min(epicLevel + 1, maxLevel);
                } else
                    grantedLevel = 1;
            }
            grantedLevel -= 1; // Adjust to 0-based array index
            // Differentiate by automatically granted or selectable
            if (listNum.equals("3")) {
                // Create the map if missing
                if (grantedFeatList.get(grantedLevel) == null)
                    grantedFeatList.set(grantedLevel, new TreeMap<String, FeatEntry>());

                // Add the feat to the map
                grantedFeatList.get(grantedLevel).put(classFeat.name, classFeat);
            } else {
                // Create the map if missing
                if (selectableFeatList.get(grantedLevel) == null)
                    selectableFeatList.set(grantedLevel, new TreeMap<String, FeatEntry>());

                // Add the feat to the map
                selectableFeatList.get(grantedLevel).put(classFeat.name, classFeat);
            }
        }

        // Make sure there are enough entries in the bonus feat table
        if (bonusFeatTable.getEntryCount() < maxLevel) {
            throw new PageGenerationException("Too few entries in class bonus feat table " + bonusFeatTable.getName() + ": " + bonusFeatTable.getEntryCount() + ". Need " + maxLevel);
        }

        for (int i = 0; i < maxLevel; i++) {
            try {
                bonusFeatCounts.add(Integer.parseInt(bonusFeatTable.getEntry("Bonus", i)));
            } catch (NumberFormatException e) {
                if (tolErr) {
                    err_pr.println("Error: Invalid Bonus entry in " + bonusFeatTable.getName() + " on row " + i + ": " + bonusFeatTable.getEntry("Bonus", i));
                    continue;
                } else
                    throw new PageGenerationException("Invalid Bonus entry in " + bonusFeatTable.getName() + " on row " + i + ": " + bonusFeatTable.getEntry("Bonus", i));
            }
        }

        return new Tuple<List<Integer>, Tuple<List<TreeMap<String, FeatEntry>>, List<TreeMap<String, FeatEntry>>>>(
                bonusFeatCounts,
                new Tuple<List<TreeMap<String, FeatEntry>>, List<TreeMap<String, FeatEntry>>>(
                        grantedFeatList,
                        selectableFeatList));
    }

    private static int getIntFrom2DAColumn(Data_2da table, String column, int row) {
        return getIntFrom2DAColumn(table, column, row, false);
    }

    private static int getIntFrom2DAColumn(Data_2da table, String column, int row, boolean mayBeInvalid) {
        try {
            return Integer.parseInt(table.getEntry(column, row));
        } catch (NumberFormatException e) {
            if (mayBeInvalid) {
                // Caller indicated that it's okay if this value doesn't exist
                return -1;
            }

            if (tolErr) {
                err_pr.println("Error: Invalid " + column + " entry in " + table.getName() + " on row " + row + ": " + table.getEntry(column, row));
                return -1;
            } else
                throw new PageGenerationException("Invalid " + column + " entry in " + table.getName() + " on row " + row + ": " + table.getEntry(column, row));
        }
    }

    private static Map<Integer, String> baseClassIdsToSpellColumns = new HashMap<Integer, String>() {{
        put(1, "Bard");
        put(2, "Cleric");
        put(3, "Druid");
        put(6, "Paladin");
        put(7, "Ranger");
        put(9, "Wiz_Sorc");
        put(10, "Wiz_Sorc");
    }};

    /**
     * Constructs lists of the magics available to the given class.
     * The entries are ordered by spell / power level
     *
     * @param classes2da data structure wrapping classes.2da
     * @param entryNum   number of the entry to generate table for
     * @return List<Tuple < Tuple < String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>>.
     * Each list entry contains one magic type. The first tuple member consists of the
     * name of magic system, name of spell-equivalent pari. The second member contains
     * the magic entries. The Integer-keyed TreeMaps contain the spells
     * of each level. The integers are the spell levels.
     * @throws PageGenerationException if there is an error while generating the list and error tolerance is off
     */
    private static List<Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>> buildClassMagicList(Data_2da classes2da, int entryNum) {
        List<Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>> toReturn =
                new ArrayList<Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>>();
        String classAbrev = null;
        Data_2da spellList = null,
                powerList = null,
                utterList = null,
                //invocations and maneuvers are a bit different because they don't have their own name column
                invocList = null,
                maneuList = null,
                spells2da = twoDA.get("spells");


        // Check for correctly formed table name
        if (!classes2da.getEntry("FeatsTable", entryNum).toLowerCase().startsWith("cls_feat_")) {
            throw new PageGenerationException("Malformed FeatsTable entry for class " + entryNum + ": " + tlk.get(classes2da.getEntry("Name", entryNum)));
        }

        // Extract the class abbreviation
        classAbrev = classes2da.getEntry("FeatsTable", entryNum).toLowerCase().substring(9);

        // Attempt to load the class and power 2das - If these fail, assume it was just due to non-existent file
        try {
            spellList = twoDA.get("cls_spcr_" + classAbrev);
        } catch (TwoDAReadException e) { /* Ensure nullness */
            powerList = null;
        }
        try {
            powerList = twoDA.get("cls_psipw_" + classAbrev);
        } catch (TwoDAReadException e) { /* Ensure nullness */
            powerList = null;
        }
        try {
            utterList = twoDA.get("cls_" + classAbrev + "_utter");
        } catch (TwoDAReadException e) { /* Ensure nullness */
            utterList = null;
        }
        try {
            invocList = twoDA.get("cls_inv_" + classAbrev);
        } catch (TwoDAReadException e) { /* Ensure nullness */
            invocList = null;
        }
        try {
            maneuList = twoDA.get("cls_move_" + classAbrev);
        } catch (TwoDAReadException e) { /* Ensure nullness */
            maneuList = null;
        }

        // Do spellbook
        if (spellList == null && baseClassIdsToSpellColumns.containsKey(entryNum)) {
            // There's no custom spellbook for this class, so pull the info from spells.2da
            TreeMap<Integer, TreeMap<String, SpellEntry>> levelLists = new TreeMap<Integer, TreeMap<String, SpellEntry>>();
            SpellEntry spell = null;
            String classColumnName = baseClassIdsToSpellColumns.get(entryNum);
            int level;

            for (int i = 0; i < spells2da.getEntryCount(); i++) {
                // Make sure the Level entry is a number
                level = getIntFrom2DAColumn(spells2da, classColumnName, i, true);
                if (level == -1) continue;

                spell = spells.get(i);

                // If no map for this level yet, fill it in
                if (!levelLists.containsKey(level))
                    levelLists.put(level, new TreeMap<String, SpellEntry>());

                // Add the spell to the map
                levelLists.get(level)
                        .put(spell.name, spell);
            }

            toReturn.add(new Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>(
                    new Tuple<String, String>(curLanguageData[LANGDATA_SPELLBOOKTXT], curLanguageData[LANGDATA_SPELLSTXT]),
                    levelLists));
        } else if (spellList != null) {
            // Map of level numbers to maps of spell names to html links
            TreeMap<Integer, TreeMap<String, SpellEntry>> levelLists = new TreeMap<Integer, TreeMap<String, SpellEntry>>();
            SpellEntry spell = null;
            int level;

            for (int i = 0; i < spellList.getEntryCount(); i++) {
                // Make sure the Level entry is a number
                level = getIntFrom2DAColumn(spellList, "Level", i);
                if (level == -1) continue;

                // Make sure the SpellID is valid
                int spellId = getIntFrom2DAColumn(spellList, "SpellID", i);
                if (spellId == -1) continue;
                spell = spells.get(spellId);

                if (spell == null) {
                    if (tolErr) {
                        err_pr.println("Error: SpellID entry in " + spellList.getName() + " on row " + i + " points at nonexistent spell: " + spellList.getEntry("SpellID", i));
                        continue;
                    } else
                        throw new PageGenerationException("SpellID entry in " + spellList.getName() + " on row " + i + " points at nonexistent spell: " + spellList.getEntry("SpellID", i));
                }

                // If no map for this level yet, fill it in
                if (!levelLists.containsKey(level))
                    levelLists.put(level, new TreeMap<String, SpellEntry>());

                // Add the spell to the map
                levelLists.get(level)
                        .put(spell.name, spell);
            }

            toReturn.add(new Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>(
                    new Tuple<String, String>(curLanguageData[LANGDATA_SPELLBOOKTXT], curLanguageData[LANGDATA_SPELLSTXT]),
                    levelLists));
        }

        // Do psionics
        if (powerList != null) {
            // Map of level numbers to maps of spell names to html links
            TreeMap<Integer, TreeMap<String, SpellEntry>> levelLists = new TreeMap<Integer, TreeMap<String, SpellEntry>>();
            SpellEntry power = null;
            int level;

            for (int i = 0; i < powerList.getEntryCount(); i++) {
                // Skip rows that do not define a power
                if (powerList.getEntry("Level", i).equals("****"))
                    continue;

                level = getIntFrom2DAColumn(powerList, "Level", i);
                if (level == -1) continue;

                // Make sure the SpellID is valid
                int realSpellId = getIntFrom2DAColumn(powerList, "RealSpellID", i);
                if (realSpellId < 0) continue;
                power = spells.get(realSpellId);

                if (power == null) {
                    if (tolErr) {
                        err_pr.println("Error: Unable to map Name entry in " + powerList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(powerList.getEntry("Label", i)));
                        continue;
                    } else
                        throw new PageGenerationException("Unable to map Name entry in " + powerList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(powerList.getEntry("Name", i)));
                }

                // If no map for this level yet, fill it in
                if (!levelLists.containsKey(level))
                    levelLists.put(level, new TreeMap<String, SpellEntry>());

                // Add the spell to the map
                levelLists.get(level)
                        .put(power.name, power);
            }

            toReturn.add(new Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>(
                    new Tuple<String, String>(curLanguageData[LANGDATA_PSIONICPOWERSTXT], curLanguageData[LANGDATA_POWERTXT]),
                    levelLists));
        }

        // Do truenaming
        if (utterList != null) {
            // Map of level numbers to maps of spell names to html links
            TreeMap<Integer, TreeMap<String, SpellEntry>> levelLists = new TreeMap<Integer, TreeMap<String, SpellEntry>>();
            SpellEntry utterance = null;
            int level;

            for (int i = 0; i < utterList.getEntryCount(); i++) {
                // Skip rows that do not define a power
                if (utterList.getEntry("Level", i).equals("****"))
                    continue;

                // Make sure the Level entry is a number
                try {
                    level = Integer.parseInt(utterList.getEntry("Level", i));
                } catch (NumberFormatException e) {
                    if (tolErr) {
                        err_pr.println("Error: Invalid Level entry in " + utterList.getName() + " on row " + i + ": " + utterList.getEntry("Level", i));
                        continue;
                    } else
                        throw new PageGenerationException("Invalid Level entry in " + utterList.getName() + " on row " + i + ": " + utterList.getEntry("Level", i));
                }

                // Make sure the SpellID is valid
                utterance = null;
                try {
                    // Attempt to get the spell entry via a mapping of power names to spellIDs
                    utterance = spells.get(utterMap.get(tlk.get(Integer.parseInt(utterList.getEntry("Name", i)))));
                } catch (NumberFormatException e) {
                    if (tolErr) {
                        err_pr.println("Error: Invalid Name entry in " + utterList.getName() + " on row " + i + ": " + utterList.getEntry("Name", i));
                        continue;
                    } else
                        throw new PageGenerationException("Invalid Name entry in " + utterList.getName() + " on row " + i + ": " + utterList.getEntry("Name", i));
                }
                if (utterance == null) {
                    if (tolErr) {
                        err_pr.println("Error: Unable to map Name entry in " + utterList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(utterList.getEntry("Name", i)));
                        continue;
                    } else
                        throw new PageGenerationException("Unable to map Name entry in " + utterList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(utterList.getEntry("Name", i)));
                }

                // If no map for this level yet, fill it in
                if (!levelLists.containsKey(level))
                    levelLists.put(level, new TreeMap<String, SpellEntry>());

                // Add the spell to the map
                levelLists.get(level)
                        .put(utterance.name, utterance);
            }

            toReturn.add(new Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>(
                    new Tuple<String, String>(curLanguageData[LANGDATA_TRUENAMEUTTERANCETXT], curLanguageData[LANGDATA_UTTERANCETXT]),
                    levelLists));
        }

        // Do invocations
        if (invocList != null) {
            // Map of level numbers to maps of spell names to html links
            TreeMap<Integer, TreeMap<String, SpellEntry>> levelLists = new TreeMap<Integer, TreeMap<String, SpellEntry>>();
            SpellEntry invocation = null;
            int level;

            for (int i = 0; i < invocList.getEntryCount(); i++) {
                // Skip rows that do not define a power
                if (invocList.getEntry("Level", i).equals("****"))
                    continue;

                // Make sure the Level entry is a number
                try {
                    level = Integer.parseInt(invocList.getEntry("Level", i));
                } catch (NumberFormatException e) {
                    if (tolErr) {
                        err_pr.println("Error: Invalid Level entry in " + invocList.getName() + " on row " + i + ": " + invocList.getEntry("Level", i));
                        continue;
                    } else
                        throw new PageGenerationException("Invalid Level entry in " + invocList.getName() + " on row " + i + ": " + invocList.getEntry("Level", i));
                }

                // Make sure the SpellID is valid
                invocation = null;
                try {
                    // Look in spells.2da for name
                    invocation = spells.get(invMap.get(tlk.get(spells2da.getEntry("Name", invocList.getEntry("RealSpellID", i)))));
                } catch (NumberFormatException e) {
                    if (tolErr) {
                        err_pr.println("Error: Invalid Name entry in " + invocList.getName() + " on row " + i + ": " + invocList.getEntry("Name", i));
                        continue;
                    } else
                        throw new PageGenerationException("Invalid Name entry in " + invocList.getName() + " on row " + i + ": " + invocList.getEntry("Name", i));
                }
                if (invocation == null) {
                    if (tolErr) {
                        err_pr.println("Error: Unable to map Name entry in " + invocList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(invocList.getEntry("Name", i)));
                        continue;
                    } else
                        throw new PageGenerationException("Unable to map Name entry in " + invocList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(invocList.getEntry("Name", i)));
                }

                // If no map for this level yet, fill it in
                if (!levelLists.containsKey(level))
                    levelLists.put(level, new TreeMap<String, SpellEntry>());

                // Add the spell to the map
                levelLists.get(level)
                        .put(invocation.name, invocation);
            }

            toReturn.add(new Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>(
                    new Tuple<String, String>(curLanguageData[LANGDATA_INVOCATIONTXT], curLanguageData[LANGDATA_INVOCATIONTXT]),
                    levelLists));
        }

        // Do maneuvers
        if (maneuList != null) {
            // Map of level numbers to maps of spell names to html links
            TreeMap<Integer, TreeMap<String, SpellEntry>> levelLists = new TreeMap<Integer, TreeMap<String, SpellEntry>>();
            SpellEntry maneuver = null;
            int level;

            for (int i = 0; i < maneuList.getEntryCount(); i++) {
                // Skip rows that do not define a power
                if (maneuList.getEntry("Level", i).equals("****"))
                    continue;

                // Make sure the Level entry is a number
                try {
                    level = Integer.parseInt(maneuList.getEntry("Level", i));
                } catch (NumberFormatException e) {
                    if (tolErr) {
                        err_pr.println("Error: Invalid Level entry in " + maneuList.getName() + " on row " + i + ": " + maneuList.getEntry("Level", i));
                        continue;
                    } else
                        throw new PageGenerationException("Invalid Level entry in " + maneuList.getName() + " on row " + i + ": " + maneuList.getEntry("Level", i));
                }

                // Make sure the SpellID is valid
                maneuver = null;
                try {
                    // Look in spells.2da for name
                    maneuver = spells.get(maneuverMap.get(tlk.get(spells2da.getEntry("Name", maneuList.getEntry("RealSpellID", i)))));
                } catch (NumberFormatException e) {
                    if (tolErr) {
                        err_pr.println("Error: Invalid Name entry in " + maneuList.getName() + " on row " + i + ": " + maneuList.getEntry("Name", i));
                        continue;
                    } else
                        throw new PageGenerationException("Invalid Name entry in " + maneuList.getName() + " on row " + i + ": " + maneuList.getEntry("Name", i));
                }
                if (maneuver == null) {
                    if (tolErr) {
                        err_pr.println("Error: Unable to map Name entry in " + maneuList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(maneuList.getEntry("Name", i)));
                        continue;
                    } else
                        throw new PageGenerationException("Unable to map Name entry in " + maneuList.getName() + " on row " + i + " to a spellEntry: " + tlk.get(maneuList.getEntry("Name", i)));
                }

                // If no map for this level yet, fill it in
                if (!levelLists.containsKey(level))
                    levelLists.put(level, new TreeMap<String, SpellEntry>());

                // Add the spell to the map
                levelLists.get(level)
                        .put(maneuver.name, maneuver);
            }

            toReturn.add(new Tuple<Tuple<String, String>, TreeMap<Integer, TreeMap<String, SpellEntry>>>(
                    new Tuple<String, String>(curLanguageData[LANGDATA_MANEUVERTXT], curLanguageData[LANGDATA_MANEUVERTXT]),
                    levelLists));
        }

        return toReturn;
    }
}