using CashLineERP.Application.Finance;
using CashLineERP.Domain.Audit;
using CashLineERP.Domain.Finance;
using CashLineERP.SharedKernel.Workflow;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;

namespace CashLineERP.Infrastructure.Persistence.Repositories;

public sealed class ReconciliationRepository : IReconciliationRepository
{
    private readonly CashLineErpDbContext _dbContext;

    public ReconciliationRepository(CashLineErpDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public IReadOnlyCollection<BankStatementDetailView> GetBankStatements() =>
        _dbContext.BankStatements
            .AsNoTracking()
            .Include(statement => statement.Lines)
            .OrderByDescending(statement => statement.StatementDate)
            .ThenByDescending(statement => statement.Id)
            .ToArray()
            .Select(Map)
            .ToArray();

    public BankStatementDetailView? GetBankStatement(long bankStatementId)
    {
        var statement = _dbContext.BankStatements
            .AsNoTracking()
            .Include(existingStatement => existingStatement.Lines)
            .SingleOrDefault(existingStatement => existingStatement.Id == bankStatementId);

        return statement is null ? null : Map(statement);
    }

    public BankStatementDetailView CreateBankStatement(CreateBankStatementCommand command)
    {
        if (command.Lines.Count == 0)
        {
            throw new InvalidOperationException("Bank statement import requires at least one statement line.");
        }

        var normalizedBankAccountCode = NormalizeCode(command.BankAccountCode, "bank account code");
        var normalizedCurrencyCode = NormalizeCode(command.CurrencyCode, "currency code");
        var normalizedLines = command.Lines
            .Select(NormalizeLine)
            .ToArray();

        var duplicateImportedLineFingerprints = normalizedLines
            .GroupBy(line => line.Fingerprint)
            .Where(group => group.Count() > 1)
            .Select(group => group.Key.ToDisplayText())
            .ToArray();

        if (duplicateImportedLineFingerprints.Length > 0)
        {
            throw new InvalidOperationException($"Bank statement import contains duplicate lines: {string.Join("; ", duplicateImportedLineFingerprints)}.");
        }

        var existingStatementsForScope = _dbContext.BankStatements
            .Include(existingStatement => existingStatement.Lines)
            .Where(existingStatement =>
                existingStatement.StatementDate == command.StatementDate
                && existingStatement.BankAccountCode == normalizedBankAccountCode
                && existingStatement.CurrencyCode == normalizedCurrencyCode)
            .ToArray();

        var duplicateExistingLineFingerprints = existingStatementsForScope
            .SelectMany(existingStatement => existingStatement.Lines)
            .Select(CreateFingerprint)
            .Intersect(normalizedLines.Select(line => line.Fingerprint))
            .Distinct()
            .Select(fingerprint => fingerprint.ToDisplayText())
            .ToArray();

        if (duplicateExistingLineFingerprints.Length > 0)
        {
            throw new InvalidOperationException($"Bank statement import duplicates already imported lines for the same bank account/date/currency scope: {string.Join("; ", duplicateExistingLineFingerprints)}.");
        }

        var nextSequence = (_dbContext.BankStatements.Max(statement => (long?)statement.Id) ?? 0L) + 1L;
        var statement = new BankStatement(
            statementNumber: $"BSTM-{nextSequence:D6}",
            bankAccountCode: normalizedBankAccountCode,
            statementDate: command.StatementDate,
            currencyCode: normalizedCurrencyCode,
            openingBalanceAmount: NormalizeAmount(command.OpeningBalanceAmount),
            closingBalanceAmount: NormalizeAmount(command.ClosingBalanceAmount));

        var lineNumber = 1;
        foreach (var line in normalizedLines)
        {
            statement.AddLine(
                lineNumber++,
                line.TransactionDate,
                line.ExternalReference,
                line.DescriptionText,
                line.DebitAmount,
                line.CreditAmount,
                line.RunningBalanceAmount);
        }

        _dbContext.BankStatements.Add(statement);
        _dbContext.SaveChanges();
        return GetBankStatement(statement.Id)!;
    }

    public IReadOnlyCollection<ReceiptReconciliationRunDetailView> GetRuns() =>
        _dbContext.ReceiptReconciliationRuns
            .AsNoTracking()
            .Include(run => run.Items)
            .OrderByDescending(run => run.Id)
            .ToArray()
            .Select(Map)
            .ToArray();

    public ReceiptReconciliationRunDetailView? GetRun(long receiptReconciliationRunId)
    {
        var run = _dbContext.ReceiptReconciliationRuns
            .AsNoTracking()
            .Include(existingRun => existingRun.Items)
            .SingleOrDefault(existingRun => existingRun.Id == receiptReconciliationRunId);

        return run is null ? null : Map(run);
    }

    public ReceiptReconciliationRunDetailView RunReceiptReconciliation(RunReceiptReconciliationCommand command)
    {
        var period = _dbContext.AccountingPeriods.Single(existingPeriod => existingPeriod.Id == command.AccountingPeriodId);
        var statement = _dbContext.BankStatements
            .Include(existingStatement => existingStatement.Lines)
            .Single(existingStatement => existingStatement.Id == command.BankStatementId);

        if (statement.StatementDate < period.StartDate || statement.StatementDate > period.EndDate)
        {
            throw new InvalidOperationException("Bank statement date is outside the selected accounting period.");
        }

        var nextSequence = (_dbContext.ReceiptReconciliationRuns.Max(run => (long?)run.Id) ?? 0L) + 1L;
        var run = new ReceiptReconciliationRun(
            runNumber: $"RECON-{nextSequence:D6}",
            accountingPeriodId: period.Id,
            bankStatementId: statement.Id);

        var postedReceipts = _dbContext.AccountsReceivableReceipts
            .AsNoTracking()
            .Where(receipt => receipt.StatusId == WorkflowStatusSeedIds.Posted
                && receipt.CurrencyCode == statement.CurrencyCode)
            .ToArray();
        var alreadyReconciledReceiptIds = _dbContext.BankStatementLines
            .AsNoTracking()
            .Where(line => line.IsReconciled && line.AccountsReceivableReceiptId.HasValue)
            .Select(line => line.AccountsReceivableReceiptId!.Value)
            .ToHashSet();

        var matchedReceiptIds = new HashSet<long>();
        foreach (var line in statement.Lines.OrderBy(entry => entry.LineNumber))
        {
            if (line.IsReconciled)
            {
                continue;
            }

            var statementAmount = line.GetStatementAmount();
            var receipt = postedReceipts.FirstOrDefault(candidate =>
                !alreadyReconciledReceiptIds.Contains(candidate.Id)
                && !matchedReceiptIds.Contains(candidate.Id)
                && !string.IsNullOrWhiteSpace(line.ExternalReference)
                && string.Equals(candidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase)
                && candidate.AmountAmount == statementAmount);

            if (receipt is not null)
            {
                line.Reconcile(receipt.Id);
                matchedReceiptIds.Add(receipt.Id);

                run.AddItem(new ReceiptReconciliationItem(
                    receiptReconciliationRunId: run.Id,
                    bankStatementLineId: line.Id,
                    accountsReceivableReceiptId: receipt.Id,
                    accountsPayablePaymentId: null,
                    customerRefundId: null,
                    matchedDocumentType: "AR_RECEIPT",
                    matchStatusCode: "MATCHED",
                    statementAmount: statementAmount,
                    matchedDocumentAmount: receipt.AmountAmount,
                    differenceAmount: 0m,
                    notes: "Matched on reference number and amount."));

                continue;
            }

            var exceptionCandidate = postedReceipts.FirstOrDefault(candidate =>
                !alreadyReconciledReceiptIds.Contains(candidate.Id)
                && !matchedReceiptIds.Contains(candidate.Id)
                && ((!string.IsNullOrWhiteSpace(line.ExternalReference)
                        && string.Equals(candidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase))
                    || candidate.AmountAmount == statementAmount));

            if (exceptionCandidate is not null)
            {
                var amountDifference = statementAmount - exceptionCandidate.AmountAmount;
                var notes = !string.IsNullOrWhiteSpace(line.ExternalReference)
                    && string.Equals(exceptionCandidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase)
                        ? "Potential AR receipt match found on reference number, but the amount differs and requires review."
                        : "Potential AR receipt match found on amount, but the reference differs and requires review.";

                run.AddItem(new ReceiptReconciliationItem(
                    receiptReconciliationRunId: run.Id,
                    bankStatementLineId: line.Id,
                    accountsReceivableReceiptId: exceptionCandidate.Id,
                    accountsPayablePaymentId: null,
                    customerRefundId: null,
                    matchedDocumentType: "AR_RECEIPT",
                    matchStatusCode: "EXCEPTION",
                    statementAmount: statementAmount,
                    matchedDocumentAmount: exceptionCandidate.AmountAmount,
                    differenceAmount: amountDifference,
                    notes: notes));

                continue;
            }

            run.AddItem(new ReceiptReconciliationItem(
                receiptReconciliationRunId: run.Id,
                bankStatementLineId: line.Id,
                accountsReceivableReceiptId: null,
                accountsPayablePaymentId: null,
                customerRefundId: null,
                matchedDocumentType: "UNMATCHED",
                matchStatusCode: "UNMATCHED",
                statementAmount: statementAmount,
                matchedDocumentAmount: null,
                differenceAmount: statementAmount,
                notes: "No posted AR receipt matched by reference number and amount."));
        }

        run.Complete(command.ActorUserId);
        _dbContext.ReceiptReconciliationRuns.Add(run);
        _dbContext.SaveChanges();
        AddInitialAuditEvents(run, command.ActorUserId);
        _dbContext.SaveChanges();
        return GetRun(run.Id)!;
    }

    public ReceiptReconciliationRunDetailView RunSettlementReconciliation(RunReceiptReconciliationCommand command)
    {
        var period = _dbContext.AccountingPeriods.Single(existingPeriod => existingPeriod.Id == command.AccountingPeriodId);
        var statement = _dbContext.BankStatements
            .Include(existingStatement => existingStatement.Lines)
            .Single(existingStatement => existingStatement.Id == command.BankStatementId);

        if (statement.StatementDate < period.StartDate || statement.StatementDate > period.EndDate)
        {
            throw new InvalidOperationException("Bank statement date is outside the selected accounting period.");
        }

        var nextSequence = (_dbContext.ReceiptReconciliationRuns.Max(run => (long?)run.Id) ?? 0L) + 1L;
        var run = new ReceiptReconciliationRun(
            runNumber: $"SETTLE-{nextSequence:D6}",
            accountingPeriodId: period.Id,
            bankStatementId: statement.Id);

        var postedPayments = _dbContext.AccountsPayablePayments
            .AsNoTracking()
            .Where(payment => payment.StatusId == WorkflowStatusSeedIds.Posted
                && payment.CurrencyCode == statement.CurrencyCode)
            .ToArray();
        var postedCustomerRefunds = _dbContext.CustomerRefunds
            .AsNoTracking()
            .Where(refund => refund.StatusId == WorkflowStatusSeedIds.Posted
                && refund.CurrencyCode == statement.CurrencyCode)
            .ToArray();
        var alreadyReconciledPaymentIds = _dbContext.BankStatementLines
            .AsNoTracking()
            .Where(line => line.IsReconciled && line.AccountsPayablePaymentId.HasValue)
            .Select(line => line.AccountsPayablePaymentId!.Value)
            .ToHashSet();
        var alreadyReconciledRefundIds = _dbContext.BankStatementLines
            .AsNoTracking()
            .Where(line => line.IsReconciled && line.CustomerRefundId.HasValue)
            .Select(line => line.CustomerRefundId!.Value)
            .ToHashSet();

        var matchedPaymentIds = new HashSet<long>();
        var matchedRefundIds = new HashSet<long>();
        foreach (var line in statement.Lines.OrderBy(entry => entry.LineNumber))
        {
            if (line.IsReconciled)
            {
                continue;
            }

            var statementAmount = line.GetStatementAmount();
            var payment = postedPayments.FirstOrDefault(candidate =>
                !alreadyReconciledPaymentIds.Contains(candidate.Id)
                && !matchedPaymentIds.Contains(candidate.Id)
                && candidate.AmountAmount == statementAmount
                && !string.IsNullOrWhiteSpace(line.ExternalReference)
                && string.Equals(candidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase));

            if (payment is not null)
            {
                line.ReconcileAccountsPayablePayment(payment.Id);
                matchedPaymentIds.Add(payment.Id);

                run.AddItem(new ReceiptReconciliationItem(
                    receiptReconciliationRunId: run.Id,
                    bankStatementLineId: line.Id,
                    accountsReceivableReceiptId: null,
                    accountsPayablePaymentId: payment.Id,
                    customerRefundId: null,
                    matchedDocumentType: "AP_PAYMENT",
                    matchStatusCode: "MATCHED",
                    statementAmount: statementAmount,
                    matchedDocumentAmount: payment.AmountAmount,
                    differenceAmount: 0m,
                    notes: "Matched on AP payment reference number and amount."));

                continue;
            }

            var refund = postedCustomerRefunds.FirstOrDefault(candidate =>
                !alreadyReconciledRefundIds.Contains(candidate.Id)
                && !matchedRefundIds.Contains(candidate.Id)
                && candidate.AmountAmount == statementAmount
                && !string.IsNullOrWhiteSpace(line.ExternalReference)
                && string.Equals(candidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase));

            if (refund is not null)
            {
                line.ReconcileCustomerRefund(refund.Id);
                matchedRefundIds.Add(refund.Id);

                run.AddItem(new ReceiptReconciliationItem(
                    receiptReconciliationRunId: run.Id,
                    bankStatementLineId: line.Id,
                    accountsReceivableReceiptId: null,
                    accountsPayablePaymentId: null,
                    customerRefundId: refund.Id,
                    matchedDocumentType: "CUSTOMER_REFUND",
                    matchStatusCode: "MATCHED",
                    statementAmount: statementAmount,
                    matchedDocumentAmount: refund.AmountAmount,
                    differenceAmount: 0m,
                    notes: "Matched on customer refund reference number and amount."));

                continue;
            }

            var exceptionCandidate = postedPayments.FirstOrDefault(candidate =>
                !alreadyReconciledPaymentIds.Contains(candidate.Id)
                && !matchedPaymentIds.Contains(candidate.Id)
                && ((!string.IsNullOrWhiteSpace(line.ExternalReference)
                        && string.Equals(candidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase))
                    || candidate.AmountAmount == statementAmount));

            if (exceptionCandidate is not null)
            {
                var amountDifference = statementAmount - exceptionCandidate.AmountAmount;
                var notes = !string.IsNullOrWhiteSpace(line.ExternalReference)
                    && string.Equals(exceptionCandidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase)
                        ? "Potential AP payment match found on reference number, but the amount differs and requires review."
                        : "Potential AP payment match found on amount, but the reference differs and requires review.";

                run.AddItem(new ReceiptReconciliationItem(
                    receiptReconciliationRunId: run.Id,
                    bankStatementLineId: line.Id,
                    accountsReceivableReceiptId: null,
                    accountsPayablePaymentId: exceptionCandidate.Id,
                    customerRefundId: null,
                    matchedDocumentType: "AP_PAYMENT",
                    matchStatusCode: "EXCEPTION",
                    statementAmount: statementAmount,
                    matchedDocumentAmount: exceptionCandidate.AmountAmount,
                    differenceAmount: amountDifference,
                    notes: notes));

                continue;
            }

            var refundExceptionCandidate = postedCustomerRefunds.FirstOrDefault(candidate =>
                !alreadyReconciledRefundIds.Contains(candidate.Id)
                && !matchedRefundIds.Contains(candidate.Id)
                && ((!string.IsNullOrWhiteSpace(line.ExternalReference)
                        && string.Equals(candidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase))
                    || candidate.AmountAmount == statementAmount));

            if (refundExceptionCandidate is not null)
            {
                var amountDifference = statementAmount - refundExceptionCandidate.AmountAmount;
                var notes = !string.IsNullOrWhiteSpace(line.ExternalReference)
                    && string.Equals(refundExceptionCandidate.ReferenceNumber, line.ExternalReference, StringComparison.OrdinalIgnoreCase)
                        ? "Potential customer refund match found on reference number, but the amount differs and requires review."
                        : "Potential customer refund match found on amount, but the reference differs and requires review.";

                run.AddItem(new ReceiptReconciliationItem(
                    receiptReconciliationRunId: run.Id,
                    bankStatementLineId: line.Id,
                    accountsReceivableReceiptId: null,
                    accountsPayablePaymentId: null,
                    customerRefundId: refundExceptionCandidate.Id,
                    matchedDocumentType: "CUSTOMER_REFUND",
                    matchStatusCode: "EXCEPTION",
                    statementAmount: statementAmount,
                    matchedDocumentAmount: refundExceptionCandidate.AmountAmount,
                    differenceAmount: amountDifference,
                    notes: notes));

                continue;
            }

            run.AddItem(new ReceiptReconciliationItem(
                receiptReconciliationRunId: run.Id,
                bankStatementLineId: line.Id,
                accountsReceivableReceiptId: null,
                accountsPayablePaymentId: null,
                customerRefundId: null,
                matchedDocumentType: "UNMATCHED",
                matchStatusCode: "UNMATCHED",
                statementAmount: statementAmount,
                matchedDocumentAmount: null,
                differenceAmount: statementAmount,
                notes: "No posted AP payment matched by reference number and amount."));
        }

        run.Complete(command.ActorUserId);
        _dbContext.ReceiptReconciliationRuns.Add(run);
        _dbContext.SaveChanges();
        AddInitialAuditEvents(run, command.ActorUserId);
        _dbContext.SaveChanges();
        return GetRun(run.Id)!;
    }

    public IReadOnlyCollection<ReconciliationExceptionQueueItemView> GetExceptionQueue(string? matchStatusCode = null)
    {
        var normalizedStatus = string.IsNullOrWhiteSpace(matchStatusCode)
            ? null
            : matchStatusCode.Trim().ToUpperInvariant();

        var eligibleStatuses = normalizedStatus switch
        {
            null or "" or "ALL" => new[] { "EXCEPTION", "UNMATCHED" },
            "EXCEPTION" => new[] { "EXCEPTION" },
            "UNMATCHED" => new[] { "UNMATCHED" },
            _ => Array.Empty<string>()
        };

        if (eligibleStatuses.Length == 0)
        {
            return Array.Empty<ReconciliationExceptionQueueItemView>();
        }

        var items = _dbContext.ReceiptReconciliationItems
            .AsNoTracking()
            .ToArray()
            .Where(item => eligibleStatuses.Contains(item.MatchStatusCode, StringComparer.OrdinalIgnoreCase))
            .ToArray();

        if (items.Length == 0)
        {
            return Array.Empty<ReconciliationExceptionQueueItemView>();
        }

        var runIds = items.Select(item => item.ReceiptReconciliationRunId).Distinct().ToArray();
        var lineIds = items.Select(item => item.BankStatementLineId).Distinct().ToArray();
        var runs = _dbContext.ReceiptReconciliationRuns
            .AsNoTracking()
            .ToArray()
            .Where(run => runIds.Contains(run.Id))
            .ToDictionary(run => run.Id);
        var lines = _dbContext.BankStatementLines
            .AsNoTracking()
            .ToArray()
            .Where(line => lineIds.Contains(line.Id))
            .ToDictionary(line => line.Id);
        var bankStatementIds = runs.Values.Select(run => run.BankStatementId).Distinct().ToArray();
        var statements = _dbContext.BankStatements
            .AsNoTracking()
            .ToArray()
            .Where(statement => bankStatementIds.Contains(statement.Id))
            .ToDictionary(statement => statement.Id);

        return items
            .OrderByDescending(item => runs[item.ReceiptReconciliationRunId].StartedAtUtc)
            .ThenBy(item => lines[item.BankStatementLineId].TransactionDate)
            .ThenBy(item => lines[item.BankStatementLineId].LineNumber)
            .Select(item =>
            {
                var run = runs[item.ReceiptReconciliationRunId];
                var line = lines[item.BankStatementLineId];
                var statement = statements[run.BankStatementId];

                return new ReconciliationExceptionQueueItemView(
                    item.Id,
                    run.Id,
                    run.RunNumber,
                    statement.Id,
                    statement.StatementNumber,
                    statement.BankAccountCode,
                    line.Id,
                    line.LineNumber,
                    line.TransactionDate,
                    line.ExternalReference,
                    line.DescriptionText,
                    item.MatchedDocumentType,
                    item.MatchStatusCode,
                    item.AccountsReceivableReceiptId,
                    item.AccountsPayablePaymentId,
                    item.CustomerRefundId,
                    item.StatementAmount,
                    item.MatchedDocumentAmount,
                    item.DifferenceAmount,
                    item.Notes,
                    item.ResolutionCode,
                    item.OverrideApprovalRoleCode,
                    item.OverrideReasonCode,
                    item.ResolvedAtUtc,
                    item.ResolvedByUserId,
                    run.StartedAtUtc,
                    run.CompletedAtUtc);
            })
            .ToArray();
    }

    public IReadOnlyCollection<ReconciliationHistoryEntryView> GetHistory(long? receiptReconciliationRunId = null, long? bankStatementId = null, string? resolutionCode = null, string? matchedDocumentType = null)
    {
        var items = _dbContext.ReceiptReconciliationItems
            .AsNoTracking()
            .ToArray()
            .Where(item => item.ResolvedAtUtc.HasValue || !string.IsNullOrWhiteSpace(item.ResolutionCode));

        if (receiptReconciliationRunId.HasValue)
        {
            items = items.Where(item => item.ReceiptReconciliationRunId == receiptReconciliationRunId.Value);
        }

        if (!string.IsNullOrWhiteSpace(resolutionCode))
        {
            items = items.Where(item => string.Equals(item.ResolutionCode, resolutionCode.Trim(), StringComparison.OrdinalIgnoreCase));
        }

        if (!string.IsNullOrWhiteSpace(matchedDocumentType))
        {
            items = items.Where(item => string.Equals(item.MatchedDocumentType, matchedDocumentType.Trim(), StringComparison.OrdinalIgnoreCase));
        }

        var filteredItems = items.ToArray();
        if (filteredItems.Length == 0)
        {
            return Array.Empty<ReconciliationHistoryEntryView>();
        }

        var runIds = filteredItems.Select(item => item.ReceiptReconciliationRunId).Distinct().ToArray();
        var lineIds = filteredItems.Select(item => item.BankStatementLineId).Distinct().ToArray();
        var runs = _dbContext.ReceiptReconciliationRuns
            .AsNoTracking()
            .ToArray()
            .Where(run => runIds.Contains(run.Id))
            .ToDictionary(run => run.Id);
        var lines = _dbContext.BankStatementLines
            .AsNoTracking()
            .ToArray()
            .Where(line => lineIds.Contains(line.Id))
            .ToDictionary(line => line.Id);
        var statements = _dbContext.BankStatements
            .AsNoTracking()
            .ToArray()
            .Where(statement => runs.Values.Select(run => run.BankStatementId).Contains(statement.Id))
            .ToDictionary(statement => statement.Id);

        var entries = filteredItems
            .Where(item => !bankStatementId.HasValue || runs[item.ReceiptReconciliationRunId].BankStatementId == bankStatementId.Value)
            .OrderByDescending(item => item.ResolvedAtUtc ?? item.CreatedAtUtc)
            .ThenByDescending(item => item.Id)
            .Select(item => MapHistoryEntry(item, runs[item.ReceiptReconciliationRunId], lines[item.BankStatementLineId], statements[runs[item.ReceiptReconciliationRunId].BankStatementId]))
            .ToArray();

        return entries;
    }

    public ReconciliationAuditView? GetAudit(long receiptReconciliationItemId)
    {
        var item = _dbContext.ReceiptReconciliationItems
            .AsNoTracking()
            .SingleOrDefault(existingItem => existingItem.Id == receiptReconciliationItemId);

        if (item is null)
        {
            return null;
        }

        var run = _dbContext.ReceiptReconciliationRuns.AsNoTracking().Single(existingRun => existingRun.Id == item.ReceiptReconciliationRunId);
        var line = _dbContext.BankStatementLines.AsNoTracking().Single(existingLine => existingLine.Id == item.BankStatementLineId);
        var statement = _dbContext.BankStatements.AsNoTracking().Single(existingStatement => existingStatement.Id == run.BankStatementId);
        var auditEvents = _dbContext.AuditEvents
            .AsNoTracking()
            .Where(auditEvent =>
                auditEvent.ModuleCode == ReconciliationAuditModuleCode
                && auditEvent.EntityName == ReconciliationAuditEntityName
                && auditEvent.EntityId == item.Id)
            .OrderBy(auditEvent => auditEvent.OccurredAtUtc)
            .ThenBy(auditEvent => auditEvent.Id)
            .Select(auditEvent => new ReconciliationAuditEventView(
                auditEvent.Id,
                auditEvent.ActionCode,
                auditEvent.ActorUserId,
                auditEvent.OldValue,
                auditEvent.NewValue,
                auditEvent.OccurredAtUtc))
            .ToArray();

        return new ReconciliationAuditView(
            MapHistoryEntry(item, run, line, statement),
            auditEvents);
    }

    public ReceiptReconciliationItemView ManualMatch(long receiptReconciliationItemId, ManualMatchReconciliationItemCommand command)
    {
        var item = _dbContext.ReceiptReconciliationItems.Single(existingItem => existingItem.Id == receiptReconciliationItemId);
        var run = _dbContext.ReceiptReconciliationRuns
            .Include(existingRun => existingRun.Items)
            .Single(existingRun => existingRun.Id == item.ReceiptReconciliationRunId);
        var line = _dbContext.BankStatementLines.Single(existingLine => existingLine.Id == item.BankStatementLineId);
        var statement = _dbContext.BankStatements.Single(existingStatement => existingStatement.Id == line.BankStatementId);

        EnsureItemCanBeResolved(item, line);
        var oldValue = SerializeAuditPayload(item);
        ResolveItem(item, line, statement.CurrencyCode, command.ActorUserId, command.MatchedDocumentType, command.AccountsReceivableReceiptId, command.AccountsPayablePaymentId, command.CustomerRefundId, command.Notes, requireExactAmount: true, resolutionStatusCode: "MANUAL_MATCHED");
        AddResolutionAuditEvent(item, command.ActorUserId, "MANUAL_MATCHED", oldValue);

        run.RecalculateCounters();
        _dbContext.SaveChanges();
        return GetRun(run.Id)!.Items.Single(existingItem => existingItem.ReceiptReconciliationItemId == receiptReconciliationItemId);
    }

    public ReceiptReconciliationItemView OverrideItem(long receiptReconciliationItemId, OverrideReconciliationItemCommand command)
    {
        var item = _dbContext.ReceiptReconciliationItems.Single(existingItem => existingItem.Id == receiptReconciliationItemId);
        var run = _dbContext.ReceiptReconciliationRuns
            .Include(existingRun => existingRun.Items)
            .Single(existingRun => existingRun.Id == item.ReceiptReconciliationRunId);
        var line = _dbContext.BankStatementLines.Single(existingLine => existingLine.Id == item.BankStatementLineId);
        var statement = _dbContext.BankStatements.Single(existingStatement => existingStatement.Id == line.BankStatementId);

        EnsureItemCanBeResolved(item, line);
        if (string.IsNullOrWhiteSpace(command.Notes))
        {
            throw new InvalidOperationException("Reconciliation override requires finance notes.");
        }

        var oldValue = SerializeAuditPayload(item);
        item.RecordOverrideApproval(command.ApprovalRoleCode, command.OverrideReasonCode);
        ResolveItem(item, line, statement.CurrencyCode, command.ActorUserId, command.MatchedDocumentType, command.AccountsReceivableReceiptId, command.AccountsPayablePaymentId, command.CustomerRefundId, command.Notes, requireExactAmount: false, resolutionStatusCode: "OVERRIDDEN");
        AddResolutionAuditEvent(item, command.ActorUserId, "OVERRIDDEN", oldValue);

        run.RecalculateCounters();
        _dbContext.SaveChanges();
        return GetRun(run.Id)!.Items.Single(existingItem => existingItem.ReceiptReconciliationItemId == receiptReconciliationItemId);
    }

    private static BankStatementDetailView Map(BankStatement statement) =>
        new(
            statement.Id,
            statement.StatementNumber,
            statement.BankAccountCode,
            statement.StatementDate,
            statement.CurrencyCode,
            statement.ImportStatusCode,
            statement.OpeningBalanceAmount,
            statement.ClosingBalanceAmount,
            statement.Lines.OrderBy(line => line.LineNumber).Select(line => new BankStatementLineView(
                line.Id,
                line.LineNumber,
                line.TransactionDate,
                line.ExternalReference,
                line.DescriptionText,
                line.DebitAmount,
                line.CreditAmount,
                line.RunningBalanceAmount,
                line.IsReconciled,
                line.AccountsReceivableReceiptId,
                line.AccountsPayablePaymentId,
                line.CustomerRefundId)).ToArray());

    private static ReceiptReconciliationRunDetailView Map(ReceiptReconciliationRun run) =>
        new(
            run.Id,
            run.RunNumber,
            run.AccountingPeriodId,
            run.BankStatementId,
            run.RunStatusCode,
            run.MatchedItemCount,
            run.ExceptionItemCount,
            run.UnmatchedItemCount,
            run.StartedAtUtc,
            run.CompletedAtUtc,
            run.Items.OrderBy(item => item.Id).Select(item => new ReceiptReconciliationItemView(
                item.Id,
                item.BankStatementLineId,
                item.AccountsReceivableReceiptId,
                item.AccountsPayablePaymentId,
                item.CustomerRefundId,
                item.MatchedDocumentType,
                item.MatchStatusCode,
                item.StatementAmount,
                item.MatchedDocumentAmount,
                item.DifferenceAmount,
                item.Notes,
                item.ResolutionCode,
                item.OverrideApprovalRoleCode,
                item.OverrideReasonCode,
                item.ResolvedAtUtc,
                item.ResolvedByUserId)).ToArray());

    private static ReconciliationHistoryEntryView MapHistoryEntry(
        ReceiptReconciliationItem item,
        ReceiptReconciliationRun run,
        BankStatementLine line,
        BankStatement statement) =>
        new(
            item.Id,
            run.Id,
            run.RunNumber,
            statement.Id,
            statement.StatementNumber,
            statement.BankAccountCode,
            line.Id,
            line.LineNumber,
            line.TransactionDate,
            line.ExternalReference,
            line.DescriptionText,
            item.MatchedDocumentType,
            item.MatchStatusCode,
            item.StatementAmount,
            item.MatchedDocumentAmount,
            item.DifferenceAmount,
            item.Notes,
            item.ResolutionCode,
            item.OverrideApprovalRoleCode,
            item.OverrideReasonCode,
            item.CreatedAtUtc,
            item.CreatedByUserId,
            item.ResolvedAtUtc,
            item.ResolvedByUserId);

    private void EnsureItemCanBeResolved(ReceiptReconciliationItem item, BankStatementLine line)
    {
        if (!string.Equals(item.MatchStatusCode, "EXCEPTION", StringComparison.OrdinalIgnoreCase)
            && !string.Equals(item.MatchStatusCode, "UNMATCHED", StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException("Only EXCEPTION or UNMATCHED reconciliation items can enter manual resolution.");
        }

        if (line.IsReconciled)
        {
            throw new InvalidOperationException("Bank statement line is already reconciled.");
        }
    }

    private static string NormalizeCode(string? value, string fieldName)
    {
        var normalized = NormalizeText(value)?.ToUpperInvariant();
        if (string.IsNullOrWhiteSpace(normalized))
        {
            throw new InvalidOperationException($"Bank statement import requires {fieldName}.");
        }

        return normalized;
    }

    private static string? NormalizeText(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return null;
        }

        return string.Join(" ", value
            .Trim()
            .Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries));
    }

    private static decimal NormalizeAmount(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);

    private static NormalizedBankStatementLine NormalizeLine(CreateBankStatementLineCommand line)
    {
        var normalizedDebitAmount = NormalizeAmount(line.DebitAmount);
        var normalizedCreditAmount = NormalizeAmount(line.CreditAmount);

        if (normalizedDebitAmount < 0m || normalizedCreditAmount < 0m)
        {
            throw new InvalidOperationException("Bank statement import does not allow negative debit or credit amounts.");
        }

        if ((normalizedDebitAmount == 0m && normalizedCreditAmount == 0m)
            || (normalizedDebitAmount != 0m && normalizedCreditAmount != 0m))
        {
            throw new InvalidOperationException("Each bank statement line must carry either a debit amount or a credit amount, but not both.");
        }

        var normalizedExternalReference = NormalizeText(line.ExternalReference)?.ToUpperInvariant();
        var normalizedDescription = NormalizeText(line.DescriptionText);
        var normalizedRunningBalance = NormalizeAmount(line.RunningBalanceAmount);

        return new NormalizedBankStatementLine(
            line.TransactionDate,
            normalizedExternalReference,
            normalizedDescription,
            normalizedDebitAmount,
            normalizedCreditAmount,
            normalizedRunningBalance,
            new BankStatementLineFingerprint(
                line.TransactionDate,
                normalizedExternalReference,
                normalizedDescription,
                normalizedDebitAmount,
                normalizedCreditAmount,
                normalizedRunningBalance));
    }

    private static BankStatementLineFingerprint CreateFingerprint(BankStatementLine line) =>
        new(
            line.TransactionDate,
            NormalizeText(line.ExternalReference)?.ToUpperInvariant(),
            NormalizeText(line.DescriptionText),
            NormalizeAmount(line.DebitAmount),
            NormalizeAmount(line.CreditAmount),
            NormalizeAmount(line.RunningBalanceAmount));

    private void ResolveItem(
        ReceiptReconciliationItem item,
        BankStatementLine line,
        string statementCurrencyCode,
        long actorUserId,
        string matchedDocumentType,
        long? accountsReceivableReceiptId,
        long? accountsPayablePaymentId,
        long? customerRefundId,
        string? notes,
        bool requireExactAmount,
        string resolutionStatusCode)
    {
        var normalizedDocumentType = matchedDocumentType.Trim().ToUpperInvariant();

        switch (normalizedDocumentType)
        {
            case "AR_RECEIPT":
            {
                if (!accountsReceivableReceiptId.HasValue)
                {
                    throw new InvalidOperationException("Manual reconciliation against AR receipt requires receipt ID.");
                }

                var receipt = _dbContext.AccountsReceivableReceipts.Single(existingReceipt => existingReceipt.Id == accountsReceivableReceiptId.Value);
                if (receipt.StatusId != WorkflowStatusSeedIds.Posted)
                {
                    throw new InvalidOperationException("Manual reconciliation requires a posted AR receipt.");
                }

                if (!string.Equals(receipt.CurrencyCode, statementCurrencyCode, StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidOperationException("Manual reconciliation document currency must match bank statement currency.");
                }

                if (_dbContext.BankStatementLines.Any(existingLine =>
                        existingLine.Id != line.Id &&
                        existingLine.IsReconciled &&
                        existingLine.AccountsReceivableReceiptId == receipt.Id))
                {
                    throw new InvalidOperationException("Selected AR receipt is already reconciled to another bank statement line.");
                }

                if (requireExactAmount && receipt.AmountAmount != line.GetStatementAmount())
                {
                    throw new InvalidOperationException("Manual match requires exact amount equality between bank statement line and selected AR receipt.");
                }

                line.Reconcile(receipt.Id);
                item.ResolveWithAccountsReceivableReceipt(actorUserId, receipt.Id, receipt.AmountAmount, resolutionStatusCode, notes);
                return;
            }

            case "AP_PAYMENT":
            {
                if (!accountsPayablePaymentId.HasValue)
                {
                    throw new InvalidOperationException("Manual reconciliation against AP payment requires payment ID.");
                }

                var payment = _dbContext.AccountsPayablePayments.Single(existingPayment => existingPayment.Id == accountsPayablePaymentId.Value);
                if (payment.StatusId != WorkflowStatusSeedIds.Posted)
                {
                    throw new InvalidOperationException("Manual reconciliation requires a posted AP payment.");
                }

                if (!string.Equals(payment.CurrencyCode, statementCurrencyCode, StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidOperationException("Manual reconciliation document currency must match bank statement currency.");
                }

                if (_dbContext.BankStatementLines.Any(existingLine =>
                        existingLine.Id != line.Id &&
                        existingLine.IsReconciled &&
                        existingLine.AccountsPayablePaymentId == payment.Id))
                {
                    throw new InvalidOperationException("Selected AP payment is already reconciled to another bank statement line.");
                }

                if (requireExactAmount && payment.AmountAmount != line.GetStatementAmount())
                {
                    throw new InvalidOperationException("Manual match requires exact amount equality between bank statement line and selected AP payment.");
                }

                line.ReconcileAccountsPayablePayment(payment.Id);
                item.ResolveWithAccountsPayablePayment(actorUserId, payment.Id, payment.AmountAmount, resolutionStatusCode, notes);
                return;
            }

            case "CUSTOMER_REFUND":
            {
                if (!customerRefundId.HasValue)
                {
                    throw new InvalidOperationException("Manual reconciliation against customer refund requires refund ID.");
                }

                var refund = _dbContext.CustomerRefunds.Single(existingRefund => existingRefund.Id == customerRefundId.Value);
                if (refund.StatusId != WorkflowStatusSeedIds.Posted)
                {
                    throw new InvalidOperationException("Manual reconciliation requires a posted customer refund.");
                }

                if (!string.Equals(refund.CurrencyCode, statementCurrencyCode, StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidOperationException("Manual reconciliation document currency must match bank statement currency.");
                }

                if (_dbContext.BankStatementLines.Any(existingLine =>
                        existingLine.Id != line.Id &&
                        existingLine.IsReconciled &&
                        existingLine.CustomerRefundId == refund.Id))
                {
                    throw new InvalidOperationException("Selected customer refund is already reconciled to another bank statement line.");
                }

                if (requireExactAmount && refund.AmountAmount != line.GetStatementAmount())
                {
                    throw new InvalidOperationException("Manual match requires exact amount equality between bank statement line and selected customer refund.");
                }

                line.ReconcileCustomerRefund(refund.Id);
                item.ResolveWithCustomerRefund(actorUserId, refund.Id, refund.AmountAmount, resolutionStatusCode, notes);
                return;
            }

            default:
                throw new InvalidOperationException($"Unsupported reconciliation document type '{matchedDocumentType}'.");
        }
    }

    private void AddInitialAuditEvents(ReceiptReconciliationRun run, long actorUserId)
    {
        foreach (var item in run.Items)
        {
            _dbContext.AuditEvents.Add(new AuditEvent(
                moduleCode: ReconciliationAuditModuleCode,
                entityName: ReconciliationAuditEntityName,
                entityId: item.Id,
                actionCode: GetInitialAuditActionCode(item.MatchStatusCode),
                actorUserId: actorUserId,
                oldValue: null,
                newValue: SerializeAuditPayload(item)));
        }
    }

    private void AddResolutionAuditEvent(ReceiptReconciliationItem item, long actorUserId, string actionCode, string? oldValue)
    {
        _dbContext.AuditEvents.Add(new AuditEvent(
            moduleCode: ReconciliationAuditModuleCode,
            entityName: ReconciliationAuditEntityName,
            entityId: item.Id,
            actionCode: actionCode,
            actorUserId: actorUserId,
            oldValue: oldValue,
            newValue: SerializeAuditPayload(item)));
    }

    private static string SerializeAuditPayload(ReceiptReconciliationItem item) =>
        JsonSerializer.Serialize(new
        {
            item.MatchedDocumentType,
            item.MatchStatusCode,
            item.StatementAmount,
            item.MatchedDocumentAmount,
            item.DifferenceAmount,
            item.ResolutionCode,
            item.OverrideApprovalRoleCode,
            item.OverrideReasonCode,
            item.AccountsReceivableReceiptId,
            item.AccountsPayablePaymentId,
            item.CustomerRefundId,
            item.Notes
        });

    private static string GetInitialAuditActionCode(string matchStatusCode) =>
        matchStatusCode.Trim().ToUpperInvariant() switch
        {
            "MATCHED" => "MATCHED_IDENTIFIED",
            "EXCEPTION" => "EXCEPTION_IDENTIFIED",
            "UNMATCHED" => "UNMATCHED_IDENTIFIED",
            _ => "RECONCILIATION_ITEM_CREATED"
        };

    private const string ReconciliationAuditModuleCode = "RECONCILIATION";
    private const string ReconciliationAuditEntityName = "RECONCILIATION_ITEM";

    private sealed record NormalizedBankStatementLine(
        DateOnly TransactionDate,
        string? ExternalReference,
        string? DescriptionText,
        decimal DebitAmount,
        decimal CreditAmount,
        decimal RunningBalanceAmount,
        BankStatementLineFingerprint Fingerprint);

    private sealed record BankStatementLineFingerprint(
        DateOnly TransactionDate,
        string? ExternalReference,
        string? DescriptionText,
        decimal DebitAmount,
        decimal CreditAmount,
        decimal RunningBalanceAmount)
    {
        public string ToDisplayText() =>
            $"{TransactionDate:yyyy-MM-dd}|{ExternalReference ?? "NO-REF"}|{DebitAmount:0.00}|{CreditAmount:0.00}|{RunningBalanceAmount:0.00}";
    }
}
