package prc.autodoc;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static prc.Main.*;

/**
 * This class forms an interface for accessing 2da files in the
 * PRC automated manual generator.
 */
public class Data_2da implements Cloneable {
    // String matching pattern. Gets a block of non-whitespace (tab is not counted as whitespace here) that does not contain any " OR " followed by any characters until the next "
    private static final Pattern pattern = Pattern.compile("[[\\S&&[^\"]][\t]]+|\"[^\"]+\"");
    // Same as the above, but counts tab as whitespace. Bug-compatibility with BioWare's own violations of 2da spec
    private static final Pattern bugCompatPattern = Pattern.compile("[\\S&&[^\"]]+|\"[^\"]+\"");
    //private static Matcher matcher = pattern.matcher("");

    private LinkedHashMap<String, ArrayList<String>> mainData = new LinkedHashMap<String, ArrayList<String>>();
    private final String name;
    private String defaultValue;

    /**
     * Bugcompatibility feature for a special case where the first line of a 2da has * for line number instead of 0
     */
    private boolean starOnLine0 = false;

    /**
     * Used for storing the original case of the labels. The ones used in the hashmap are lowercase
     */
    private ArrayList<String> realLabels = new ArrayList<String>();

    private final boolean TLKEditCompatible = true;

    /**
     * Creates a new, empty Data_2da with the specified name.
     *
     * @param name The new 2da file's name.
     */
    public Data_2da(String name) {
        this(name, "");
    }

    /**
     * Creates a new, empty Data_2da with the specified name and default value.
     *
     * @param name         the new 2da file's name
     * @param defaultValue the new 2da file's default value
     */
    public Data_2da(String name, String defaultValue) {
        this.name = name;
        this.defaultValue = defaultValue;
    }

    /**
     * Private constructor for use in cloning.
     *
     * @param name         the file name
     * @param defaultValue the default value
     * @param realLabels   the labels with original case
     * @param mainData     the contents
     */
    public Data_2da(String name, String defaultValue, ArrayList<String> realLabels, LinkedHashMap<String, ArrayList<String>> mainData) {
        this.name = name;
        this.defaultValue = defaultValue;
        this.realLabels = realLabels;
        this.mainData = mainData;
    }

    /**
     * Saves the 2da represented by this object to file. Equivalent to calling
     * <code>save2da(path, false, false)</code>.
     *
     * @param path the directory to save the file in. If this is "" or null,
     *             the current directory is used.
     * @throws IOException if <code>true</code> and a file with the same name as this 2da
     *                     exists at <code>path</code>, it is overwritten.
     *                     If <code>false</code> and in the same situation, an IOException is
     *                     thrown.
     */
    public void save2da(String path) throws IOException {
        save2da(path, false, false);
    }

    /**
     * Saves the 2da represented by this object to file. CRLFs are explicitly used
     * instead of system specific line terminator.
     *
     * @param path           the directory to save the file in. If this is "" or null,
     *                       the current directory is used.
     * @param allowOverWrite if <code>true</code> and a file with the same name as this 2da
     *                       exists at <code>path</code>, it is overwritten.
     *                       If <code>false</code> and in the same situation, an IOException is
     *                       thrown.
     * @param evenColumns    if <code>true</code>, every entry in a column will be padded until they
     *                       start from the same position
     * @throws IOException if cannot overwrite, or the underlying IO throws one
     */
    public void save2da(String path, boolean allowOverWrite, boolean evenColumns) throws IOException {
        String CRLF = "\r\n";
        if (path == null || path.equals(""))
            path = "." + File.separator;
        if (!path.endsWith(File.separator))
            path += File.separator;

        File file = new File(path + name + ".2da");
        if (file.exists() && !allowOverWrite)
            throw new IOException("File exists already: " + file.getAbsolutePath());

        // Inform user
        if (verbose) System.out.print("Saving 2da file: " + name + " ");

        FileWriter fw = new FileWriter(file, false);
        String[] labels = this.getLabels();
        String toWrite;

        // Get the amount of padding used, if any
        int[] widths = new int[labels.length + 1];// All initialised to 0
        if (evenColumns) {
            ArrayList<String> column;
            int pad;
            // Loop over columns
            for (int i = 0; i < labels.length; i++) {
                pad = labels[i].length();
                column = mainData.get(labels[i]);
                // Loop over rows
                for (int j = 0; j < this.getEntryCount(); j++) {
                    toWrite = column.get(j);
                    // If the string contains spaces, it needs to be wrapped in "
                    if (toWrite.indexOf(" ") != -1)
                        toWrite = "\"" + toWrite + "\"";
                    if (toWrite.length() > pad) pad = toWrite.length();
                }
                widths[i] = pad;
            }

            // The last entry in the array is used for the numbers column
            widths[widths.length - 1] = new Integer(this.getEntryCount()).toString().length();
        }

        // Write the header and default lines
        fw.write("2DA V2.0" + CRLF);
        if (!defaultValue.equals(""))
            fw.write("DEFAULT: " + defaultValue + CRLF);
        else
            fw.write(CRLF);

        // Write the labels row using the original case
        for (int i = 0; i < widths[widths.length - 1]; i++) fw.write(" ");
        for (int i = 0; i < realLabels.size(); i++) {
            fw.write(" " + realLabels.get(i));
            for (int j = 0; j < widths[i] - realLabels.get(i).length(); j++) fw.write(" ");
        }
        fw.write((TLKEditCompatible ? " " : "") + CRLF);

        // Write the data
        for (int i = 0; i < this.getEntryCount(); i++) {
            // Write the number row and it's padding
            if (i == 0 && starOnLine0)
                fw.write("*");
            else
                fw.write("" + i);
            for (int j = 0; j < widths[widths.length - 1] - new Integer(i).toString().length(); j++) fw.write(" ");
            // Loop over columns
            for (int j = 0; j < labels.length; j++) {
                toWrite = mainData.get(labels[j]).get(i);
                // If the string contains spaces, it needs to be wrapped in "
                if (toWrite.indexOf(" ") != -1)
                    toWrite = "\"" + toWrite + "\"";
                fw.write(" " + toWrite);
                // Write padding
                for (int k = 0; k < widths[j] - toWrite.length(); k++) fw.write(" ");
            }
            fw.write((TLKEditCompatible ? " " : "") + CRLF);

            if (verbose) spinner.spin();
        }

        fw.flush();
        fw.close();

        if (verbose) System.out.println("- Done");
    }

    /**
     * Creates a new Data_2da on the 2da file specified.
     *
     * @param filePath path to the 2da file to load
     * @return a Data_2da instance containing the read 2da
     * @throws IllegalArgumentException <code>filePath</code> does not specify a 2da file
     * @throws TwoDAReadException       reading the 2da file specified does not succeed,
     *                                  or the file does not contain any data
     */
    public static Data_2da load2da(String filePath) throws IllegalArgumentException, TwoDAReadException {
        return load2da(filePath, false);
    }

    /**
     * Creates a new Data_2da on the 2da file specified.
     *
     * @param filePath  path to the 2da file to load
     * @param bugCompat if this is <code>true</code>, ignores
     *                  departures from the 2da spec present in Bioware 2das
     * @return a Data_2da instance containing the read 2da
     * @throws IllegalArgumentException <code>filePath</code> does not specify a 2da file
     * @throws TwoDAReadException       reading the 2da file specified does not succeed,
     *                                  or the file does not contain any data
     */
    public static Data_2da load2da(String filePath, boolean bugCompat) throws IllegalArgumentException, TwoDAReadException {
        Data_2da toReturn;
        Matcher matcher = bugCompat ? bugCompatPattern.matcher("") : pattern.matcher("");
        String name;

        // Some paranoia checking for bad parameters
        if (!filePath.toLowerCase().endsWith("2da"))
            throw new IllegalArgumentException("Non-2da filename passed to Data_2da: " + filePath);

        // Create the file handle
        File baseFile = new File(filePath);
        // More paraoia
        if (!baseFile.exists())
            throw new IllegalArgumentException("Nonexistent file passed to Data_2da: " + filePath);
        if (!baseFile.isFile())
            throw new IllegalArgumentException("Nonfile passed to Data_2da: " + filePath);


        // Drop the path from the filename
        name = baseFile.getName().substring(0, baseFile.getName().length() - 4);
        //toReturn = new Data_2da(baseFile.getName().substring(0, baseFile.getName().length() - 4));

        // Tell the user what we are doing
        if (verbose) System.out.print("Reading 2da file: " + name + " ");

        // Create a Scanner for reading the 2da
        Scanner reader = null;
        try {
            // Fully read the file into a byte array
            RandomAccessFile raf = new RandomAccessFile(baseFile, "r");
            byte[] bytebuf = new byte[(int) raf.length()];
            raf.readFully(bytebuf);
            raf.close();
            //reader = new Scanner(baseFile);
            reader = new Scanner(new String(bytebuf));
        } catch (Exception e) {
            err_pr.println("Error: File operation failed. Aborting.\nException data:\n" + e);
            System.exit(1);
        }

        try {
            // Check the 2da header
            if (!reader.hasNextLine())
                throw new TwoDAReadException("Empty file: " + name + "!");
            String data = reader.nextLine();

            if (!(bugCompat ? Pattern.matches("2DA[ \t]V2\\.0\\s*", data) : data.contains("2DA V2.0")))
                throw new TwoDAReadException("2da header missing or invalid: " + name);

            // Initialise the return object
            toReturn = new Data_2da(name);

            // Start the actual reading
            try {
                toReturn.createData(reader, matcher, bugCompat);
            } catch (TwoDAReadException e) {
                throw new TwoDAReadException("Exception occurred when reading 2da file: " + toReturn.getName() + "\n" + e.getMessage(), e);
            }
        } finally {
            // Cleanup
            reader.close();
        }

        if (verbose) System.out.println("- Done");
        return toReturn;
    }

    /**
     * Reads the data rows from the 2da into the hashmap and
     * does validity checking on the 2da while doing so.
     *
     * @param reader    Scanner that the method reads from
     * @param matcher   Matcher being used to parse the data read
     * @param bugCompat if this is <code>true</code>, ignores
     *                  departures from the 2da spec present in Bioware 2das
     */
    private void createData(Scanner reader, Matcher matcher, boolean bugCompat) {
        Scanner rowParser;
        String data, bugCompat_data;
        boolean bugCompat_MissingDefaultLine = false;
        int line = 0;

        // Get the default - though it's not used by this implementation, it should not be lost by opening and resaving a file
        if (!reader.hasNextLine())
            throw new TwoDAReadException("No contents after header in 2da file " + name + "!");
        bugCompat_data = data = reader.nextLine();
        matcher.reset(data);
        if (matcher.find()) { // Non-blank default line
            data = matcher.group();
            if (data.trim().equalsIgnoreCase("DEFAULT:")) {
                if (matcher.find())
                    this.defaultValue = matcher.group();
                else
                    throw new TwoDAReadException("Malformed default line in 2da file " + name + "!");
            } else if (!bugCompat)
                throw new TwoDAReadException("Malformed default line in 2da file " + name + "!");
            else
                bugCompat_MissingDefaultLine = true;
        }

        // Find the labels row
        if (bugCompat_MissingDefaultLine) // Handle cases where the labels are on the second line in the file instead of 3rd
            data = bugCompat_data;
        else {
            if (!reader.hasNextLine())
                throw new TwoDAReadException("No labels found in 2da file!");
            data = reader.nextLine();
        }

        // Check for blank lines between the DEFAULT line and labels
        if (data.trim().equals(""))
            if (!bugCompat)
                throw new TwoDAReadException("Labels not present on third line of the file!");
            else
                while (true) {
                    if (reader.hasNextLine()) {
                        data = reader.nextLine();
                        if (!data.trim().equals(""))
                            break;
                    } else
                        throw new TwoDAReadException("No data in 2da file!");
                }

        // Parse the labels
        String[] localrealLabels = data.trim().split("\\p{javaWhitespace}+");
        String[] labels = new String[localrealLabels.length];
        //System.arraycopy(realLabels, 0, labels, 0, localrealLabels.length);

        // Create the row containers and the main store
        for (int i = 0; i < labels.length; i++) {
            realLabels.add(localrealLabels[i]);
            labels[i] = localrealLabels[i].toLowerCase();
            mainData.put(labels[i], new ArrayList<String>());
        }

        // Error if there are empty lines between the header and the data or no lines at all
        if (!reader.hasNextLine())
            if (!bugCompat)
                throw new TwoDAReadException("No data in 2da file!");
            else
                return;
        if ((data = reader.nextLine()).trim().equals(""))
            if (!bugCompat)
                throw new TwoDAReadException("Blank lines following labels row!");
            else
                while (true) {
                    if (reader.hasNextLine()) {
                        data = reader.nextLine();
                        if (!data.trim().equals(""))
                            break;
                    } else
                        return;
                }

        while (true) {
            //rowParser = new Scanner(data);
            matcher.reset(data);
            matcher.find();

            // Check for the presence of the row number
            try {
                // Special case - In bugcompatibility mode, 0 can be replaced with *
                if (bugCompat && line == 0 && matcher.group().trim().equals("*")) {
                    starOnLine0 = true;
                } else
                    line = Integer.parseInt(matcher.group());
            } catch (NumberFormatException e) {
                throw new TwoDAReadException("Numberless 2da line: " + (line + 1));
            }

            // Start parsing the row
            for (int i = 0; i < labels.length; i++) {
                // Find the next match and check for too short rows
                if (!matcher.find())
                    throw new TwoDAReadException("Too short 2da line: " + line);

                // Get the next element and add it to the data structure
                data = matcher.group();
                // Remove the surrounding quotes if they are present
                if (data.startsWith("\"")) data = data.substring(1, data.length() - 1);
                mainData.get(labels[i]).add(data);
            }

            // Check for too long rows
            if (matcher.find())
                throw new TwoDAReadException("Too long 2da line: " + line);

            // Increment the entry counter
            //entries++;

            /* Get the next line if there is one, or break the loop
             * A bit ugly, but I couldn't figure an easy way of making the loop go right
             * even for 2das with only one row without biggish changes
             */
            if (reader.hasNextLine()) {
                data = reader.nextLine();
                if (data.trim().equals(""))
                    break;
            } else
                break;

            if (verbose) spinner.spin();
        }

        // Some validity checking on the 2da. Empty rows allowed only in the end
        if (getNextNonEmptyRow(reader) != null)
            throw new TwoDAReadException("Empty row in the middle of 2da. After row: " + line);
    }

    /**
     * Reads rows from a Scanner pointed at a 2da file until it finds a
     * row containing non-whitespace characters.
     *
     * @param reader Scanner that the method reads from
     * @return The row found, or null if none were found.
     */
    private static String getNextNonEmptyRow(Scanner reader) {
        String toReturn = null;
        while (reader.hasNextLine()) {
            toReturn = reader.nextLine();
            if (!toReturn.trim().equals(""))
                break;
        }

        if (toReturn == null || toReturn.trim().equals(""))
            return null;

        return toReturn;
    }

    /**
     * Get the list of column labels in this 2da.
     *
     * @return an array of Strings containing the column labels
     */
    public String[] getLabels() {
        // For some reason, it won't let me cast the keyset directly into a String[]
//		return (String[])(mainData.keySet().toArray());
        Object[] temp = mainData.keySet().toArray();
        String[] toReturn = new String[temp.length];
        for (int i = 0; i < temp.length; i++) toReturn[i] = (String) temp[i];
        /*String[] toReturn = (String[])mainData.keySet().toArray();*/
        return toReturn;
    }

    /**
     * Get the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get, as string
     * @return int represeting the 2da entry or <code>null</code> if the column does not exist
     * if the column is **** then 0 will be returned
     * @throws NumberFormatException if <code>row</code> cannot be converted to an integer
     */
    public int getBiowareEntryAsInt(String label, String row) {
        String returnString = this.getEntry(label, Integer.parseInt(row));
        if (returnString.equals("****"))
            return 0;
        else if (returnString.matches("\\D")) //check its a number
            return 0;
        return Integer.decode(returnString);
    }

    /**
     * Get the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get
     * @return String represeting the 2da entry or <code>null</code> if the column does not exist
     * if the column is **** then a zero length string will be returned
     */
    public int getBiowareEntryAsInt(String label, int row) {
        ArrayList<String> column = mainData.get(label.toLowerCase());
        String returnString = column != null ? column.get(row) : null;
        if (returnString.equals("****"))
            return 0;
        else if (returnString.matches("\\D")) //check its a number
            return 0;
        return Integer.decode(returnString);
    }

    /**
     * Get the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get, as string
     * @return String represeting the 2da entry or <code>null</code> if the column does not exist
     * if the column is **** then a zero length string will be returned
     * @throws NumberFormatException if <code>row</code> cannot be converted to an integer
     */
    public String getBiowareEntry(String label, String row) {
        String returnString = this.getEntry(label, Integer.parseInt(row));
        if (returnString.equals("****"))
            return "";
        return returnString;
    }

    /**
     * Get the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get
     * @return String represeting the 2da entry or <code>null</code> if the column does not exist
     * if the column is **** then a zero length string will be returned
     */
    public String getBiowareEntry(String label, int row) {
        ArrayList<String> column = mainData.get(label.toLowerCase());
        String returnString = column != null ? column.get(row) : null;
        if (returnString.equals("****"))
            return "";
        return returnString;
    }

    /**
     * Get the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get, as string
     * @return String represeting the 2da entry or <code>null</code> if the column does not exist
     * @throws NumberFormatException if <code>row</code> cannot be converted to an integer
     */
    public String getEntry(String label, String row) {
        return this.getEntry(label, Integer.parseInt(row));
    }

    /**
     * Get the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get
     * @return String represeting the 2da entry or <code>null</code> if the column does not exist
     */
    public String getEntry(String label, int row) {
        ArrayList<String> column = mainData.get(label.toLowerCase());
        return column != null ? column.get(row) : null;
    }

    /**
     * Get number of entries in this 2da. Works by returning the size of one of the columns in the 2da.
     *
     * @return integer equal to the number of entries in this 2da
     */
    public int getEntryCount() {
        if (mainData.entrySet().size() == 0) {
            return 0;
        }
        return mainData.entrySet().iterator().next().getValue().size();
    }

    /**
     * Get the name of this 2da
     *
     * @return String representing this 2da's name
     */
    public String getName() {
        return name;
    }

    /**
     * Sets the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get, as string
     * @param entry the new contents of the entry. If this is null or empty, it is replaced with ****
     * @throws NumberFormatException if <code>row</code> cannot be converted to an integer
     */
    public void setEntry(String label, String row, String entry) {
        this.setEntry(label, Integer.parseInt(row), entry);
    }

    /**
     * Sets the 2da entry on the given row and column
     *
     * @param label the label of the column to get
     * @param row   the number of the row to get, as string
     * @param entry the new contents of the entry. If this is null or empty, it is replaced with ****
     *              or with the default value if that is set
     */
    public void setEntry(String label, int row, String entry) {
        if (entry == null || entry.equals(""))
            if (defaultValue.equals(""))
                entry = "****";
            else
                entry = defaultValue;
        mainData.get(label.toLowerCase()).set(row, entry);
    }

    /**
     * Returns the contents of the requested row as a string array. The order the columns are
     * taken is the same as the order of labels from getLabels().
     *
     * @param index the index of the row to get
     * @return an array of strings containing the elements in the r
     * @throws NumberFormatException if <code>index</code> cannot be converted to an integer
     */
    public String[] getRow(String index) {
        return getRow(Integer.parseInt(index));
    }


    /**
     * Returns the contents of the requested row as a string array. The order the columns are
     * taken is the same as the order of labels from getLabels().
     *
     * @param index the index of the row to get
     * @return an array of strings containing the elements in the row
     */
    public String[] getRow(int index) {
        String[] labels = this.getLabels();
        String[] toReturn = new String[labels.length];

        for (int i = 0; i < labels.length; i++) {
            toReturn[i] = mainData.get(labels[i]).get(index);
        }

        return toReturn;
    }

    /**
     * Appends a new, empty row to the end of the 2da file, with entries defaulting to the
     * default value or if that is not set, ****
     */
    public void appendRow() {
        String[] labels = this.getLabels();

        for (String label : labels) {
            if (defaultValue.equals(""))
                mainData.get(label).add("****");
            else
                mainData.get(label).add(defaultValue);
        }
    }

    /**
     * Appends a new, empty row to the end of the 2da file. The new row will be filled with the values
     * given as parameter.
     *
     * @param data the strings that will be used to fill the new row
     * @throws IllegalArgumentException if the number of elements in <code>data</code> array is not
     *                                  same as number of columns in the 2da
     */
    public void appendRow(String[] data) {
        String[] labels = this.getLabels();

        // Sanity check
        if (labels.length != data.length)
            throw new IllegalArgumentException("Differing column width when attempting to insert row");

        for (int i = 0; i < labels.length; i++) {
            mainData.get(labels[i]).add(data[i]);
        }
    }

    /**
     * Inserts a new row into the given index in the 2da file. The row currently at the index and all
     * subsequent rows have their index increased by one. The new row will be filled with the values
     * given as parameter.
     *
     * @param index the index where the new row will be located
     * @param data  the strings that will be used to fill the new row
     * @throws IllegalArgumentException if the number of elements in <code>data</code> array is not
     *                                  same as number of columns in the 2da
     * @throws NumberFormatException    if <code>index</code> cannot be converted to an integer
     */
    public void insertRow(String index, String[] data) {
        insertRow(Integer.parseInt(index), data);
    }

    /**
     * Inserts a new row into the given index in the 2da file. The row currently at the index and all
     * subsequent rows have their index increased by one. The new row will be filled with the values
     * given as parameter.
     *
     * @param index the index where the new row will be located
     * @param data  the strings that will be used to fill the new row
     * @throws IllegalArgumentException if the number of elements in <code>data</code> array is not
     *                                  same as number of columns in the 2da
     */
    public void insertRow(int index, String[] data) {
        String[] labels = this.getLabels();

        // Sanity check
        if (labels.length != data.length)
            throw new IllegalArgumentException("Differing column width when attempting to insert row");

        for (int i = 0; i < labels.length; i++) {
            mainData.get(labels[i]).add(index, data[i]);
        }
    }


    /**
     * Adds a new, empty row to the given index in the 2da file. The row currently at the index and all
     * subsequent rows have their index increased by one.
     * The entries default to ****.
     *
     * @param index the index where the new row will be located
     * @throws NumberFormatException if <code>index</code> cannot be converted to an integer
     */
    public void insertRow(String index) {
        insertRow(Integer.parseInt(index));
    }

    /**
     * Adds a new, empty row to the given index in the 2da file. The row currently at the index and all
     * subsequent rows have their index increased by one.
     * The entries default to default value or if that is not set, ****.
     *
     * @param index the index where the new row will be located
     */
    public void insertRow(int index) {
        String[] labels = this.getLabels();

        for (String label : labels) {
            if (defaultValue.equals(""))
                mainData.get(label).add(index, "****");
            else
                mainData.get(label).add(index, defaultValue);
        }
    }

    /**
     * Removes the row at the given index. All subsequent rows have their indexed shifted down by one.
     *
     * @param index the index of the row to remove
     * @throws NumberFormatException if <code>index</code> cannot be converted to an integer
     */
    public void removeRow(String index) {
        removeRow(Integer.parseInt(index));
    }

    /**
     * Removes the row at the given index. All subsequent rows have their indexed shifted down by one.
     *
     * @param index the index of the row to remove
     */
    public void removeRow(int index) {
        String[] labels = this.getLabels();

        for (String label : labels) {
            mainData.get(label).remove(index);
        }
    }

    /**
     * Adds a new column to the 2da file. The new column will be the last in the file.
     *
     * @param label the name of the column to add
     */
    public void addColumn(String label) {
        ArrayList<String> column = new ArrayList<String>();
        mainData.put(label.toLowerCase(), column);
        realLabels.add(label);

        if (defaultValue.equals("")) {
            for (int i = 0; i < this.getEntryCount(); i++) {
                column.add("****");
            }
        } else {
            for (int i = 0; i < this.getEntryCount(); i++) {
                column.add(defaultValue);
            }
        }
    }

    /**
     * Removes the column with the given label from the 2da.
     *
     * @param label the name of the column to remove
     */
    public void removeColumn(String label) {
        mainData.remove(label);
        realLabels.remove(label);
    }


    /**
     * The main method, as usual
     *
     * @param args
     */
    public static void main(String[] args) {
        if (args.length == 0) readMe();
        List<String> fileNames = new ArrayList<String>();
        boolean compare = false;
        boolean resave = false;
        boolean minimal = false;
        boolean ignoreErrors = false;
        boolean readStdin = false;
        boolean bugCompat = false;

        for (String param : args) {//[-bcrmnqs] file... | -
            // Parameter parseage
            if (param.startsWith("-")) {
                if (param.equals("-"))
                    readStdin = true;
                else if (param.equals("--help")) readMe();
                else {
                    for (char c : param.substring(1).toCharArray()) {
                        switch (c) {
                            case 'b':
                                bugCompat = true;
                                break;
                            case 'c':
                                compare = true;
                                if (resave) resave = false;
                                break;
                            case 'r':
                                resave = true;
                                if (compare) compare = false;
                                break;
                            case 'm':
                                minimal = true;
                                break;
                            case 'n':
                                ignoreErrors = true;
                                break;
                            case 'q':
                                verbose = false;
                                break;
                            case 's':
                                spinner.disable();
                                break;
                            default:
                                err_pr.println("Error: Unknown parameter: " + c);
                                readMe();
                        }
                    }
                }
            } else
                // It's a filename
                fileNames.add(param);
        }

        // Read files from stdin if specified
        if (readStdin) {
            Scanner scan = new Scanner(System.in);
            String s;
            while (scan.hasNextLine()) {
                s = scan.nextLine();
                if (s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"')
                    s = s.substring(1, s.length() - 1);
                fileNames.add(s);
            }
        }

        // Run the specified operation
        if (compare) {
            Data_2da file1, file2;
            file1 = load2da(args[1], bugCompat);
            file2 = load2da(args[2], bugCompat);

            doComparison(file1, file2);
        } else if (resave) {
            Data_2da temp;
            for (String fileName : fileNames) {
                try {
                    temp = load2da(fileName, bugCompat);
                    temp.save2da(new File(fileName).getCanonicalFile().getParent() + File.separator, true, !minimal);
                } catch (Exception e) {
                    // Print the error
                    err_pr.printException(e);
                    // If ignoring errors, and this error is of expected type, continue
                    if (e instanceof IllegalArgumentException ||
                            e instanceof TwoDAReadException ||
                            e instanceof IOException)
                        if (ignoreErrors)
                            continue;
                    System.exit(1);
                }
            }
        } else {
            // Validify by loading
            for (String fileName : fileNames) {
                try {
                    load2da(fileName, bugCompat);
                } catch (Exception e) {
                    // Print the error
                    err_pr.printException(e);
                    // If ignoring errors, and this error is of expected type, continue
                    if (e instanceof IllegalArgumentException || e instanceof TwoDAReadException)
                        if (ignoreErrors)
                            continue;
                    System.exit(1);
                }
            }
        }
    }

    private static void readMe() {
        System.out.println("Usage:\n" +
                "  [-bcrmnqs] file... | -\n" +
                "\n" +
                "  -b    bug-compatibility mode. Counts tabs as whitespace instead of data\n" +
                "  -c    prints the differing lines between the 2das given as first two\n" +
                "        parameters. They must have the same label set and entrycount.\n" +
                "        Mutually exclusive with -r\n" +
                "  -r    resaves the 2das given as parameters. Mutually exclusive with -c\n" +
                "  -m    saves the files with minimal spaces. Only relevant when resaving\n" +
                "  -n    ignores errors that occur during validity testing and resaving,\n" +
                "        just skips to the next file\n" +
                "  -q    quiet mode\n" +
                "  -s    no spinner\n" +
                "  -     a line given as a lone parameter means that the list of files is\n" +
                "        read from stdin in addition to the ones passed from command line.\n" +
                "        The list passed in such manner should contain one filename per line\n" +
                "\n" +
                "  --help  prints this text\n" +
                "\n" +
                "\n" +
                "if neither -c or -r is specified, performs validity testing on the given files"
        );
        System.exit(0);
    }

    /**
     * Compares the given two 2da files and prints differences it finds
     * Differing number of rows, or row names will cause comparison to abort.
     *
     * @param file1 Data_2da containing one of the files to be compared
     * @param file2 Data_2da containing the other file to be compared
     */
    public static void doComparison(Data_2da file1, Data_2da file2) {
        // Check labels
        String[] labels1 = file1.getLabels(),
                labels2 = file2.getLabels();
        if (labels1.length != labels2.length) {
            System.out.println("Differing amount of row labels\n" +
                    file1.getName() + ": " + labels1.length + "\n" +
                    file2.getName() + ": " + labels2.length);
            return;
        }
        for (int i = 0; i < labels1.length; i++) {
            if (!labels1[i].equals(labels2[i])) {
                System.out.println("Differing labels");
                return;
            }
        }

        // Check lengths
        int shortCount = file1.getEntryCount();
        if (file1.getEntryCount() != file2.getEntryCount()) {
            System.out.println("Differing line counts.\n" +
                    file1.getName() + ": " + file1.getEntryCount() + "\n" +
                    file2.getName() + ": " + file2.getEntryCount());

            shortCount = shortCount > file2.getEntryCount() ? file2.getEntryCount() : shortCount;
        }

        // Check elements
        for (int i = 0; i < shortCount; i++) {
            for (String label : labels1) {
                if (!file1.getEntry(label, i).equals(file2.getEntry(label, i))) {
                    System.out.println("Differing entries on row " + i + ", column " + label + "\n" +
                            file1.getName() + ": " + file1.getEntry(label, i) + "\n" +
                            file2.getName() + ": " + file2.getEntry(label, i));
                }
            }
        }
    }

    public String toString() {
        return this.name + "(" + this.getEntryCount() + " entries)";
    }

    /**
     * @see java.lang.Object#toString()
     */
    public String toOutputString() {
        String CRLF = "\r\n";
        StringBuffer toReturn = new StringBuffer();
        boolean evenColumns = true;
        String[] labels = this.getLabels();
        String toWrite;

        // Get the amount of padding used, if any
        int[] widths = new int[labels.length + 1];// All initialised to 0
        ArrayList<String> column;
        int pad;
        // Loop over columns
        for (int i = 0; i < labels.length; i++) {
            pad = labels[i].length();
            column = mainData.get(labels[i]);
            // Loop over rows
            for (int j = 0; j < this.getEntryCount(); j++) {
                toWrite = column.get(j);
                // If the string contains spaces, it needs to be wrapped in "
                if (toWrite.indexOf(" ") != -1)
                    toWrite = "\"" + toWrite + "\"";
                if (toWrite.length() > pad) pad = toWrite.length();
            }
            widths[i] = pad;
        }

        // The last entry in the array is used for the numbers column
        widths[widths.length - 1] = new Integer(this.getEntryCount()).toString().length();

        // Write the header and default lines
        toReturn.append("2DA V2.0" + CRLF);
        if (!defaultValue.equals(""))
            toReturn.append("DEFAULT: " + defaultValue + CRLF);
        else
            toReturn.append(CRLF);

        // Write the labels row using the original case
        for (int i = 0; i < widths[widths.length - 1]; i++) toReturn.append(" ");
        for (int i = 0; i < realLabels.size(); i++) {
            toReturn.append(" " + realLabels.get(i));
            for (int j = 0; j < widths[i] - realLabels.get(i).length(); j++) toReturn.append(" ");
        }
        toReturn.append((TLKEditCompatible ? " " : "") + CRLF);

        // Write the data
        for (int i = 0; i < this.getEntryCount(); i++) {
            // Write the number row and it's padding
            toReturn.append("" + i);
            for (int j = 0; j < widths[widths.length - 1] - new Integer(i).toString().length(); j++)
                toReturn.append(" ");
            // Loop over columns
            for (int j = 0; j < labels.length; j++) {
                toWrite = mainData.get(labels[j]).get(i);
                // If the string contains spaces, it needs to be wrapped in "
                if (toWrite.indexOf(" ") != -1)
                    toWrite = "\"" + toWrite + "\"";
                toReturn.append(" " + toWrite);
                // Write padding
                for (int k = 0; k < widths[j] - toWrite.length(); k++) toReturn.append(" ");
            }
            toReturn.append((TLKEditCompatible ? " " : "") + CRLF);
        }

        return toReturn.toString();
    }

    /**
     * Makes an independent copy of this 2da.
     *
     * @see java.lang.Object#clone()
     */
    @SuppressWarnings("unchecked")
    public Object clone() {
        // Make a sufficiently deep copy of the main data arrays
        LinkedHashMap<String, ArrayList<String>> cloneData = new LinkedHashMap<String, ArrayList<String>>();
        for (String key : this.getLabels()) // Use real labels to preserve order
            cloneData.put(key, (ArrayList<String>) this.mainData.get(key).clone());


        // Create a new Data_2da. The Strings are immutable, so they can be used as-is and clone()
        // on an array produces a sufficiently deep copy right away
        return new Data_2da(
                this.name,
                this.defaultValue,
                (ArrayList<String>) this.realLabels.clone(),
                cloneData
        );
    }
}