module transaction.service;

import handy_http_primitives;
import std.datetime;
import slf4d;

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

// Transactions Services

Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
    Page!TransactionsListItem page = ds.getTransactionRepository()
        .findAll(pageRequest);
    return page;
}

TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
    import std.algorithm : map;
    import std.array : array;
    TransactionDetail txn = ds.getTransactionRepository().findById(transactionId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    txn.attachments = ds.getAttachmentRepository().findAllByTransactionId(txn.id)
        .map!(AttachmentResponse.of)
        .array;
    return txn;
}

TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload, in MultipartFile[] files) {
    TransactionRepository txnRepo = ds.getTransactionRepository();
    TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
    TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
    AccountRepository accountRepo = ds.getAccountRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();

    validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
    SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());

    // Add the transaction:
    ulong txnId;
    ds.doTransaction(() {
        TransactionDetail txn = txnRepo.insert(payload);
        AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
        if (!payload.creditedAccountId.isNull) {
            jeRepo.insert(
                timestamp,
                payload.creditedAccountId.get,
                txn.id,
                txn.amount,
                AccountJournalEntryType.CREDIT,
                txn.currency
            );
        }
        if (!payload.debitedAccountId.isNull) {
            jeRepo.insert(
                timestamp,
                payload.debitedAccountId.get,
                txn.id,
                txn.amount,
                AccountJournalEntryType.DEBIT,
                txn.currency
            );
        }
        TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
        tagRepo.updateTags(txn.id, payload.tags);
        updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
        txnId = txn.id;
    });
    return getTransaction(ds, txnId);
}

TransactionDetail updateTransaction(
    ProfileDataSource ds,
    ulong transactionId,
    in AddTransactionPayload payload,
    in MultipartFile[] files
) {
    TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
    TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
    AccountRepository accountRepo = ds.getAccountRepository();
    TransactionRepository transactionRepo = ds.getTransactionRepository();
    TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();

    validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
    SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());

    const TransactionDetail prev = transactionRepo.findById(transactionId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));

    // Update the transaction:
    ds.doTransaction(() {
        TransactionDetail curr = transactionRepo.update(transactionId, payload);
        updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp);
        tagRepo.updateTags(transactionId, payload.tags);
        updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
    });
    return getTransaction(ds, transactionId);
}

private void updateLinkedAccountJournalEntries(
    in TransactionDetail prev,
    in TransactionDetail curr,
    in AddTransactionPayload payload,
    ProfileDataSource ds,
    in SysTime timestamp
) {
    AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
    const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
    const bool updateCreditEntry = amountOrCurrencyChanged || (
        (prev.creditedAccount.isNull && !payload.creditedAccountId.isNull) ||
        (!prev.creditedAccount.isNull && payload.creditedAccountId.isNull) ||
        (
            !prev.creditedAccount.isNull &&
            !payload.creditedAccountId.isNull &&
            prev.creditedAccount.get.id != payload.creditedAccountId.get
        )
    );
    const bool updateDebitEntry = amountOrCurrencyChanged || (
        (prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) ||
        (!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) ||
        (
            !prev.debitedAccount.isNull &&
            !payload.debitedAccountId.isNull &&
            prev.debitedAccount.get.id != payload.debitedAccountId.get
        )
    );

    // Update journal entries if necessary:
    if (updateCreditEntry && !prev.creditedAccount.isNull) {
        jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id);
    }
    if (updateCreditEntry && !payload.creditedAccountId.isNull) {
        jeRepo.insert(
            timestamp,
            payload.creditedAccountId.get,
            curr.id,
            curr.amount,
            AccountJournalEntryType.CREDIT,
            curr.currency
        );
    }
    if (updateDebitEntry && !prev.debitedAccount.isNull) {
        jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id);
    }
    if (updateDebitEntry && !payload.debitedAccountId.isNull) {
        jeRepo.insert(
            timestamp,
            payload.debitedAccountId.get,
            curr.id,
            curr.amount,
            AccountJournalEntryType.DEBIT,
            curr.currency
        );
    }
}

void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
    TransactionRepository txnRepo = ds.getTransactionRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
    TransactionDetail txn = txnRepo.findById(transactionId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    ds.doTransaction(() {
        // First delete all attachments.
        foreach (a; attachmentRepo.findAllByTransactionId(txn.id)) {
            attachmentRepo.remove(a.id);
        }
        txnRepo.deleteById(txn.id);
    });
}

private void validateTransactionPayload(
    TransactionVendorRepository vendorRepo,
    TransactionCategoryRepository categoryRepo,
    AccountRepository accountRepo,
    in AddTransactionPayload payload
) {
    if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
    }
    if (
        !payload.creditedAccountId.isNull &&
        !payload.debitedAccountId.isNull &&
        payload.creditedAccountId.get == payload.debitedAccountId.get
    ) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit.");
    }
    if (payload.amount == 0) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
    }
    SysTime now = Clock.currTime(UTC());
    SysTime timestamp;
    try {
        timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
    } catch (TimeException e) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime.");
    }
    if (timestamp > now) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
    }
    if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist.");
    }
    if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist.");
    }
    if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist.");
    }
    if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist.");
    }
    foreach (tag; payload.tags) {
        import std.regex;
        auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`);
        if (!matchFirst(tag, r)) {
            throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\".");
        }
    }
    if (payload.lineItems.length > 0) {
        long lineItemsTotal = 0;
        foreach (lineItem; payload.lineItems) {
            if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) {
                throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist.");
            }
            if (lineItem.quantity == 0) {
                throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero.");
            }
            for (ulong i = 0; i < lineItem.quantity; i++) {
                lineItemsTotal += lineItem.valuePerItem;
            }
        }
        if (lineItemsTotal != payload.amount) {
            throw new HttpStatusException(
                HttpStatus.BAD_REQUEST,
                "Total of all line items doesn't equal the transaction's total."
            );
        }
    }
}

/** 
 * Helper function to add / remove attachments for a transaction.
 */
void updateAttachments(
    ulong transactionId,
    SysTime timestamp,
    in ulong[] attachmentIdsToRemove,
    in MultipartFile[] attachmentsToAdd,
    AttachmentRepository attachmentRepo,
    TransactionRepository txnRepo
) {
    // Save & link attachment files:
    foreach (file; attachmentsToAdd) {
        ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
        txnRepo.linkAttachment(transactionId, attachmentId);
    }
    // Delete attachments (this cascades to delete the link record in transaction_attachment).
    foreach (idToRemove; attachmentIdsToRemove) {
        attachmentRepo.remove(idToRemove);
    }
}

// Vendors Services

TransactionVendor[] getAllVendors(ProfileDataSource ds) {
    return ds.getTransactionVendorRepository().findAll();
}

TransactionVendor getVendor(ProfileDataSource ds, ulong vendorId) {
    return ds.getTransactionVendorRepository().findById(vendorId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}

TransactionVendor createVendor(ProfileDataSource ds, in VendorPayload payload) {
    auto vendorRepo = ds.getTransactionVendorRepository();
    if (vendorRepo.existsByName(payload.name)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor name is already in use.");
    }
    return vendorRepo.insert(payload.name, payload.description);
}

TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPayload payload) {
    TransactionVendorRepository repo = ds.getTransactionVendorRepository();
    TransactionVendor existingVendor = repo.findById(vendorId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    if (payload.name != existingVendor.name && repo.existsByName(payload.name)) {
        throw new HttpStatusException(
            HttpStatus.BAD_REQUEST,
            "Vendor name is already in use."
        );
    }
    return repo.updateById(vendorId, payload.name, payload.description);
}

void deleteVendor(ProfileDataSource ds, ulong vendorId) {
    ds.getTransactionVendorRepository().deleteById(vendorId);
}

// Categories Services

TransactionCategoryTree[] getCategories(ProfileDataSource ds) {
    TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
    return getCategoriesRecursive(repo, Optional!ulong.empty, 0);
}

private TransactionCategoryTree[] getCategoriesRecursive(
    TransactionCategoryRepository repo,
    Optional!ulong parentId,
    uint depth
) {
    import util.data : toNullable;
    TransactionCategoryTree[] nodes;
    foreach (category; repo.findAllByParentId(parentId)) {
        nodes ~= TransactionCategoryTree(
            category.id,
            parentId,
            category.name,
            category.description,
            category.color,
            getCategoriesRecursive(repo, Optional!ulong.of(category.id), depth + 1),
            depth
        );
    }
    return nodes;
}

TransactionCategoryResponse getCategory(ProfileDataSource ds, ulong categoryId) {
    auto category = ds.getTransactionCategoryRepository().findById(categoryId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    return TransactionCategoryResponse.of(category);
}

TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) {
    TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
    if (payload.name is null || payload.name.length == 0) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name.");
    }
    if (repo.existsByName(payload.name)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
    }
    if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
    }
    import std.regex;
    const colorHexRegex = ctRegex!`^(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$`;
    if (payload.color is null || matchFirst(payload.color, colorHexRegex).empty) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
    }
    auto category = repo.insert(
        toOptional(payload.parentId),
        payload.name,
        payload.description,
        payload.color
    );
    return TransactionCategoryResponse.of(category);
}

TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryId, in CategoryPayload payload) {
    TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
    if (payload.name is null || payload.name.length == 0) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name.");
    }
    TransactionCategory prev = repo.findById(categoryId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    if (payload.name != prev.name && repo.existsByName(payload.name)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
    }
    if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
    }
    TransactionCategory curr = repo.updateById(
        categoryId,
        payload.name,
        payload.description,
        payload.color
    );
    return TransactionCategoryResponse.of(curr);
}

void deleteCategory(ProfileDataSource ds, ulong categoryId) {
    ds.getTransactionCategoryRepository().deleteById(categoryId);
}
