module account.service;

import handy_http_primitives;
import std.datetime;

import account.model;
import account.data;
import profile.data;
import util.money;

/**
 * Gets the balance for an account, at a given point in time.
 * Params:
 *   ds = The data source for fetching account data.
 *   accountId = The account's id.
 *   timestamp = The timestamp at which to determine the account's balance.
 * Returns: An optional that resolves to the account's balance if it can be
 * determined from value records and journal entries.
 */
Optional!long getBalance(ProfileDataSource ds, ulong accountId, SysTime timestamp = Clock.currTime(UTC())) {
    auto accountRepo = ds.getAccountRepository();
    auto valueRecordRepo = ds.getAccountValueRecordRepository();
    auto journalEntryRepo = ds.getAccountJournalEntryRepository();

    Account account = accountRepo.findById(accountId).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));

    Optional!AccountValueRecord nearestBefore = valueRecordRepo.findNearestByAccountIdBefore(accountId, timestamp);
    Optional!AccountValueRecord nearestAfter = valueRecordRepo.findNearestByAccountIdAfter(accountId, timestamp);
    if (nearestBefore.isNull && nearestAfter.isNull) return Optional!long.empty;

    if (!nearestBefore.isNull && !nearestAfter.isNull) {
        Duration timeFromBefore = timestamp - nearestBefore.value.timestamp;
        Duration timeToAfter = nearestAfter.value.timestamp - timestamp;
        if (timeFromBefore <= timeToAfter) {
            return Optional!long.of(deriveBalance(account, nearestBefore.value, timestamp, journalEntryRepo));
        } else {
            return Optional!long.of(deriveBalance(account, nearestAfter.value, timestamp, journalEntryRepo));
        }
    }
    if (!nearestBefore.isNull) {
        return Optional!long.of(deriveBalance(account, nearestBefore.value, timestamp, journalEntryRepo));
    }
    if (!nearestAfter.isNull) {
        return Optional!long.of(deriveBalance(account, nearestAfter.value, timestamp, journalEntryRepo));
    }

    return Optional!long.empty;
}

struct CurrencyBalance {
    Currency currency;
    long balance;
}

CurrencyBalance[] getTotalBalanceForAllAccounts(ProfileDataSource ds, SysTime timestamp = Clock.currTime(UTC())) {
    auto accountRepo = ds.getAccountRepository();
    CurrencyBalance[] balances;
    foreach (Account account; accountRepo.findAll()) {
        Optional!long accountBalance = getBalance(ds, account.id, timestamp);
        if (!accountBalance.isNull) {
            long value = accountBalance.value;
            if (!account.type.debitsPositive) {
                value = -value;
            }
            // Add the balance to the relevant currency balance:
            bool added = false;
            foreach (ref cb; balances) {
                if (cb.currency.code == account.currency.code) {
                    cb.balance += value;
                    added = true;
                    break;
                }
            }
            if (!added) {
                balances ~= CurrencyBalance(account.currency, value);
            }
        }
    }
    return balances;
}

/** 
 * Helper method that derives a balance for an account, by using the nearest
 * value record, and all journal entries between that record and the desired
 * timestamp. We start at the value record's balance, then apply each journal
 * entry's amount to that balance (either credit or debit) to arrive at the
 * final balance.
 * Params:
 *   account = The account to derive a balance for.
 *   valueRecord = The nearest value record to use.
 *   timestamp = The target timestamp at which to determine the balance.
 *   journalEntryRepo = A repository for fetching journal entries.
 * Returns: The derived balance.
 */
private long deriveBalance(
    Account account,
    AccountValueRecord valueRecord,
    SysTime timestamp,
    AccountJournalEntryRepository journalEntryRepo
) {
    enum TimeDirection {
        Forward,
        Backward
    }

    SysTime startTimestamp;
    SysTime endTimestamp;
    TimeDirection timeDirection;
    if (valueRecord.timestamp < timestamp) {
        startTimestamp = valueRecord.timestamp;
        endTimestamp = timestamp;
        timeDirection = TimeDirection.Forward;
    } else {
        startTimestamp = timestamp;
        endTimestamp = valueRecord.timestamp;
        timeDirection = TimeDirection.Backward;
    }

    long balance = valueRecord.value;
    AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp, account.id);
    foreach (entry; journalEntries) {
        long entryValue = entry.amount;
        if (entry.type == AccountJournalEntryType.CREDIT) {
            entryValue *= -1;
        }
        if (!account.type.debitsPositive) {
            entryValue *= -1;
        }
        if (timeDirection == TimeDirection.Backward) {
            entryValue *= -1;
        }
        balance += entryValue;
    }
    return balance;
}

unittest {
    import std.algorithm;
    import std.array;
    import util.money;

    class MockAccountRepository : TestAccountRepositoryStub {
        private Account account;

        this(Account account) {
            this.account = account;
        }

        override Optional!Account findById(ulong id) {
            if (id == this.account.id) return Optional!Account.of(account);
            return Optional!Account.empty;
        }
    }

    class MockValueRecordRepository : TestAccountValueRecordRepositoryStub {
        private AccountValueRecord[] valueRecords;

        this(AccountValueRecord[] valueRecords) {
            this.valueRecords = valueRecords;
        }

        override Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) {
            auto matches = valueRecords
                .filter!(v => v.accountId == accountId)
                .filter!(v => v.timestamp < timestamp)
                .array;
            matches.sort!((a, b) => a.timestamp < b.timestamp);
            if (matches.length == 0) return Optional!AccountValueRecord.empty;
            return Optional!AccountValueRecord.of(matches[$-1]);
        }

        override Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
            auto matches = valueRecords
                .filter!(v => v.accountId == accountId)
                .filter!(v => v.timestamp > timestamp)
                .array;
            matches.sort!((a, b) => a.timestamp < b.timestamp);
            if (matches.length == 0) return Optional!AccountValueRecord.empty;
            return Optional!AccountValueRecord.of(matches[0]);
        }
    }

    class MockJournalEntryRepository : TestAccountJournalEntryRepositoryStub {
        private AccountJournalEntry[] journalEntries;

        this(AccountJournalEntry[] journalEntries) {
            this.journalEntries = journalEntries;
        }
        
        override AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) {
            auto matches = journalEntries.filter!(je => je.timestamp >= startIncl && je.timestamp <= endIncl)
                .array;
            matches.sort!((a, b) => a.timestamp < b.timestamp);
            return matches;
        }
    }

    class MockDataSource : TestProfileDataSourceStub {
        private Account account;
        private AccountValueRecord[] valueRecords;
        private AccountJournalEntry[] journalEntries;

        this(Account account, AccountValueRecord[] valueRecords, AccountJournalEntry[] journalEntries) {
            this.account = account;
            this.valueRecords = valueRecords;
            this.journalEntries = journalEntries;
        }

        override AccountRepository getAccountRepository() {
            return new MockAccountRepository(account);
        }

        override AccountValueRecordRepository getAccountValueRecordRepository() {
            return new MockValueRecordRepository(valueRecords);
        }

        override AccountJournalEntryRepository getAccountJournalEntryRepository() {
            return new MockJournalEntryRepository(journalEntries);
        }
    }

    SysTime NOW = SysTime(DateTime(2025, 8, 18, 15, 30, 59), UTC());
    Account sampleAccount = Account(
        1,
        NOW - days(30),
        false,
        AccountTypes.CHECKING,
        "1234",
        "Sample Checking Account",
        Currencies.USD,
        "This is a sample testing account."
    );

    AccountValueRecord makeSampleValueRecord(SysTime timestamp, long value) {
        return AccountValueRecord(
            1,
            timestamp,
            sampleAccount.id,
            AccountValueRecordType.BALANCE,
            value,
            sampleAccount.currency
        );
    }

    AccountJournalEntry makeSampleCreditEntry(SysTime timestamp, ulong amount) {
        return AccountJournalEntry(
            1,
            timestamp,
            sampleAccount.id,
            1,
            amount,
            AccountJournalEntryType.CREDIT,
            sampleAccount.currency
        );
    }

    AccountJournalEntry makeSampleDebitEntry(SysTime timestamp, ulong amount) {
        return AccountJournalEntry(
            1,
            timestamp,
            sampleAccount.id,
            1,
            amount,
            AccountJournalEntryType.DEBIT,
            sampleAccount.currency
        );
    }

    // 1. Account exists, but no value records or journal entries.
    ProfileDataSource ds1 = new MockDataSource(sampleAccount, [], []);
    assert(getBalance(ds1, sampleAccount.id).isNull);

    // 2a. Single value record exists, so that's the value of the account.
    ProfileDataSource ds2a = new MockDataSource(
        sampleAccount,
        [makeSampleValueRecord(NOW - days(5), 123)],
        []
    );
    assert(getBalance(ds2a, sampleAccount.id, NOW).value == 123);

    // 2b. Same as the previous, but a value record exists after the timestamp.
    ProfileDataSource ds2b = new MockDataSource(
        sampleAccount,
        [makeSampleValueRecord(NOW + days(5), 123)],
        []
    );
    assert(getBalance(ds2b, sampleAccount.id, NOW).value == 123);

    // 3. If there are balance records both before and after the timestamp, the closest one is used.
    ProfileDataSource ds3 = new MockDataSource(
        sampleAccount,
        [
            makeSampleValueRecord(NOW - days(5), 123),
            makeSampleValueRecord(NOW + days(3), -25)
        ],
        []
    );
    assert(getBalance(ds3, sampleAccount.id, NOW).value == -25);
    assert(getBalance(ds3, sampleAccount.id, NOW - days(2)).value == 123);

    // 4. Balance record followed by some entries, as a basic balance derivation.
    ProfileDataSource ds4 = new MockDataSource(
        sampleAccount,
        [
            makeSampleValueRecord(NOW - days(10), 100)
        ],
        [
            makeSampleDebitEntry(NOW - days(9), 25), // 100 + 25 = 125
            makeSampleCreditEntry(NOW - days(8), 5), // 125 - 5 = 120
            makeSampleDebitEntry(NOW - days(5), 80), // 120 + 80 = 200
            makeSampleCreditEntry(NOW - days(1), 150) // 200 - 150 = 50
        ]
    );
    assert(getBalance(ds4, sampleAccount.id, NOW).value == 50);
    assert(getBalance(ds4, sampleAccount.id, NOW - days(2)).value == 200);

    // 5. Balance record in the future, with some entries between it and now, for backwards derivation.
    ProfileDataSource ds5 = new MockDataSource(
        sampleAccount,
        [
            makeSampleValueRecord(NOW + days(10), 100)
        ],
        [
            makeSampleCreditEntry(NOW + days(9), 200), // 100 - (-200) = 300
            makeSampleDebitEntry(NOW + days(8), 50), // 300 - 50 = 250
            makeSampleDebitEntry(NOW + days(2), 25) // 250 - 25 = 225
        ]
    );
    assert(getBalance(ds5, sampleAccount.id, NOW).value == 225);
    assert(getBalance(ds5, sampleAccount.id, NOW + days(4)).value == 250);
}
