using CashLineERP.Application.Finance;
using CashLineERP.Domain.Finance;
using CashLineERP.Domain.Procurement;
using CashLineERP.Domain.Workflow;
using CashLineERP.SharedKernel.Workflow;
using Microsoft.EntityFrameworkCore;

namespace CashLineERP.Infrastructure.Persistence.Repositories;

public sealed class AccountsPayablePaymentRepository : IAccountsPayablePaymentRepository
{
    private readonly CashLineErpDbContext _dbContext;

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

    public IReadOnlyCollection<AccountsPayablePaymentDetailView> GetPayments() =>
        _dbContext.Set<AccountsPayablePayment>()
            .AsNoTracking()
            .Include(payment => payment.Allocations)
            .OrderByDescending(payment => payment.PaymentDate)
            .ThenByDescending(payment => payment.Id)
            .ToArray()
            .Select(Map)
            .ToArray();

    public AccountsPayablePaymentDetailView? GetPayment(long accountsPayablePaymentId)
    {
        var payment = _dbContext.Set<AccountsPayablePayment>()
            .AsNoTracking()
            .Include(existingPayment => existingPayment.Allocations)
            .SingleOrDefault(existingPayment => existingPayment.Id == accountsPayablePaymentId);

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

    public AccountsPayablePaymentDetailView Create(CreateAccountsPayablePaymentCommand command)
    {
        ValidateCreateCommand(command);
        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, command.PaymentDate, "AP payment");

        var allocationContexts = BuildAllocationContexts(command.SupplierId, command.CurrencyCode, command.Allocations);
        var nextSequence = (_dbContext.Set<AccountsPayablePayment>().Max(payment => (long?)payment.Id) ?? 0L) + 1L;
        var payment = new AccountsPayablePayment(
            paymentNumber: $"APPAY-{nextSequence:D6}",
            supplierId: command.SupplierId,
            paymentDate: command.PaymentDate,
            paymentMethod: command.PaymentMethod,
            currencyCode: command.CurrencyCode,
            amountAmount: command.AmountAmount,
            referenceNumber: command.ReferenceNumber,
            notes: command.Notes);

        foreach (var allocation in allocationContexts)
        {
            payment.AddAllocation(
                allocation.Command.AccountsPayableBillId,
                allocation.Command.AllocationAmount,
                allocation.Command.AllocationDate);
        }

        _dbContext.Add(payment);
        _dbContext.SaveChanges();
        return GetPayment(payment.Id)!;
    }

    public AccountsPayablePaymentDetailView Submit(long accountsPayablePaymentId, long actorUserId)
    {
        var payment = _dbContext.Set<AccountsPayablePayment>()
            .Include(existingPayment => existingPayment.Allocations)
            .Single(existingPayment => existingPayment.Id == accountsPayablePaymentId);

        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, payment.PaymentDate, "AP payment");
        payment.Submit(actorUserId);

        var workflowInstance = new WorkflowInstance(
            moduleCode: "FINANCE",
            documentType: "AP_PAYMENT",
            documentId: payment.Id,
            makerUserId: actorUserId,
            workflowRuleId: 1L);
        workflowInstance.Submit(actorUserId, "AP payment submitted for controlled posting.");

        _dbContext.WorkflowInstances.Add(workflowInstance);
        _dbContext.SaveChanges();
        return GetPayment(payment.Id)!;
    }

    public AccountsPayablePaymentDetailView Approve(long accountsPayablePaymentId, long actorUserId)
    {
        var payment = _dbContext.Set<AccountsPayablePayment>()
            .Include(existingPayment => existingPayment.Allocations)
            .Single(existingPayment => existingPayment.Id == accountsPayablePaymentId);

        payment.Approve(actorUserId);

        var workflowInstance = _dbContext.WorkflowInstances
            .Include(instance => instance.Actions)
            .SingleOrDefault(instance =>
                instance.DocumentType == "AP_PAYMENT" &&
                instance.DocumentId == payment.Id);

        workflowInstance?.Approve(actorUserId, "AP payment approved by checker for controlled posting.");

        _dbContext.SaveChanges();
        return GetPayment(payment.Id)!;
    }

    public AccountsPayablePaymentDetailView Post(long accountsPayablePaymentId, long actorUserId)
    {
        var payment = _dbContext.Set<AccountsPayablePayment>()
            .Include(existingPayment => existingPayment.Allocations)
            .Single(existingPayment => existingPayment.Id == accountsPayablePaymentId);

        if (payment.StatusId == WorkflowStatusSeedIds.Posted)
        {
            return GetPayment(payment.Id)!;
        }

        var postingPeriodId = AccountingPeriodLockGuard.RequireOpenPeriod(_dbContext, payment.PaymentDate, "AP payment");
        using var transaction = _dbContext.Database.IsRelational()
            ? _dbContext.Database.BeginTransaction()
            : null;

        var nextBatchSequence = (_dbContext.GeneralLedgerBatches.Max(batch => (long?)batch.Id) ?? 0L) + 1L;
        var batch = new GeneralLedgerBatch(
            batchNumber: $"GLB-{nextBatchSequence:D6}",
            batchType: "AP_PAYMENT",
            branchId: null,
            periodId: postingPeriodId,
            descriptionText: $"AP payment posting for {payment.PaymentNumber}",
            sourceModule: "FINANCE",
            sourceDocumentId: payment.Id);

        var journal = new GeneralLedgerJournal(
            generalLedgerBatchId: batch.Id,
            journalNumber: $"JRNL-{nextBatchSequence:D6}",
            journalType: "AP_PAYMENT",
            postingDate: payment.PaymentDate,
            taxDate: payment.PaymentDate,
            documentDate: payment.PaymentDate,
            descriptionText: $"Supplier payment {payment.PaymentNumber}");

        journal.AddLine(
            lineNumber: 1,
            generalLedgerAccountId: 2001L,
            branchId: null,
            departmentId: null,
            partyId: payment.SupplierId,
            itemId: null,
            debitAmount: payment.AmountAmount,
            creditAmount: 0m,
            currencyCode: payment.CurrencyCode,
            fxRate: 1m,
            lineDescription: "Accounts payable settlement");

        journal.AddLine(
            lineNumber: 2,
            generalLedgerAccountId: 1001L,
            branchId: null,
            departmentId: null,
            partyId: payment.SupplierId,
            itemId: null,
            debitAmount: 0m,
            creditAmount: payment.AmountAmount,
            currencyCode: payment.CurrencyCode,
            fxRate: 1m,
            lineDescription: "Cash / bank payment recognition");

        batch.AddJournal(journal);
        _dbContext.GeneralLedgerBatches.Add(batch);
        payment.Post(actorUserId, batch.Id);
        _dbContext.SaveChanges();
        transaction?.Commit();
        return GetPayment(payment.Id)!;
    }

    public AccountsPayablePaymentDetailView Reverse(long accountsPayablePaymentId, long actorUserId, ReverseAccountsPayablePaymentCommand command)
    {
        var payment = _dbContext.Set<AccountsPayablePayment>()
            .Include(existingPayment => existingPayment.Allocations)
            .Single(existingPayment => existingPayment.Id == accountsPayablePaymentId);

        var reversalPeriodId = AccountingPeriodLockGuard.RequireOpenPeriod(_dbContext, command.ReversalDate, "AP payment reversal");
        using var transaction = _dbContext.Database.IsRelational()
            ? _dbContext.Database.BeginTransaction()
            : null;

        var nextBatchSequence = (_dbContext.GeneralLedgerBatches.Max(batch => (long?)batch.Id) ?? 0L) + 1L;
        var batch = new GeneralLedgerBatch(
            batchNumber: $"GLB-{nextBatchSequence:D6}",
            batchType: "AP_PAYMENT_REVERSAL",
            branchId: null,
            periodId: reversalPeriodId,
            descriptionText: $"AP payment reversal for {payment.PaymentNumber}",
            sourceModule: "FINANCE",
            sourceDocumentId: payment.Id);

        var journal = new GeneralLedgerJournal(
            generalLedgerBatchId: batch.Id,
            journalNumber: $"JRNL-{nextBatchSequence:D6}",
            journalType: "AP_PAYMENT_REVERSAL",
            postingDate: command.ReversalDate,
            taxDate: command.ReversalDate,
            documentDate: command.ReversalDate,
            descriptionText: $"Supplier payment reversal {payment.PaymentNumber}");

        journal.AddLine(
            lineNumber: 1,
            generalLedgerAccountId: 1001L,
            branchId: null,
            departmentId: null,
            partyId: payment.SupplierId,
            itemId: null,
            debitAmount: payment.AmountAmount,
            creditAmount: 0m,
            currencyCode: payment.CurrencyCode,
            fxRate: 1m,
            lineDescription: "Cash / bank reinstatement");

        journal.AddLine(
            lineNumber: 2,
            generalLedgerAccountId: 2001L,
            branchId: null,
            departmentId: null,
            partyId: payment.SupplierId,
            itemId: null,
            debitAmount: 0m,
            creditAmount: payment.AmountAmount,
            currencyCode: payment.CurrencyCode,
            fxRate: 1m,
            lineDescription: "Accounts payable reversal of settlement");

        batch.AddJournal(journal);
        _dbContext.GeneralLedgerBatches.Add(batch);
        payment.Reverse(
            actorUserId,
            command.ReversalDate,
            NormalizeCode(command.ReasonCode, "AP payment reversal reason code"),
            NormalizeOptionalText(command.Notes),
            batch.Id);

        _dbContext.SaveChanges();
        transaction?.Commit();
        return GetPayment(payment.Id)!;
    }

    public AccountsPayablePaymentDetailView Repost(long accountsPayablePaymentId, long actorUserId, RepostAccountsPayablePaymentCommand command)
    {
        var sourcePayment = _dbContext.Set<AccountsPayablePayment>()
            .Include(existingPayment => existingPayment.Allocations)
            .Single(existingPayment => existingPayment.Id == accountsPayablePaymentId);

        if (sourcePayment.StatusId != WorkflowStatusSeedIds.Reversed)
        {
            throw new InvalidOperationException("Only reversed AP payments can create a replacement repost payment.");
        }

        if (sourcePayment.RepostPaymentId.HasValue)
        {
            throw new InvalidOperationException("AP payment already has a linked replacement repost payment.");
        }

        ValidateCreateCommand(new CreateAccountsPayablePaymentCommand(
            sourcePayment.SupplierId,
            command.PaymentDate,
            command.PaymentMethod,
            sourcePayment.CurrencyCode,
            sourcePayment.AmountAmount,
            command.ReferenceNumber,
            command.Notes,
            command.Allocations));

        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, command.PaymentDate, "AP payment repost");
        var allocationContexts = BuildAllocationContexts(sourcePayment.SupplierId, sourcePayment.CurrencyCode, command.Allocations);
        var nextSequence = (_dbContext.Set<AccountsPayablePayment>().Max(payment => (long?)payment.Id) ?? 0L) + 1L;
        var replacementPayment = new AccountsPayablePayment(
            paymentNumber: $"APPAY-{nextSequence:D6}",
            supplierId: sourcePayment.SupplierId,
            paymentDate: command.PaymentDate,
            paymentMethod: NormalizeCode(command.PaymentMethod, "AP payment method"),
            currencyCode: sourcePayment.CurrencyCode,
            amountAmount: sourcePayment.AmountAmount,
            referenceNumber: NormalizeOptionalText(command.ReferenceNumber) ?? sourcePayment.ReferenceNumber,
            notes: NormalizeOptionalText(command.Notes));

        foreach (var allocation in allocationContexts)
        {
            replacementPayment.AddAllocation(
                allocation.Command.AccountsPayableBillId,
                allocation.Command.AllocationAmount,
                allocation.Command.AllocationDate);
        }

        _dbContext.Add(replacementPayment);
        _dbContext.SaveChanges();

        replacementPayment.MarkAsRepost(sourcePayment.Id, actorUserId);
        sourcePayment.LinkRepostPayment(replacementPayment.Id, actorUserId);
        _dbContext.SaveChanges();

        return GetPayment(replacementPayment.Id)!;
    }

    private AccountsPayablePaymentDetailView Map(AccountsPayablePayment payment)
    {
        var billIds = payment.Allocations
            .Select(allocation => allocation.AccountsPayableBillId)
            .Distinct()
            .ToArray();

        var bills = _dbContext.AccountsPayableBills
            .AsNoTracking()
            .ToArray()
            .Where(bill => billIds.Contains(bill.Id))
            .ToDictionary(bill => bill.Id);

        var totalAllocatedByBill = _dbContext.AccountsPayablePaymentAllocations
            .AsNoTracking()
            .Join(
                _dbContext.Set<AccountsPayablePayment>().AsNoTracking(),
                allocation => allocation.AccountsPayablePaymentId,
                paymentRecord => paymentRecord.Id,
                (allocation, paymentRecord) => new { allocation, paymentRecord.StatusId })
            .ToArray()
            .Where(entry =>
                billIds.Contains(entry.allocation.AccountsPayableBillId) &&
                entry.StatusId != WorkflowStatusSeedIds.Reversed)
            .GroupBy(entry => entry.allocation.AccountsPayableBillId)
            .ToDictionary(group => group.Key, group => group.Sum(entry => entry.allocation.AllocationAmount));

        var paymentAllocatedByBill = payment.Allocations
            .GroupBy(allocation => allocation.AccountsPayableBillId)
            .ToDictionary(group => group.Key, group => group.Sum(entry => entry.AllocationAmount));

        var allocations = payment.Allocations.Select(allocation =>
        {
            var bill = bills[allocation.AccountsPayableBillId];
            var totalAllocated = totalAllocatedByBill.GetValueOrDefault(allocation.AccountsPayableBillId);
            var currentPaymentAllocated = paymentAllocatedByBill.GetValueOrDefault(allocation.AccountsPayableBillId);
            var previouslyAllocated = totalAllocated - currentPaymentAllocated;
            var outstandingAfterAllocation = Math.Max(0m, bill.TotalAmount - totalAllocated);

            return new AccountsPayablePaymentAllocationView(
                allocation.Id,
                allocation.AccountsPayableBillId,
                bill.BillNumber,
                allocation.AllocationAmount,
                allocation.AllocationDate,
                bill.TotalAmount,
                previouslyAllocated,
                outstandingAfterAllocation);
        }).ToArray();

        return new AccountsPayablePaymentDetailView(
            payment.Id,
            payment.PaymentNumber,
            payment.SupplierId,
            payment.PaymentDate,
            payment.PaymentMethod,
            payment.CurrencyCode,
            WorkflowStatusSeedIds.ToCode(payment.StatusId),
            payment.AmountAmount,
            payment.UnappliedAmount,
            payment.ReferenceNumber,
            payment.Notes,
            payment.SubmittedAtUtc,
            payment.SubmittedByUserId,
            payment.ApprovedAtUtc,
            payment.ApprovedByUserId,
            payment.PostedAtUtc,
            payment.PostedByUserId,
            payment.ReversalDate,
            payment.ReversalReasonCode,
            payment.ReversalNotes,
            payment.ReversalGeneralLedgerBatchId,
            payment.RepostedFromPaymentId,
            payment.RepostPaymentId,
            payment.GeneralLedgerBatchId,
            allocations);
    }

    private void ValidateCreateCommand(CreateAccountsPayablePaymentCommand command)
    {
        if (command.AmountAmount <= 0m)
        {
            throw new InvalidOperationException("AP payment amount must be greater than zero.");
        }

        if (command.Allocations.Count == 0)
        {
            throw new InvalidOperationException("AP payment must contain at least one allocation.");
        }

        var duplicateBillIds = command.Allocations
            .GroupBy(allocation => allocation.AccountsPayableBillId)
            .Where(group => group.Count() > 1)
            .Select(group => group.Key)
            .ToArray();

        if (duplicateBillIds.Length > 0)
        {
            throw new InvalidOperationException($"AP payment cannot contain duplicate allocation rows for bill IDs: {string.Join(", ", duplicateBillIds)}.");
        }

        var totalAllocationAmount = command.Allocations.Sum(allocation => allocation.AllocationAmount);
        if (totalAllocationAmount > command.AmountAmount)
        {
            throw new InvalidOperationException("AP payment allocations cannot exceed the payment amount.");
        }
    }

    private IReadOnlyCollection<AccountsPayableBillAllocationContext> BuildAllocationContexts(
        long supplierId,
        string currencyCode,
        IReadOnlyCollection<CreateAccountsPayablePaymentAllocationCommand> allocations)
    {
        var billIds = allocations.Select(allocation => allocation.AccountsPayableBillId).Distinct().ToArray();
        var bills = _dbContext.AccountsPayableBills
            .AsNoTracking()
            .ToArray()
            .Where(bill => billIds.Contains(bill.Id))
            .ToDictionary(bill => bill.Id);

        var existingAllocatedByBill = _dbContext.AccountsPayablePaymentAllocations
            .AsNoTracking()
            .Join(
                _dbContext.Set<AccountsPayablePayment>().AsNoTracking(),
                allocation => allocation.AccountsPayablePaymentId,
                payment => payment.Id,
                (allocation, payment) => new { allocation, payment.StatusId })
            .ToArray()
            .Where(entry =>
                billIds.Contains(entry.allocation.AccountsPayableBillId) &&
                entry.StatusId != WorkflowStatusSeedIds.Reversed)
            .GroupBy(entry => entry.allocation.AccountsPayableBillId)
            .ToDictionary(group => group.Key, group => group.Sum(entry => entry.allocation.AllocationAmount));

        var contexts = new List<AccountsPayableBillAllocationContext>(allocations.Count);

        foreach (var allocation in allocations)
        {
            if (allocation.AllocationAmount <= 0m)
            {
                throw new InvalidOperationException("AP payment allocation amount must be greater than zero.");
            }

            if (!bills.TryGetValue(allocation.AccountsPayableBillId, out var bill))
            {
                throw new InvalidOperationException($"AP payment allocation references unknown AP bill {allocation.AccountsPayableBillId}.");
            }

            if (bill.StatusId != WorkflowStatusSeedIds.Posted)
            {
                throw new InvalidOperationException($"AP payment allocation requires posted AP bill {bill.BillNumber}.");
            }

            if (bill.SupplierId != supplierId)
            {
                throw new InvalidOperationException($"AP payment allocation bill {bill.BillNumber} belongs to a different supplier.");
            }

            if (!string.Equals(bill.CurrencyCode, currencyCode, StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidOperationException($"AP payment allocation bill {bill.BillNumber} uses currency {bill.CurrencyCode}, which does not match payment currency {currencyCode}.");
            }

            var previouslyAllocated = existingAllocatedByBill.GetValueOrDefault(allocation.AccountsPayableBillId);
            var outstandingAmount = Math.Max(0m, bill.TotalAmount - previouslyAllocated);
            if (allocation.AllocationAmount > outstandingAmount)
            {
                throw new InvalidOperationException($"AP payment allocation amount {allocation.AllocationAmount} exceeds outstanding amount {outstandingAmount} for bill {bill.BillNumber}.");
            }

            contexts.Add(new AccountsPayableBillAllocationContext(allocation, bill, previouslyAllocated, outstandingAmount));
        }

        return contexts;
    }

    private sealed record AccountsPayableBillAllocationContext(
        CreateAccountsPayablePaymentAllocationCommand Command,
        AccountsPayableBill Bill,
        decimal PreviouslyAllocatedAmount,
        decimal OutstandingAmountBeforeAllocation);

    private static string NormalizeCode(string value, string fieldName)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new InvalidOperationException($"{fieldName} is required.");
        }

        return value.Trim().ToUpperInvariant();
    }

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

        return value.Trim();
    }
}
