module account.data_impl_sqlite;

import std.datetime;

import d2sqlite3;
import handy_http_primitives : Optional;
import slf4d;

import account.data;
import account.model;
import account.dto;
import util.sqlite;
import util.money;
import util.pagination;

class SqliteAccountRepository : AccountRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }

    Optional!Account findById(ulong id) {
        return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id);
    }

    bool existsById(ulong id) {
        return util.sqlite.exists(db, "SELECT id FROM account WHERE id = ?", id);
    }

    Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
        util.sqlite.update(
            db,
            "INSERT INTO account
            (created_at, type, number_suffix, name, currency, description)
            VALUES (?, ?, ?, ?, ?, ?)",
            Clock.currTime(UTC()).toISOExtString(),
            type.id,
            numberSuffix,
            name,
            currency.code,
            description
        );
        ulong accountId = db.lastInsertRowid();
        return findById(accountId).orElseThrow("Couldn't find account!");
    }

    void setArchived(ulong id, bool archived) {
        util.sqlite.update(
            db,
            "UPDATE account SET archived = ? WHERE id = ?",
            archived ? 1 : 0,
            id
        );
    }

    Account update(ulong id, in Account newData) {
        return doTransaction(db, () {
            Account oldAccount = this.findById(id).orElseThrow("Account doesn't exist.");
            bool typeDiff = oldAccount.type != newData.type;
            bool numberSuffixDiff = oldAccount.numberSuffix != newData.numberSuffix;
            bool nameDiff = oldAccount.name != newData.name;
            bool currencyDiff = oldAccount.currency != newData.currency;
            bool descriptionDiff = oldAccount.description != newData.description;
            util.sqlite.update(
                db,
                q"SQL
                UPDATE account
                SET type = ?,
                    number_suffix = ?,
                    name = ?,
                    currency = ?,
                    description = ?
                WHERE id = ?
SQL",
                newData.type.id,
                newData.numberSuffix,
                newData.name,
                newData.currency.code,
                newData.description,
                id
            );
            return this.findById(id).orElseThrow("Account doesn't exist");
        });
    }

    void deleteById(ulong id) {
        doTransaction(db, () {
            // Delete all associated transactions.
            util.sqlite.update(
                db,
                "DELETE FROM \"transaction\" WHERE id IN " ~
                "(SELECT transaction_id FROM account_journal_entry WHERE account_id = ?)",
                id
            );
            // Delete the account itself (and all cascaded entities, like journal entries).
            util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id);
            /* Delete all orphaned history entries for journal entries referencing deleted transactions.
            This is needed because even though the above `DELETE` cascades to
            delete all history items for this account, there are some history
            items linked to other accounts which aren't deleted automatically.
            */
            util.sqlite.update(
                db,
                "DELETE FROM account_history_item WHERE type LIKE 'JOURNAL_ENTRY' AND id NOT IN (
                    SELECT item_id FROM history_item_linked_journal_entry
                )"
            );
        });
    }

    Account[] findAll() {
        return util.sqlite.findAll(db, "SELECT * FROM account", &parseAccount);
    }

    AccountCreditCardProperties getCreditCardProperties(ulong id) {
        Account account = findById(id).orElseThrow("Account doesn't exist.");
        if (account.type != AccountTypes.CREDIT_CARD) throw new Exception("Account is not credit card.");
        auto optionalProps = findOne(
            db,
            "SELECT * FROM account_credit_card_properties WHERE account_id = ?",
            &parseCreditCardProperties,
            id
        );
        if (!optionalProps.isNull) return optionalProps.value;
        // No properties exist, so set them and return the new data.
        const props = AccountCreditCardProperties(account.id, -1);
        util.sqlite.update(
            db,
            "INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",
            props.account_id,
            props.creditLimit
        );
        return props;
    }

    void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) {
        bool hasProps = exists(db, "SELECT * FROM account_credit_card_properties WHERE account_id = ?", id);
        if (hasProps) {
            util.sqlite.update(
                db,
                "UPDATE account_credit_card_properties SET credit_limit = ? WHERE account_id = ?",
                props.creditLimit,
                id
            );
        } else {
            util.sqlite.update(
                db,
                "INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",
                id,
                props.creditLimit
            );
        }
    }

    private static struct BaseHistoryItem {
        ulong id;
        string timestamp;
        string type;
    }

    Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) {
        ulong count = util.sqlite.count(
            db,
            "SELECT COUNT(id) FROM account_history_item WHERE account_id = ?",
            accountId
        );
        const baseQuery = "SELECT id, timestamp, type " ~
            "FROM account_history_item " ~
            "WHERE account_id = ?";
        string query = baseQuery ~ " " ~ pagination.toSql();
        // First fetch the basic information about each item.
        BaseHistoryItem[] items = util.sqlite.findAll(
            db,
            query,
            (row) {
                return BaseHistoryItem(
                    row.peek!ulong(0),
                    row.peek!string(1),
                    row.peek!string(2)
                );
            },
            accountId
        );
        // Then we'll do another query based on the type of history item.
        // This is fine for paginated responses.
        AccountHistoryItemResponse[] results;
        foreach (BaseHistoryItem item; items) {
            if (item.type == AccountHistoryItemType.ValueRecord) {
                results ~= fetchValueRecordHistoryItem(item);
            } else if (item.type == AccountHistoryItemType.JournalEntry) {
                results ~= fetchJournalEntryHistoryItem(item);
            } else {
                AccountHistoryItemResponse base = new AccountHistoryItemResponse();
                base.timestamp = item.timestamp;
                base.type = item.type;
                results ~= base;
            }
        }
        return Page!AccountHistoryItemResponse.of(results, pagination, count);
    }

    private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
        return util.sqlite.findOne(
            db,
            "SELECT vr.id, vr.type, vr.value, vr.currency, vr.timestamp FROM history_item_linked_value_record h " ~
            "LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~
            "WHERE h.item_id = ?",
            (row) {
                auto obj = new AccountHistoryValueRecordItemResponse();
                obj.timestamp = row.peek!string(4);
                obj.type = item.type;
                obj.valueRecordId = row.peek!ulong(0);
                obj.valueRecordType = row.peek!string(1);
                obj.value = row.peek!long(2);
                obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(3));
                return obj;
            },
            item.id
        ).orElseThrow();
    }

    private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) {
        return util.sqlite.findOne(
            db,
            "SELECT je.type, je.amount, je.currency, tx.id, tx.description, tx.timestamp " ~
            "FROM history_item_linked_journal_entry h " ~
            "LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~
            "LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~
            "WHERE h.item_id = ?",
            (row) {
                auto obj = new AccountHistoryJournalEntryItemResponse();
                obj.timestamp = row.peek!string(5);
                obj.type = item.type;
                obj.journalEntryType = row.peek!string(0);
                obj.amount = row.peek!ulong(1);
                obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(2));
                obj.transactionId = row.peek!ulong(3);
                obj.transactionDescription = row.peek!string(4);
                return obj;
            },
            item.id
        ).orElseThrow();
    }

    static Account parseAccount(Row row) {
        return Account(
            row.peek!ulong(0),
            SysTime.fromISOExtString(row.peek!string(1)),
            row.peek!bool(2),
            AccountType.fromId(row.peek!string(3)),
            row.peek!string(4),
            row.peek!string(5),
            Currency.ofCode(row.peek!string(6)),
            row.peek!string(7)
        );
    }

    static AccountCreditCardProperties parseCreditCardProperties(Row row) {
        import std.typecons : Nullable;
        ulong accountId = row.peek!ulong(0);
        Nullable!ulong creditLimit = row.peek!ulong(1);
        return AccountCreditCardProperties(
            accountId,
            creditLimit.isNull ? -1 : creditLimit.get()
        );
    }
}

class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }

    Optional!AccountJournalEntry findById(ulong id) {
        return util.sqlite.findById(db, "account_journal_entry", &parseEntry, id);
    }

    AccountJournalEntry insert(
        SysTime timestamp,
        ulong accountId,
        ulong transactionId,
        ulong amount,
        AccountJournalEntryType type,
        Currency currency
    ) {
        util.sqlite.update(
            db,
            "INSERT INTO account_journal_entry
            (timestamp, account_id, transaction_id, amount, type, currency)
            VALUES (?, ?, ?, ?, ?, ?)",
            timestamp.toISOExtString(),
            accountId,
            transactionId,
            amount,
            type,
            currency.code
        );
        ulong journalEntryId = db.lastInsertRowid();
        // Insert a history item that links to the journal entry.
        ulong historyItemId = insertNewAccountHistoryItem(
            db, timestamp, accountId, AccountHistoryItemType.JournalEntry);
        util.sqlite.update(
            db,
            "INSERT INTO history_item_linked_journal_entry (item_id, journal_entry_id) VALUES (?, ?)",
            historyItemId,
            journalEntryId
        );
        return findById(journalEntryId).orElseThrow();
    }

    void deleteById(ulong id) {
        util.sqlite.update(
            db,
            "DELETE FROM account_history_item WHERE id IN " ~
            "(SELECT item_id FROM history_item_linked_journal_entry WHERE journal_entry_id = ?)",
            id
        );
        util.sqlite.deleteById(db, "account_journal_entry", id);
    }

    void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
        util.sqlite.update(
            db,
            "DELETE FROM account_history_item WHERE id IN " ~
            "(SELECT j.item_id FROM history_item_linked_journal_entry j " ~
            "   LEFT JOIN account_journal_entry je ON je.id = j.journal_entry_id " ~
            "WHERE je.account_id = ? AND je.transaction_id = ?)",
            accountId, transactionId
        );
        util.sqlite.update(
            db,
            "DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?",
            accountId, transactionId
        );
    }

    AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) {
        const query = "SELECT * FROM account_journal_entry " ~
            "WHERE timestamp >= ? AND timestamp <= ? AND account_id = ? " ~
            "ORDER BY timestamp ASC";
        return util.sqlite.findAll(
            db,
            query,
            &parseEntry,
            startIncl.toISOExtString(), endIncl.toISOExtString(), accountId
        );
    }

    static AccountJournalEntry parseEntry(Row row) {
        string typeStr = row.peek!(string, PeekMode.slice)(5);
        AccountJournalEntryType type;
        if (typeStr == "CREDIT") {
            type = AccountJournalEntryType.CREDIT;
        } else if (typeStr == "DEBIT") {
            type = AccountJournalEntryType.DEBIT;
        } else {
            throw new Exception("Invalid account journal entry type: " ~ typeStr);
        }
        return AccountJournalEntry(
            row.peek!ulong(0),
            SysTime.fromISOExtString(row.peek!string(1)),
            row.peek!ulong(2),
            row.peek!ulong(3),
            row.peek!ulong(4),
            type,
            Currency.ofCode(row.peek!(string, PeekMode.slice)(6))
        );
    }
}

class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }

    Optional!AccountValueRecord findById(ulong accountId, ulong id) {
        return util.sqlite.findOne(
            db,
            "SELECT * FROM account_value_record WHERE account_id = ? AND id = ?",
            &parseValueRecord,
            accountId, id
        );
    }

    Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr) {
        const baseQuery = "SELECT * FROM account_value_record WHERE account_id = ?";
        string query = baseQuery ~ " " ~ pr.toSql();
        AccountValueRecord[] records = util.sqlite.findAll(
            db,
            query,
            &parseValueRecord,
            accountId
        );
        ulong count = util.sqlite.count(
            db,
            "SELECT COUNT(id) FROM account_value_record WHERE account_id = ?",
            accountId
        );
        return Page!AccountValueRecord.of(records, pr, count);
    }
    
    AccountValueRecord insert(
        SysTime timestamp,
        ulong accountId,
        AccountValueRecordType type,
        long value,
        Currency currency
    ) {
        util.sqlite.update(
            db,
            "INSERT INTO account_value_record " ~
            "(timestamp, account_id, type, value, currency) " ~
            "VALUES (?, ?, ?, ?, ?)",
            timestamp.toISOExtString(),
            accountId,
            type,
            value,
            currency.code
        );
        ulong valueRecordId = db.lastInsertRowid();
        // Insert a history item that links to this value record.
        ulong historyItemId = insertNewAccountHistoryItem(
            db, timestamp, accountId, AccountHistoryItemType.ValueRecord);
        util.sqlite.update(
            db,
            "INSERT INTO history_item_linked_value_record (item_id, value_record_id) VALUES (?, ?)",
            historyItemId,
            valueRecordId
        );
        return findById(accountId, valueRecordId).orElseThrow();
    }

    void linkAttachment(ulong valueRecordId, ulong attachmentId) {
        util.sqlite.update(
            db,
            "INSERT INTO account_value_record_attachment (value_record_id, attachment_id) VALUES (?, ?)",
            valueRecordId,
            attachmentId
        );
    }

    void deleteById(ulong accountId, ulong id) {
        // First delete any associated history items:
        util.sqlite.update(
            db,
            "DELETE FROM account_history_item WHERE id IN " ~
            "(SELECT item_id FROM history_item_linked_value_record WHERE value_record_id = ?)",
            id
        );
        util.sqlite.update(
            db,
            "DELETE FROM account_value_record WHERE account_id = ? AND id = ?",
            accountId, id
        );
    }

    Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) {
        const query = "SELECT * FROM account_value_record " ~
            "WHERE account_id = ? AND timestamp < ? " ~
            "ORDER BY timestamp DESC LIMIT 1";
        return util.sqlite.findOne(
            db,
            query,
            &parseValueRecord,
            accountId, timestamp.toISOExtString()
        );
    }

    Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
        const query = "SELECT * FROM account_value_record " ~
            "WHERE account_id = ? AND timestamp > ? " ~
            "ORDER BY timestamp ASC LIMIT 1";
        return util.sqlite.findOne(
            db,
            query,
            &parseValueRecord,
            accountId, timestamp.toISOExtString()
        );
    }

    static AccountValueRecord parseValueRecord(Row row) {
        string typeStr = row.peek!(string, PeekMode.slice)(3);
        AccountValueRecordType type;
        if (typeStr == "BALANCE") {
            type = AccountValueRecordType.BALANCE;
        } else {
            throw new Exception("Invalid account value record type: " ~ typeStr);
        }
        return AccountValueRecord(
            row.peek!ulong(0),
            parseISOTimestamp(row, 1),
            row.peek!ulong(2),
            type,
            row.peek!long(4),
            Currency.ofCode(row.peek!(string, PeekMode.slice)(5))
        );
    }
}

private ulong insertNewAccountHistoryItem(
    ref Database db,
    SysTime timestamp,
    ulong accountId,
    AccountHistoryItemType type
) {
    util.sqlite.update(
        db,
        "INSERT INTO account_history_item (account_id, timestamp, type) VALUES (?, ?, ?)",
        accountId,
        timestamp.toISOExtString(),
        type
    );
    return db.lastInsertRowid();
}
