/*
 * This file is part of the Amalthea library.
 * Copyright (C) 2018-2021, 2024 Eugene 'Vindex' Stulin
 * Distributed under the BSL 1.0 or the GNU LGPL 3.0 or later.
 */

/**
 * The module contains several useful functions
 * for convenient operations on CSV file contents.
 */
module amalthea.csv;

public import amalthea.libcore;
import amalthea.dataprocessing : getIndex;

import std.algorithm, std.array, std.path, std.string;
import std.format : format;


/*******************************************************************************
 * Structure for working with CSV-tables.
 */
struct CSV {
    private string csvFilePath;
    private char delimiter = ',';

    private string[][] dataTable;
    string[] headersOfColumns;

    /***************************************************************************
     * The constructor takes a path to CSV-file.
     */
    this(string filepath) {
        this.csvFilePath = filepath;
        this.generateDataTable();
    }

    /***************************************************************************
     * The constructor takes a two-dimensional array as the initiating table.
     */
    this(string[][] table) {
        this.createTable(table);
    }

    /***************************************************************************
     * The constructor takes a path to future CSV-file and
     * two-dimensional array as the initiating table.
     * Params:
     *     filepath = Path to file for saving CSV table. The existence of
     *                the file, as well as its contents, do not matter.
     *     table    = 2D array with content for the CSV file.
     */
    this(string filepath, string[][] table) {
        this.csvFilePath = filepath;
        this.createTable(table);
    }

    /***************************************************************************
     * The constructor takes an array of table column names
     * and a two-dimensional array as the initiating table.
     * Params:
     *     keys = Table column titles.
     *     table = 2D  with content for the CSV file (without titles).
     */
    this(string[] keys, string[][] table) {
        createTable(keys, table);
    }

    /***************************************************************************
     * The method returns content of the table of the CSV-object
     * as two-dimensional array.
     */
    string[][] getTable() {
        return this.dataTable.dup;
    }

    /***************************************************************************
     * The method returns rows of the table as associative arrays,
     * where keys are names of the columns and values are content of the fields.
     */
    string[string][] getArrayOfRowMaps() {
        if (this.dataTable.length == 0) {
            return null;
        }
        string[][] table = this.getTable[1 .. $];
        string[string][] arrayOfMaps;
        foreach(row; table) {
            string[string] map;
            foreach(i, header; this.headersOfColumns) {
                map[header] = row[i];
            }
            arrayOfMaps ~= map;
        }
        return arrayOfMaps;
    }

    /***************************************************************************
     * Finds the table field by another field in the same row (key1 and value)
     * and column name (key2). Returns first match only.
     */
    string getValueOfField(string key1, string value, string key2) {
        string[string] row = this.getRowMapByField(key1, value);
        if (key2 !in row) {
            throw new CsvException(format!"'%s': key not found."(key2));
        }
        return row[key2];
    }

    /***************************************************************************
     * The method finds table row by field (by column name and value),
     * returns associative array, where keys are names of columns and
     * values are content of fields of this row.
     */
    string[string] getRowMapByField(string key, string value) {
        auto arrayOfMaps = this.getArrayOfRowMaps();
        if (!arrayOfMaps.empty) {
            if (key !in arrayOfMaps[0]) {
                throw new CsvException(format!"'%s': key not found."(key));
            }
        }
        foreach(mapLine; arrayOfMaps) {
            if (mapLine[key] == value) {
                return mapLine;
            }
        }
        throw new CsvException(format!"'%s': value not found."(value));
    }

    /***************************************************************************
     * The method sets new value (value2)
     * by other field in the same row and column name.
     */
    void setValueOfField(
        string key1, string value1, string key2, string value2
    ) {
        ssize_t index = -1;
        auto arrayOfMaps = getArrayOfRowMaps();
        if (!arrayOfMaps.empty && key1 !in arrayOfMaps[0]) {
            throw new CsvException(format!"'%s': key not found."(key1));
        }
        foreach(i, mapLine; arrayOfMaps) {
            if (mapLine[key1] != value1) {
                continue;
            }
            index = i;
            break;
        }
        if (index == -1) {
            throw new CsvException(format!"'%s': value not found."(value1));
        }
        auto indexForKey2 = headersOfColumns.getIndex(key2);
        if (indexForKey2 == -1) {
            throw new CsvException(format!"'%s': key not found."(key2));
        }
        dataTable[index+1][indexForKey2] = value2;
    }

    /***************************************************************************
     * The methods adds new table row.
     */
    void addRow(string[] row) {
        if (this.headersOfColumns.length > 0) {
            row.length = this.headersOfColumns.length;
        } else {
            this.headersOfColumns ~= row;
        }
        dataTable ~= row;
    }
    void addRow(S...)(S args) {
        string[] row;
        foreach(arg; args) {
            row ~= to!string(arg);
        }
        addRow(row);
    }

    /***************************************************************************
     * The method deletes table row by field (by its key and value).
     */
    void deleteRowByKeyAndValue(string key, string value) {
        ssize_t index = -1;
        auto arrayOfMaps = this.getArrayOfRowMaps();
        if (!arrayOfMaps.empty && key !in arrayOfMaps[0]) {
            throw new CsvException(format!"'%s': key not found."(key));
        }
        foreach(i, mapLine; arrayOfMaps) {
            if (mapLine[key] != value) {
                continue;
            }
            index = i;
            break;
        }
        if (index == -1) {
            throw new CsvException(format!"'%s': value not found."(value));
        }
        // +1 because 0-element is header
        dataTable = dataTable.remove(index + 1);
    }
    alias deleteRowByField = deleteRowByKeyAndValue;

    /***************************************************************************
     * The method writes table content to the initiating file.
     */
    void rewrite() {
        writeTo(csvFilePath);
    }

    /***************************************************************************
     * The method writes table content to the specified file.
     */
    void writeTo(string filepath) {
        auto dir = std.path.dirName(filepath);
        if (dir != "" && !dir.exists) mkdirRecurse(dir);
        auto f = File(filepath, "w");
        string newContent;
        foreach(row; dataTable) {
            foreach(i, field; row) {
                newContent ~= `"`~field.replace(`"`, `""`)~`"`;
                if (i != row.length-1) newContent ~= delimiter;
            }
            newContent ~= "\n";
        }
        f.write(newContent);
        f.flush();
    }

    /***************************************************************************
     * The two-dimensional array will be used as the content of the CSV table.
     */
    void createTable(string[][] table) {
        this.dataTable = table.dup;

        if (!this.dataTable.empty) {
            this.headersOfColumns = this.dataTable[0].dup;
        }
    }

    /***************************************************************************
     * The two-dimensional array will be used as the content of the CSV table.
     * Params:
     *     keys = Titles for columns.
     *     table = 2D array for CSV fields.
     */
    void createTable(string[] keys, string[][] table) {
        if (!table.empty && keys.length != table[0].length) {
            throw new CsvException("Dimensional mismatch.");
        }
        this.dataTable = table.dup;
        this.dataTable = keys ~ table;
        this.headersOfColumns = keys.dup;
    }

    private void generateDataTable() {
        // prepare data
        if (!csvFilePath.exists) {
            return;
        }
        auto f = File(csvFilePath, "r");
        string[][] arr;
        size_t i;
        foreach(line; f.byLine) {
            arr.length++;
            foreach(el; this.splitCSVLine(line.idup)) {
                arr[i] ~= [el];
            }
            ++i;
        }
        if (i == 0) {
            return;
        }

        // validate
        if (!arr.empty) {
            size_t ncol = arr[0].length;
            foreach (row; arr[1 .. $]) {
                if (row.length != ncol) {
                    throw new CsvException("Dimensional mismatch.");
                }
            }
        }

        // store
        this.dataTable = arr.dup;
        string[] keys;
        foreach(headerField; this.dataTable[0]) {
            keys ~= headerField;
        }
        this.headersOfColumns = keys.dup;

    }

    private string[] splitCSVLine(string csvRow) {
        string[] tmp = csvRow.split(delimiter);
        string[] result;
        for(size_t i; i < tmp.length; i++) {
            auto el = tmp[i];
            if (el.startsWith('"')) {
                el = el[1 .. $];
                if (el.endsWith('"')) {
                    el = el[0 .. $-1];
                } else {
                    auto nextIndex = i + 1;
                    foreach(fragment; tmp[nextIndex .. $]) {
                        i++;
                        el ~= delimiter ~ fragment;
                        if (el.endsWith('"')) {
                            el = el[0 .. $-1];
                            break;
                        }
                    }
                }
            }
            result ~= el;
        }
        foreach(ref el; result) {
            if (el != `""`) {
                el = el.replace(`""`, `"`);
            }
        }
        return result;
    }

}


class CsvException : Exception { mixin RealizeException; }


unittest {
    string[][] table = [
        ["name", "age"],
        ["Kristina", "23"],
        ["Yana", "23"],
        ["Xenia", "33"],
        ["Maria", "29"]
    ];
    auto csv = CSV(table);
    string[][] readTable = csv.getTable();
    assert(equal(readTable, table));

    string[string][] expectedMaps = [
        ["name": "Kristina", "age": "23"],
        ["name": "Yana",     "age": "23"],
        ["name": "Xenia",    "age": "33"],
        ["name": "Maria",    "age": "29"]
    ];
    assert(equal(csv.getArrayOfRowMaps(), expectedMaps));

    assert("Xenia" == csv.getValueOfField("age", "33", "name"));
    assert("Kristina" == csv.getValueOfField("age", "23", "name"));
    assert("29" == csv.getValueOfField("name", "Maria", "age"));

    bool notFound = false;
    try {
        writeln(csv.getValueOfField("age", "17", "name"));
    } catch(CsvException e) {
        assert(e.msg == "'17': value not found.");
        notFound = true;
    }
    assert(notFound);
    
    notFound = false;
    try {
        writeln(csv.getValueOfField("city", "Antananarivo", "name"));
    } catch(CsvException e) {
        assert(e.msg == "'city': key not found.");
        notFound = true;
    }
    assert(notFound);

    notFound = false;
    try {
        writeln(csv.getValueOfField("name", "Yana", "surname"));
    } catch(CsvException e) {
        assert(e.msg == "'surname': key not found.");
        notFound = true;
    }
    assert(notFound);

    csv.setValueOfField("name", "Xenia", "age", "34");
    assert("34" == csv.getValueOfField("name", "Xenia", "age"));

    notFound = false;
    try {
        csv.setValueOfField("name", "Valery", "age", "28");
    } catch(CsvException e) {
        assert(e.msg == "'Valery': value not found.");
        notFound = true;
    }
    assert(notFound);

    notFound = false;
    try {
        csv.setValueOfField("name", "Maria", "surname", "Larina");
    } catch(CsvException e) {
        assert(e.msg == "'surname': key not found.");
        notFound = true;
    }
    assert(notFound);

    notFound = false;
    try {
        csv.setValueOfField("id", "0x264", "name", "Secret");
    } catch(CsvException e) {
        assert(e.msg == "'id': key not found.");
        notFound = true;
    }
    assert(notFound);

    csv.addRow(["Kate", "24"]);
    readTable = csv.getTable();
    assert(readTable[$-1] == ["Kate", "24"]);

    csv.deleteRowByKeyAndValue("name", "Kate");
    expectedMaps = [
        ["age": "23", "name": "Kristina"],
        ["age": "23", "name": "Yana"],
        ["age": "34", "name": "Xenia"],
        ["age": "29", "name": "Maria"]
    ];
    assert(equal(csv.getArrayOfRowMaps(), expectedMaps));

    notFound = false;
    try {
        csv.deleteRowByKeyAndValue("name", "Lucy");
    } catch(CsvException e) {
        assert(e.msg == "'Lucy': value not found.");
        notFound = true;
    }
    assert(notFound);
}


unittest {
    auto csv = CSV();
    csv.addRow("name", "age");
    assert(csv.getArrayOfRowMaps().empty);
    csv.addRow("Mary", "34");
    assert(equal(csv.getArrayOfRowMaps(), [["name": "Mary", "age": "34"]]));
}
