/*
 * This file is part of the Amalthea library.
 * Copyright (C) 2018-2021, 2024, 2026 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;

import std.algorithm;
import std.array;
import std.conv : to;
import std.file : mkdirRecurse;
import std.format : format;
import std.path;
import std.stdio : File;
import std.string;

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


/*******************************************************************************
 * 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() const {
        string[][] res;
        foreach(subArr; this.dataTable.dup) {
            res ~= subArr.dup;
        }
        return res;
    }

    /***************************************************************************
     * 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() const {
        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) const {
        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) const {
        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() const {
        writeTo(csvFilePath);
    }

    /***************************************************************************
     * The method writes table content to the specified file.
     */
    void writeTo(string filepath) const {
        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 + 1 != row.length) 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[] result;
        bool inQuotes;
        size_t fieldPos;
        foreach(i, char ch; csvRow) {
            bool end = csvRow.length == i + 1;
            if (ch == '"') {
                inQuotes = !inQuotes;
            }
            if (end || (ch == delimiter && !inQuotes)) {
                string value;
                value = end ? csvRow[fieldPos .. i + 1] : csvRow[fieldPos .. i];
                if (value.length > 1 && value[0] == '"' && value[$-1] == '"') {
                    value = value[1 .. $-1];
                }
                result ~= value;
                fieldPos = i + 1;
            }
        }
        foreach(ref el; result) {
            if (el != `""`) {
                el = el.replace(`""`, `"`);
            }
        }
        return result;
    }

}


class CsvException : Exception { mixin RealizeException; }


unittest {
    import std.stdio;

    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"]]));
}
