module transaction.data_impl_sqlite;

import handy_http_primitives : Optional, StringMultiValueMap, mapIfPresent, toOptional;
import util.data;
import std.datetime;
import std.typecons;
import d2sqlite3;

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

class SqliteTransactionVendorRepository : TransactionVendorRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }
    
    Optional!TransactionVendor findById(ulong id) {
        return util.sqlite.findById(db, "transaction_vendor", &parseVendor, id);
    }

    TransactionVendor[] findAll() {
        return util.sqlite.findAll(
            db,
            "SELECT * FROM transaction_vendor ORDER BY name ASC",
            &parseVendor
        );
    }

    bool existsByName(string name) {
        return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name);
    }

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

    TransactionVendor insert(string name, string description) {
        util.sqlite.update(
            db,
            "INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
            name, description
        );
        ulong id = db.lastInsertRowid();
        return findById(id).orElseThrow();
    }

    void deleteById(ulong id) {
        util.sqlite.deleteById(db, "transaction_vendor", id);
    }

    TransactionVendor updateById(ulong id, string name, string description) {
        util.sqlite.update(
            db,
            "UPDATE transaction_vendor SET name = ?, description = ? WHERE id = ?",
            name, description, id
        );
        return findById(id).orElseThrow();
    }

    private static TransactionVendor parseVendor(Row row) {
        return TransactionVendor(
            row.peek!ulong(0),
            row.peek!string(1),
            row.peek!string(2)
        );
    }
}

class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }
    
    Optional!TransactionCategory findById(ulong id) {
        return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
    }

    Optional!TransactionCategory findByName(string name) {
        return util.sqlite.findOne(db, "SELECT * FROM transaction_category WHERE name = ?", &parseCategory, name);
    }

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

    bool existsByName(string name) {
        return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE name = ?", name);
    }

    TransactionCategory[] findAll() {
        return util.sqlite.findAll(
            db,
            "SELECT * FROM transaction_category ORDER BY parent_id, name",
            &parseCategory
        );
    }

    TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
        if (parentId) {
            return util.sqlite.findAll(
                db,
                "SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
                &parseCategory,
                parentId.value
            );
        }
        return util.sqlite.findAll(
            db,
            "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
            &parseCategory
        );
    }

    TransactionCategory insert(Optional!ulong parentId, string name, string description, string color) {
        util.sqlite.update(
            db,
            "INSERT INTO transaction_category
            (parent_id, name, description, color)
            VALUES (?, ?, ?, ?)",
            toNullable(parentId), name, description, color
        );
        ulong id = db.lastInsertRowid();
        return findById(id).orElseThrow();
    }

    void deleteById(ulong id) {
        util.sqlite.deleteById(db, "transaction_category", id);
    }

    TransactionCategory updateById(ulong id, string name, string description, string color, Optional!ulong parentId) {
        util.sqlite.update(
            db,
            "UPDATE transaction_category
            SET name = ?, description = ?, color = ?, parent_id = ?
            WHERE id = ?",
            name, description, color, toNullable!ulong(parentId), id
        );
        return findById(id).orElseThrow();
    }

    TransactionCategoryBalance[] getBalance(
        ulong id,
        bool includeChildren,
        Optional!SysTime afterTimestamp,
        Optional!SysTime beforeTimestamp
    ) {
        import std.algorithm : map;
        import std.conv : to;
        import std.string : join;
        // First collect the list of IDs to include in the query.
        ulong[] idsForQuery = [id];
        if (includeChildren) {
            import std.range : front, popFront;
            ulong[] idQueue = [id];
            while (idQueue.length > 0) {
                ulong nextId = idQueue.front;
                idQueue.popFront;
                auto children = findAllByParentId(Optional!ulong.of(nextId));
                foreach (child; children) {
                    idsForQuery ~= child.id;
                    idQueue ~= child.id;
                }
            }
        }
        const categoryIdsString = idsForQuery.map!(id => id.to!string).join(",");

        // Now build the query, taking into account the optional timestamp constraints.
        QueryBuilder qb = QueryBuilder("\"transaction\" txn")
            .join("LEFT JOIN account_journal_entry je ON je.transaction_id = txn.id")
            .select("je.currency")
            .select("je.type")
            .select("SUM(je.amount)")
            .where("txn.category_id IN (" ~ categoryIdsString ~ ")");
        if (!afterTimestamp.isNull) {
            qb.where("txn.timestamp > ?")
                .withArgBinding((ref stmt, ref idx) {
                    stmt.bind(idx++, afterTimestamp.value.toISOExtString());
                });
        }
        if (!beforeTimestamp.isNull) {
            qb.where("txn.timestamp < ?")
                .withArgBinding((ref stmt, ref idx) {
                    stmt.bind(idx++, beforeTimestamp.value.toISOExtString());
                });
        }
        string query = qb.build() ~ " GROUP BY je.currency, je.type ORDER BY je.currency ASC, je.type ASC";
        Statement stmt = db.prepare(query);
        qb.applyArgBindings(stmt);
        ResultRange result = stmt.execute();

        // Process the results into a set of category balances for each currency.
        TransactionCategoryBalance[ushort] balancesGroupedByCurrency;
        foreach (row; result) {
            Currency currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(0));
            string journalEntryType = row.peek!(string, PeekMode.slice)(1);
            ulong amountSum = row.peek!ulong(2);

            TransactionCategoryBalance balance;
            if (currency.numericCode in balancesGroupedByCurrency) {
                balance = balancesGroupedByCurrency[currency.numericCode];
            } else {
                balance = TransactionCategoryBalance(0, 0, 0, currency);
            }
            if (journalEntryType == AccountJournalEntryType.CREDIT) {
                balance.credits = amountSum;
            } else if (journalEntryType == AccountJournalEntryType.DEBIT) {
                balance.debits = amountSum;
            }
            balancesGroupedByCurrency[currency.numericCode] = balance;
        }

        // Post-process into a list of balances for returning:
        TransactionCategoryBalance[] balances = balancesGroupedByCurrency.values;
        foreach (ref bal; balances) {
            bal.balance = cast(long) bal.debits - cast(long) bal.credits;
        }
        return balances;
    }

    private static TransactionCategory parseCategory(Row row) {
        import std.typecons;
        return TransactionCategory(
            row.peek!ulong(0),
            row.parseOptional!ulong(1),
            row.peek!string(2),
            row.peek!string(3),
            row.peek!string(4)
        );
    }
}

class SqliteTransactionTagRepository : TransactionTagRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }

    string[] findAllByTransactionId(ulong transactionId) {
        return util.sqlite.findAll(
            db,
            "SELECT tag FROM transaction_tag WHERE transaction_id = ? ORDER BY tag",
            r => r.peek!string(0),
            transactionId
        );
    }

    void updateTags(ulong transactionId, in string[] tags) {
        util.sqlite.update(
            db,
            "DELETE FROM transaction_tag WHERE transaction_id = ?",
            transactionId
        );
        foreach (tag; tags) {
            util.sqlite.update(
                db,
                "INSERT INTO transaction_tag (transaction_id, tag) VALUES (?, ?)",
                transactionId, tag
            );
        }
    }

    string[] findAll() {
        return util.sqlite.findAll(
            db,
            "SELECT DISTINCT tag FROM transaction_tag ORDER BY tag",
            r => r.peek!string(0)
        );
    }
}

class SqliteTransactionRepository : TransactionRepository {
    private const TABLE_NAME = "\"transaction\"";
    private Database db;
    this(Database db) {
        this.db = db;
    }

    Page!TransactionsListItem findAll(in PageRequest pr) {
        string query = import("sql/query/get_transactions.sql") ~ "\n" ~ pr.toSql();
        TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
        ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
        return Page!(TransactionsListItem).of(results, pr, totalCount);
    }

    Page!TransactionsListItem search(in PageRequest pr, in StringMultiValueMap searchParams) {
        import std.algorithm;
        import std.conv;
        import std.string : join;
        import transaction.search_filters;
        import std.stdio;
        QueryBuilder qb = getBuilderForTransactionsList();

        // 1. Get the total count of transactions that match the search filters.
        qb.select("COUNT (DISTINCT txn.id)");
        applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db));
        string countQuery = qb.build();
        Statement countStmt = db.prepare(countQuery);
        qb.applyArgBindings(countStmt);
        auto countResult = countStmt.execute();
        ulong count = countResult.empty ? 0 : countResult.front.peek!ulong(0);
        if (count == 0) return Page!TransactionsListItem.of([], pr, 0);

        // 2. Select the ordered list of IDs of transactions to include in the page of results.
        // This is because one transaction may be split over multiple db rows.
        qb.selections = [];
        qb.select("DISTINCT txn.id");
        string idsQuery = qb.build() ~ "\n" ~ pr.toSql();
        Statement idsStmt = db.prepare(idsQuery);
        qb.applyArgBindings(idsStmt);
        string idsStr = idsStmt.execute()
            .map!(r => r.peek!ulong(0))
            .map!(id => id.to!string)
            .join(",");
        
        // 3. Select the list of transactions for the results based on the IDs found in step 2.
        qb.selections = [];
        qb.conditions = [];
        qb.argBinders = [];
        addSelectsForTransactionsList(qb);
        qb.groupBy("txn.id");
        qb.where("txn.id IN (" ~ idsStr ~ ")");
        string query = qb.build() ~ "\n" ~ pr.toOrderClause();
        TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem);
        return Page!TransactionsListItem.of(results, pr, count);
    }

    AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams) {
        import transaction.search_filters;
        // Start by using the standard transactions-list query builder to apply filters.
        QueryBuilder qb = getBuilderForTransactionsList();
        qb.select("txn.currency AS currency, j_credit.amount AS credits, j_debit.amount AS debits");
        applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db));
        string baseQuery = qb.build() ~ "\nGROUP BY txn.id";
        // Now wrap that in a separate query that aggregates credits & debits for each transaction.
        string aggregateQuery = QueryBuilder("(" ~ baseQuery ~ ") AS base")
            .select("base.currency")
            .select("SUM(base.credits)")
            .select("SUM(base.debits)")
            .build() ~ " GROUP BY base.currency";
        Statement stmt = db.prepare(aggregateQuery);
        // Use the base query's argument bindings to apply to the DB statement.
        qb.applyArgBindings(stmt);
        ResultRange result = stmt.execute();
        
        // Collect the results for each currency into 
        AggregateTransactionData aggregateData;
        while (!result.empty()) {
            AggregateTransactionData.CurrencyData currencyData;
            currencyData.credits = result.front.peek!ulong(1);
            currencyData.debits = result.front.peek!ulong(2);
            currencyData.balance = currencyData.debits - currencyData.credits;
            currencyData.currency = Currency.ofCode(result.front.peek!(string, PeekMode.slice)(0));
            aggregateData.currencies ~= currencyData;
            result.popFront();
        }
        return aggregateData;
    }

    Optional!TransactionDetail findById(ulong id) {
        Optional!TransactionDetail item = util.sqlite.findOne(
            db,
            import("sql/query/get_transaction.sql"),
            (row) {
                TransactionDetail item;
                item.id = row.peek!ulong(0);
                item.timestamp = row.peek!string(1);
                item.addedAt = row.peek!string(2);
                item.amount = row.peek!ulong(3);
                item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
                item.description = row.peek!string(5);
                item.internalTransfer = row.peek!bool(6);

                Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
                if (!vendorId.isNull) {
                    item.vendor = Optional!TransactionVendor.of(TransactionVendor(
                        vendorId.get,
                        row.peek!string(8),
                        row.peek!string(9)
                    ));
                }
                Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
                if (!categoryId.isNull) {
                    item.category = Optional!TransactionCategory.of(TransactionCategory(
                        categoryId.get,
                        row.parseOptional!ulong(11),
                        row.peek!string(12),
                        row.peek!string(13),
                        row.peek!string(14)
                    ));
                }
                Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
                if (!creditedAccountId.isNull) {
                    item.creditedAccount = Optional!SimpleAccountResponse.of(
                        SimpleAccountResponse(
                            creditedAccountId.get,
                            row.peek!string(16),
                            row.peek!string(17),
                            row.peek!string(18)
                        ));
                }
                Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
                if (!debitedAccountId.isNull) {
                    item.debitedAccount = Optional!SimpleAccountResponse.of(
                        SimpleAccountResponse(
                            debitedAccountId.get,
                            row.peek!string(20),
                            row.peek!string(21),
                            row.peek!string(22)
                        ));
                }
                string tagsStr = row.peek!string(23);
                if (tagsStr !is null && tagsStr.length > 0) {
                    import std.string : split;
                    item.tags = tagsStr.split(",");
                } else {
                    item.tags = [];
                }
                return item;
            },
            id
        );
        if (item.isNull) return item;
        item.value.lineItems = util.sqlite.findAll(
            db,
            import("sql/query/get_line_items.sql"),
            (row) {
                TransactionLineItemResponse li;
                li.idx = row.peek!uint(0);
                li.valuePerItem = row.peek!long(1);
                li.quantity = row.peek!ulong(2);
                li.description = row.peek!string(3);
                Optional!ulong categoryId = row.parseOptional!ulong(4);
                if (categoryId) {
                    li.category = Optional!TransactionCategory.of(
                        TransactionCategory(
                            categoryId.value,
                            row.parseOptional!ulong(5),
                            row.peek!string(6),
                            row.peek!string(7),
                            row.peek!string(8)
                        ));
                }
                return li;
            },
            id
        );
        return item;
    }

    TransactionDetail insert(in AddTransactionPayload data) {
        util.sqlite.update(
            db,
            import("sql/insert_transaction.sql"),
            data.timestamp,
            Clock.currTime(UTC()).toISOExtString(),
            data.amount,
            data.currencyCode,
            data.description,
            data.internalTransfer,
            data.vendorId.toNullable(),
            data.categoryId.toNullable()
        );
        ulong transactionId = db.lastInsertRowid();
        insertLineItems(transactionId, data);
        return findById(transactionId).orElseThrow();
    }

    void linkAttachment(ulong transactionId, ulong attachmentId) {
        util.sqlite.update(
            db,
            "INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)",
            transactionId,
            attachmentId
        );
    }

    TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
        util.sqlite.update(
            db,
            import("sql/update_transaction.sql"),
            data.timestamp,
            data.amount,
            data.currencyCode,
            data.description,
            data.internalTransfer,
            data.vendorId.toNullable(),
            data.categoryId.toNullable(),
            transactionId
        );
        // Re-write all line items:
        util.sqlite.update(
            db,
            "DELETE FROM transaction_line_item WHERE transaction_id = ?",
            transactionId
        );
        insertLineItems(transactionId, data);
        return findById(transactionId).orElseThrow();
    }

    void deleteById(ulong id) {
        util.sqlite.deleteById(db, TABLE_NAME, id);
        // Delete all history items for journal entries that have been removed by the deletion.
        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
            )"
        );
    }

    private static TransactionsListItem parseListItem(Row row) {
        TransactionsListItem item;
        item.id = row.peek!ulong(0);
        item.timestamp = row.peek!string(1);
        item.addedAt = row.peek!string(2);
        item.amount = row.peek!ulong(3);
        item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
        item.description = row.peek!string(5);
        item.internalTransfer = row.peek!bool(6);
        Optional!ulong vendorId = row.parseOptional!ulong(7);
        if (vendorId) {
            item.vendor = SimpleVendorResponse(
                vendorId.value,
                row.peek!string(8)
            ).toOptional;
        }
        Optional!ulong categoryId = row.parseOptional!ulong(9);
        if (categoryId) {
            item.category = SimpleCategoryResponse(
                categoryId.value,
                row.peek!string(10),
                row.peek!string(11)
            ).toOptional;
        }
        Optional!ulong creditedAccountId = row.parseOptional!ulong(12);
        if (creditedAccountId) {
            item.creditedAccount = SimpleAccountResponse(
                creditedAccountId.value,
                row.peek!string(13),
                row.peek!string(14),
                row.peek!string(15)
            ).toOptional;
        }
        Optional!ulong debitedAccountId = row.parseOptional!ulong(16);
        if (debitedAccountId) {
            item.debitedAccount = SimpleAccountResponse(
                debitedAccountId.value,
                row.peek!string(17),
                row.peek!string(18),
                row.peek!string(19)
            ).toOptional;
        }
        string aggregateTags = row.peek!string(20);
        if (aggregateTags !is null) {
            import std.string : split;
            import std.algorithm : sort;
            item.tags = aggregateTags.split(",");
            sort(item.tags);
        }
        return item;
    }

    private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
        foreach (size_t idx, lineItem; data.lineItems) {
            util.sqlite.update(
                db,
                import("sql/insert_line_item.sql"),
                transactionId,
                idx,
                lineItem.valuePerItem,
                lineItem.quantity,
                lineItem.description,
                lineItem.categoryId.toNullable()
            );
        }
    }

    private QueryBuilder getBuilderForTransactionsList() {
        return QueryBuilder("\"transaction\" txn")
            .join("LEFT JOIN transaction_vendor vendor ON vendor.id = txn.vendor_id")
            .join("LEFT JOIN transaction_category category ON category.id = txn.category_id")
            .join("LEFT JOIN account_journal_entry j_credit " ~
                "ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'")
            .join("LEFT JOIN account account_credit ON account_credit.id = j_credit.account_id")
            .join("LEFT JOIN account_journal_entry j_debit " ~
                "ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'")
            .join("LEFT JOIN account account_debit ON account_debit.id = j_debit.account_id")
            .join("LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id");
    }

    private void addSelectsForTransactionsList(ref QueryBuilder qb) {
        qb
            .select("txn.id")
            .select("txn.timestamp")
            .select("txn.added_at")
            .select("txn.amount")
            .select("txn.currency")
            .select("txn.description")
            .select("txn.internal_transfer")
            .select("txn.vendor_id")
            .select("vendor.name")
            .select("txn.category_id")
            .select("category.name")
            .select("category.color")
            .select("account_credit.id")
            .select("account_credit.name")
            .select("account_credit.type")
            .select("account_credit.number_suffix")
            .select("account_debit.id")
            .select("account_debit.name")
            .select("account_debit.type")
            .select("account_debit.number_suffix")
            .select("GROUP_CONCAT(tags.tag)");
    }
}

class SqliteTransactionDraftRepository : TransactionDraftRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }
    
    Page!TransactionDraftListItem findAllDrafts(in PageRequest pr) {
        return findAllInternal(pr, DraftType.DRAFT.toOptional);
    }

    Page!TransactionDraftListItem findAllTemplates(in PageRequest pr) {
        return findAllInternal(pr, DraftType.TEMPLATE.toOptional);
    }

    Page!TransactionDraftListItem findAll(in PageRequest pr) {
        return findAllInternal(pr, Optional!DraftType.empty());
    }

    private static enum DraftType {
        DRAFT,
        TEMPLATE
    }

    private Page!TransactionDraftListItem findAllInternal(in PageRequest pr, Optional!DraftType type) {
        QueryBuilder qb = getBuilderForDraftsList();
        addSelectsForDraftsList(qb);
        qb.groupBy("draft.id");
        if (type) {
            if (type.value == DraftType.DRAFT) {
                qb.where("template_name IS NULL");
            } else {
                qb.where("template_name IS NOT NULL");
            }
        }
        string query = qb.build() ~ "\n" ~ pr.toSql();
        TransactionDraftListItem[] results = util.sqlite.findAll(db, query, &parseDraftListItem);
        ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM transaction_draft");
        return Page!(TransactionDraftListItem).of(results, pr, totalCount);
    }

    Optional!TransactionDraftResponse findById(ulong id) {
        // First fetch the draft list item (contains all basic properties).
        QueryBuilder qb = getBuilderForDraftsList();
        addSelectsForDraftsList(qb);
        qb.groupBy("draft.id");
        qb.where("draft.id = ?");
        string query = qb.build();
        Optional!TransactionDraftListItem li = util.sqlite.findOne(db, query, &parseDraftListItem, id);
        if (li.isNull) return Optional!TransactionDraftResponse.empty();
        TransactionDraftListItem draft = li.value;
        // Then fetch line items.
        TransactionDraftResponse response;
        response.id = draft.id;
        response.addedAt = draft.addedAt;
        response.templateName = draft.templateName;
        response.timestamp = draft.timestamp;
        response.amount = draft.amount;
        response.currency = draft.currency;
        response.description = draft.description;
        response.internalTransfer = draft.internalTransfer;
        response.vendor = draft.vendor;
        response.category = draft.category;
        response.creditedAccount = draft.creditedAccount;
        response.debitedAccount = draft.debitedAccount;
        response.tags = li.value.tags;
        response.lineItems = util.sqlite.findAll(
            db,
            import("sql/query/get_line_items_draft.sql"),
            (row) {
                TransactionLineItemResponse item;
                item.idx = row.peek!uint(0);
                item.valuePerItem = row.peek!long(1);
                item.quantity = row.peek!ulong(2);
                item.description = row.peek!string(3);
                Optional!ulong categoryId = row.parseOptional!ulong(4);
                if (categoryId) {
                    item.category = Optional!TransactionCategory.of(
                        TransactionCategory(
                            categoryId.value,
                            row.parseOptional!ulong(5),
                            row.peek!string(6),
                            row.peek!string(7),
                            row.peek!string(8)
                        ));
                }
                return item;
            },
            draft.id
        );
        // Return the response, excluding attachments (they are fetched using the attachment repo).
        return Optional!TransactionDraftResponse.of(response);
    }

    TransactionDraftResponse insert(in TransactionDraftPayload data) {
        util.sqlite.update(
            db,
            import("sql/insert_transaction_draft.sql"),
            Clock.currTime(UTC()).toISOExtString(),
            data.templateName.toNullable(),
            data.timestamp.toNullable(),
            data.amount.toNullable(),
            data.currencyCode.toNullable(),
            data.description.toNullable(),
            data.internalTransfer.toNullable(),
            data.vendorId.toNullable(),
            data.categoryId.toNullable(),
            data.creditedAccountId.toNullable(),
            data.debitedAccountId.toNullable()
        );
        ulong draftId = db.lastInsertRowid();
        insertLineItems(draftId, data);
        return findById(draftId).orElseThrow();
    }

    void linkAttachment(ulong draftId, ulong attachmentId) {
        util.sqlite.update(
            db,
            "INSERT INTO transaction_draft_attachment (draft_id, attachment_id) VALUES (?, ?)",
            draftId,
            attachmentId
        );
    }

    TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data) {
        util.sqlite.update(
            db,
            import("sql/update_transaction_draft.sql"),
            data.templateName.toNullable(),
            data.timestamp.toNullable(),
            data.amount.toNullable(),
            data.currencyCode.toNullable(),
            data.description.toNullable(),
            data.internalTransfer.toNullable(),
            data.vendorId.toNullable(),
            data.categoryId.toNullable(),
            data.creditedAccountId.toNullable(),
            data.debitedAccountId.toNullable(),
            draftId
        );
        // Re-write all line items:
        util.sqlite.update(
            db,
            "DELETE FROM transaction_draft_line_item WHERE draft_id = ?",
            draftId
        );
        insertLineItems(draftId, data);
        // If template name is empty / NULL, clear any recurring transactions that may exist.
        if (data.templateName.isNull || data.templateName.value.length == 0) {
            new SqliteRecurringTransactionRepository(this.db)
                .deleteAllByDraftId(draftId);
        }
        return findById(draftId).orElseThrow();
    }

    void deleteById(ulong id) {
        util.sqlite.update(
            db,
            "DELETE FROM attachment WHERE id IN (
                SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?
            )",
            id
        );
        util.sqlite.deleteById(db, "transaction_draft", id);
    }

    void updateTags(ulong draftId, in string[] tags) {
        util.sqlite.update(
            db,
            "DELETE FROM transaction_draft_tag WHERE draft_id = ?",
            draftId
        );
        foreach (tag; tags) {
            util.sqlite.update(
                db,
                "INSERT INTO transaction_draft_tag (draft_id, tag) VALUES (?, ?)",
                draftId, tag
            );
        }
    }

    string[] findAllTags() {
        return util.sqlite.findAll(
            db,
            "SELECT DISTINCT tag FROM transaction_draft_tag ORDER BY tag",
            r => r.peek!string(0)
        );
    }



    private QueryBuilder getBuilderForDraftsList() {
        return QueryBuilder("transaction_draft draft")
            .join("LEFT JOIN transaction_vendor vendor ON vendor.id = draft.vendor_id")
            .join("LEFT JOIN transaction_category category ON category.id = draft.category_id")
            .join("LEFT JOIN account account_credit ON account_credit.id = draft.credited_account_id")
            .join("LEFT JOIN account account_debit ON account_debit.id = draft.debited_account_id")
            .join("LEFT JOIN transaction_draft_tag tags ON tags.draft_id = draft.id");
    }

    private void addSelectsForDraftsList(ref QueryBuilder qb) {
        qb
            .select("draft.id")
            .select("draft.added_at")
            .select("draft.template_name")
            .select("draft.timestamp")
            .select("draft.amount")// 5
            .select("draft.currency")
            .select("draft.description")
            .select("draft.internal_transfer")
            .select("vendor.id")
            .select("vendor.name")// 10
            .select("category.id")
            .select("category.name")
            .select("category.color")
            .select("account_credit.id")
            .select("account_credit.name")// 15
            .select("account_credit.type")
            .select("account_credit.number_suffix")
            .select("account_debit.id")
            .select("account_debit.name")
            .select("account_debit.type")// 20
            .select("account_debit.number_suffix")
            .select("group_concat(tags.tag)");
    }

    private static TransactionDraftListItem parseDraftListItem(Row row) {
        TransactionDraftListItem item;
        item.id = row.peek!ulong(0);
        item.addedAt = row.peek!string(1);
        item.templateName = row.parseOptional!string(2);
        item.timestamp = row.parseOptional!string(3);
        item.amount = row.parseOptional!ulong(4);
        item.currency = row.parseOptional!(string, PeekMode.slice)(5)
            .mapIfPresent!(s => Currency.ofCode(s));
        item.description = row.parseOptional!string(6);
        item.internalTransfer = row.parseOptional!bool(7);
        Optional!ulong vendorId = row.parseOptional!ulong(8);
        if (vendorId) {
            item.vendor = SimpleVendorResponse(
                vendorId.value,
                row.peek!string(9)
            ).toOptional;
        }
        Optional!ulong categoryId = row.parseOptional!ulong(10);
        if (categoryId) {
            item.category = SimpleCategoryResponse(
                categoryId.value,
                row.peek!string(11),
                row.peek!string(12),
            ).toOptional;
        }
        Optional!ulong creditedAccountId = row.parseOptional!ulong(13);
        if (creditedAccountId) {
            item.creditedAccount = SimpleAccountResponse(
                creditedAccountId.value,
                row.peek!string(14),
                row.peek!string(15),
                row.peek!string(16)
            ).toOptional;
        }
        Optional!ulong debitedAccountId = row.parseOptional!ulong(17);
        if (debitedAccountId) {
            item.debitedAccount = SimpleAccountResponse(
                debitedAccountId.value,
                row.peek!string(18),
                row.peek!string(19),
                row.peek!string(20)
            ).toOptional;
        }
        string aggregateTags = row.peek!string(21);
        if (aggregateTags !is null) {
            import std.string : split;
            item.tags = aggregateTags.split(",");
        }
        import std.algorithm : sort;
        sort(item.tags);
        return item;
    }

    private void insertLineItems(ulong draftId, in TransactionDraftPayload payload) {
        foreach (size_t idx, lineItem; payload.lineItems) {
            util.sqlite.update(
                db,
                import("sql/insert_line_item_draft.sql"),
                draftId,
                idx,
                lineItem.valuePerItem,
                lineItem.quantity,
                lineItem.description,
                lineItem.categoryId.toNullable()
            );
        }
    }
}

class SqliteRecurringTransactionRepository : RecurringTransactionRepository {
    private Database db;
    this(Database db) {
        this.db = db;
    }

    Page!RecurringTransactionResponse findAll(in PageRequest pr) {
        string query = "SELECT * FROM recurring_transaction " ~ pr.toSql();
        RecurringTransactionResponse[] results = util.sqlite.findAll(db, query, &parseRecurringTransaction);
        ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM recurring_transaction");
        return Page!(RecurringTransactionResponse).of(results, pr, totalCount);
    }

    RecurringTransactionResponse[] findAllByDraftId(ulong draftId) {
        string query = "SELECT * FROM recurring_transaction WHERE draft_id = ? ORDER BY id";
        return util.sqlite.findAll(db, query, &parseRecurringTransaction, draftId);
    }

    Optional!RecurringTransactionResponse findById(ulong id) {
        return util.sqlite.findOne(
            db,
            "SELECT * FROM recurring_transaction WHERE id = ?",
            &parseRecurringTransaction,
            id
        );
    }

    RecurringTransactionResponse insert(in RecurringTransactionPayload data) {
        util.sqlite.update(
            db,
            "INSERT INTO recurring_transaction " ~
            "(draft_id, schedule_expr) " ~
            "VALUES (?, ?)",
            data.draftId,
            data.scheduleExpr
        );
        return findById(db.lastInsertRowid()).orElseThrow();
    }

    void deleteById(ulong id) {
        util.sqlite.deleteById(db, "recurring_transaction", id);
    }

    void deleteAllByDraftId(ulong draftId) {
        util.sqlite.update(
            db,
            "DELETE FROM recurring_transaction WHERE draft_id = ?",
            draftId
        );
    }

    void addToQueue(ulong id, string timestamp) {
        util.sqlite.update(
            db,
            "INSERT INTO recurring_transaction_queue (recurring_transaction_id, scheduled_timestamp) VALUES (?, ?)",
            id,
            timestamp
        );
    }

    Optional!string findInQueue(ulong id) {
        return util.sqlite.findOne(
            db,
            "SELECT scheduled_timestamp FROM recurring_transaction_queue WHERE recurring_transaction_id = ?",
            row => row.peek!string(0),
            id
        );
    }

    void removeFromQueue(ulong id) {
        util.sqlite.update(
            db,
            "DELETE FROM recurring_transaction_queue WHERE recurring_transaction_id = ?",
            id
        );
    }

    private static RecurringTransactionResponse parseRecurringTransaction(Row row) {
        return RecurringTransactionResponse(
            row.peek!ulong(0),
            row.peek!ulong(1),
            row.peek!string(2)
        );
    }
}
