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

namespace CashLineERP.Infrastructure.Persistence.Repositories;

public sealed class AccountsReceivableReceiptRepository : IAccountsReceivableReceiptRepository
{
    private readonly CashLineErpDbContext _dbContext;

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

    public IReadOnlyCollection<AccountsReceivableReceiptDetailView> GetReceipts() =>
        _dbContext.AccountsReceivableReceipts
            .AsNoTracking()
            .Include(receipt => receipt.Allocations)
            .OrderByDescending(receipt => receipt.ReceiptDate)
            .ThenByDescending(receipt => receipt.Id)
            .ToArray()
            .Select(Map)
            .ToArray();

    public AccountsReceivableReceiptDetailView? GetReceipt(long accountsReceivableReceiptId)
    {
        var receipt = _dbContext.AccountsReceivableReceipts
            .AsNoTracking()
            .Include(existingReceipt => existingReceipt.Allocations)
            .SingleOrDefault(existingReceipt => existingReceipt.Id == accountsReceivableReceiptId);

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

    public AccountsReceivableReceiptDetailView Create(CreateAccountsReceivableReceiptCommand command)
    {
        ValidateCreateCommand(command);
        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, command.ReceiptDate, "AR receipt");
        var allocationContexts = BuildAllocationContexts(command.CustomerId, command.CurrencyCode, command.Allocations);

        var nextSequence = (_dbContext.AccountsReceivableReceipts.Max(receipt => (long?)receipt.Id) ?? 0L) + 1L;
        var receipt = new AccountsReceivableReceipt(
            receiptNumber: $"ARRCPT-{nextSequence:D6}",
            customerId: command.CustomerId,
            receiptDate: command.ReceiptDate,
            receiptMethod: command.ReceiptMethod,
            currencyCode: command.CurrencyCode,
            amountAmount: command.AmountAmount,
            referenceNumber: command.ReferenceNumber,
            notes: command.Notes);

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

        _dbContext.AccountsReceivableReceipts.Add(receipt);
        _dbContext.SaveChanges();
        return GetReceipt(receipt.Id)!;
    }

    public AccountsReceivableReceiptDetailView Submit(long accountsReceivableReceiptId, long actorUserId)
    {
        var receipt = _dbContext.AccountsReceivableReceipts
            .Include(existingReceipt => existingReceipt.Allocations)
            .Single(existingReceipt => existingReceipt.Id == accountsReceivableReceiptId);

        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, receipt.ReceiptDate, "AR receipt");
        receipt.Submit(actorUserId);

        var workflowInstance = new WorkflowInstance(
            moduleCode: "FINANCE",
            documentType: "AR_RECEIPT",
            documentId: receipt.Id,
            makerUserId: actorUserId,
            workflowRuleId: 1L);
        workflowInstance.Submit(actorUserId, "AR receipt submitted for finance posting.");

        _dbContext.WorkflowInstances.Add(workflowInstance);
        _dbContext.SaveChanges();
        return GetReceipt(receipt.Id)!;
    }

    public AccountsReceivableReceiptDetailView Approve(long accountsReceivableReceiptId, long actorUserId)
    {
        var receipt = _dbContext.AccountsReceivableReceipts
            .Include(existingReceipt => existingReceipt.Allocations)
            .Single(existingReceipt => existingReceipt.Id == accountsReceivableReceiptId);

        receipt.Approve(actorUserId);

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

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

        _dbContext.SaveChanges();
        return GetReceipt(receipt.Id)!;
    }

    public AccountsReceivableReceiptDetailView Post(long accountsReceivableReceiptId, long actorUserId)
    {
        var receipt = _dbContext.AccountsReceivableReceipts
            .Include(existingReceipt => existingReceipt.Allocations)
            .Single(existingReceipt => existingReceipt.Id == accountsReceivableReceiptId);

        if (receipt.StatusId == WorkflowStatusSeedIds.Posted)
        {
            return GetReceipt(receipt.Id)!;
        }

        var postingPeriodId = AccountingPeriodLockGuard.RequireOpenPeriod(_dbContext, receipt.ReceiptDate, "AR receipt");
        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: "AR_RECEIPT",
            branchId: null,
            periodId: postingPeriodId,
            descriptionText: $"AR receipt posting for {receipt.ReceiptNumber}",
            sourceModule: "FINANCE",
            sourceDocumentId: receipt.Id);

        var journal = new GeneralLedgerJournal(
            generalLedgerBatchId: batch.Id,
            journalNumber: $"JRNL-{nextBatchSequence:D6}",
            journalType: "AR_RECEIPT",
            postingDate: receipt.ReceiptDate,
            taxDate: receipt.ReceiptDate,
            documentDate: receipt.ReceiptDate,
            descriptionText: $"Customer receipt {receipt.ReceiptNumber}");

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

        journal.AddLine(
            lineNumber: 2,
            generalLedgerAccountId: 1101L,
            branchId: null,
            departmentId: null,
            partyId: receipt.CustomerId,
            itemId: null,
            debitAmount: 0m,
            creditAmount: receipt.AmountAmount,
            currencyCode: receipt.CurrencyCode,
            fxRate: 1m,
            lineDescription: "Accounts receivable settlement");

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

    public AccountsReceivableReceiptDetailView Reverse(long accountsReceivableReceiptId, long actorUserId, ReverseAccountsReceivableReceiptCommand command)
    {
        var receipt = _dbContext.AccountsReceivableReceipts
            .Include(existingReceipt => existingReceipt.Allocations)
            .Single(existingReceipt => existingReceipt.Id == accountsReceivableReceiptId);

        var reversalPeriodId = AccountingPeriodLockGuard.RequireOpenPeriod(_dbContext, command.ReversalDate, "AR receipt 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: "AR_RECEIPT_REVERSAL",
            branchId: null,
            periodId: reversalPeriodId,
            descriptionText: $"AR receipt reversal for {receipt.ReceiptNumber}",
            sourceModule: "FINANCE",
            sourceDocumentId: receipt.Id);

        var journal = new GeneralLedgerJournal(
            generalLedgerBatchId: batch.Id,
            journalNumber: $"JRNL-{nextBatchSequence:D6}",
            journalType: "AR_RECEIPT_REVERSAL",
            postingDate: command.ReversalDate,
            taxDate: command.ReversalDate,
            documentDate: command.ReversalDate,
            descriptionText: $"Customer receipt reversal {receipt.ReceiptNumber}");

        journal.AddLine(
            lineNumber: 1,
            generalLedgerAccountId: 1101L,
            branchId: null,
            departmentId: null,
            partyId: receipt.CustomerId,
            itemId: null,
            debitAmount: receipt.AmountAmount,
            creditAmount: 0m,
            currencyCode: receipt.CurrencyCode,
            fxRate: 1m,
            lineDescription: "Accounts receivable reinstatement");

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

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

        _dbContext.SaveChanges();
        transaction?.Commit();
        return GetReceipt(receipt.Id)!;
    }

    public AccountsReceivableReceiptDetailView Reallocate(long accountsReceivableReceiptId, long actorUserId, ReallocateAccountsReceivableReceiptCommand command)
    {
        var sourceReceipt = _dbContext.AccountsReceivableReceipts
            .Include(existingReceipt => existingReceipt.Allocations)
            .Single(existingReceipt => existingReceipt.Id == accountsReceivableReceiptId);

        if (sourceReceipt.StatusId != WorkflowStatusSeedIds.Reversed)
        {
            throw new InvalidOperationException("Only reversed AR receipts can create a reallocation receipt.");
        }

        if (sourceReceipt.ReallocationReceiptId.HasValue)
        {
            throw new InvalidOperationException("AR receipt already has a linked reallocation receipt.");
        }

        ValidateCreateCommand(new CreateAccountsReceivableReceiptCommand(
            sourceReceipt.CustomerId,
            command.ReceiptDate,
            command.ReceiptMethod,
            sourceReceipt.CurrencyCode,
            sourceReceipt.AmountAmount,
            command.ReferenceNumber,
            command.Notes,
            command.Allocations));

        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, command.ReceiptDate, "AR receipt reallocation");
        var allocationContexts = BuildAllocationContexts(sourceReceipt.CustomerId, sourceReceipt.CurrencyCode, command.Allocations);

        var nextSequence = (_dbContext.AccountsReceivableReceipts.Max(receipt => (long?)receipt.Id) ?? 0L) + 1L;
        var replacementReceipt = new AccountsReceivableReceipt(
            receiptNumber: $"ARRCPT-{nextSequence:D6}",
            customerId: sourceReceipt.CustomerId,
            receiptDate: command.ReceiptDate,
            receiptMethod: NormalizeCode(command.ReceiptMethod, "AR receipt method"),
            currencyCode: sourceReceipt.CurrencyCode,
            amountAmount: sourceReceipt.AmountAmount,
            referenceNumber: NormalizeOptionalText(command.ReferenceNumber) ?? sourceReceipt.ReferenceNumber,
            notes: NormalizeOptionalText(command.Notes));

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

        _dbContext.AccountsReceivableReceipts.Add(replacementReceipt);
        _dbContext.SaveChanges();

        replacementReceipt.MarkAsReallocation(sourceReceipt.Id, actorUserId);
        sourceReceipt.LinkReallocationReceipt(replacementReceipt.Id, actorUserId);
        _dbContext.SaveChanges();

        return GetReceipt(replacementReceipt.Id)!;
    }

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

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

        if (duplicateInvoiceIds.Length > 0)
        {
            throw new InvalidOperationException($"AR receipt cannot contain duplicate allocation rows for invoice IDs: {string.Join(", ", duplicateInvoiceIds)}.");
        }

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

    private IReadOnlyCollection<AccountsReceivableInvoiceAllocationContext> BuildAllocationContexts(
        long customerId,
        string currencyCode,
        IReadOnlyCollection<CreateAccountsReceivableReceiptAllocationCommand> allocations)
    {
        var invoiceIds = allocations.Select(allocation => allocation.AccountsReceivableInvoiceId).Distinct().ToArray();
        var invoices = _dbContext.AccountsReceivableInvoices
            .AsNoTracking()
            .ToArray()
            .Where(invoice => invoiceIds.Contains(invoice.Id))
            .ToDictionary(invoice => invoice.Id);

        var existingAllocatedByInvoice = _dbContext.AccountsReceivableReceiptAllocations
            .AsNoTracking()
            .Join(
                _dbContext.AccountsReceivableReceipts.AsNoTracking(),
                allocation => allocation.AccountsReceivableReceiptId,
                receipt => receipt.Id,
                (allocation, receipt) => new { allocation, receipt.StatusId })
            .ToArray()
            .Where(entry =>
                invoiceIds.Contains(entry.allocation.AccountsReceivableInvoiceId) &&
                entry.StatusId != WorkflowStatusSeedIds.Reversed)
            .GroupBy(entry => entry.allocation.AccountsReceivableInvoiceId)
            .ToDictionary(group => group.Key, group => group.Sum(entry => entry.allocation.AllocationAmount));

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

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

            if (!invoices.TryGetValue(allocation.AccountsReceivableInvoiceId, out var invoice))
            {
                throw new InvalidOperationException($"AR receipt allocation references unknown AR invoice {allocation.AccountsReceivableInvoiceId}.");
            }

            if (invoice.StatusId != WorkflowStatusSeedIds.Posted)
            {
                throw new InvalidOperationException($"AR receipt allocation requires posted AR invoice {invoice.InvoiceNumber}.");
            }

            if (invoice.CustomerId != customerId)
            {
                throw new InvalidOperationException($"AR receipt allocation invoice {invoice.InvoiceNumber} belongs to a different customer.");
            }

            if (!string.Equals(invoice.CurrencyCode, currencyCode, StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidOperationException($"AR receipt allocation invoice {invoice.InvoiceNumber} uses currency {invoice.CurrencyCode}, which does not match receipt currency {currencyCode}.");
            }

            if (IsCreditInvoice(invoice.InvoiceType))
            {
                throw new InvalidOperationException($"AR receipt allocation cannot target credit invoice {invoice.InvoiceNumber}.");
            }

            var previouslyAllocated = existingAllocatedByInvoice.GetValueOrDefault(allocation.AccountsReceivableInvoiceId);
            var outstandingAmount = Math.Max(0m, invoice.TotalAmount - previouslyAllocated);
            if (allocation.AllocationAmount > outstandingAmount)
            {
                throw new InvalidOperationException($"AR receipt allocation amount {allocation.AllocationAmount} exceeds outstanding amount {outstandingAmount} for invoice {invoice.InvoiceNumber}.");
            }

            contexts.Add(new AccountsReceivableInvoiceAllocationContext(allocation, invoice, previouslyAllocated, outstandingAmount));
        }

        return contexts;
    }

    private static bool IsCreditInvoice(string invoiceType) =>
        string.Equals(invoiceType, "CREDIT", StringComparison.OrdinalIgnoreCase)
        || string.Equals(invoiceType, "CREDIT_NOTE", StringComparison.OrdinalIgnoreCase)
        || string.Equals(invoiceType, "CREDIT_MEMO", StringComparison.OrdinalIgnoreCase);

    private sealed record AccountsReceivableInvoiceAllocationContext(
        CreateAccountsReceivableReceiptAllocationCommand Command,
        AccountsReceivableInvoice Invoice,
        decimal PreviouslyAllocatedAmount,
        decimal OutstandingAmountBeforeAllocation);

    private AccountsReceivableReceiptDetailView Map(AccountsReceivableReceipt receipt)
    {
        var refundedAmount = _dbContext.CustomerRefunds
            .AsNoTracking()
            .Where(entry => entry.SourceAccountsReceivableReceiptId == receipt.Id)
            .Select(entry => entry.AmountAmount)
            .AsEnumerable()
            .DefaultIfEmpty(0m)
            .Sum();
        var availableRefundAmount = Math.Max(0m, receipt.UnappliedAmount - refundedAmount);

        return new AccountsReceivableReceiptDetailView(
            receipt.Id,
            receipt.ReceiptNumber,
            receipt.CustomerId,
            receipt.ReceiptDate,
            receipt.ReceiptMethod,
            receipt.CurrencyCode,
            WorkflowStatusSeedIds.ToCode(receipt.StatusId),
            receipt.AmountAmount,
            receipt.UnappliedAmount,
            refundedAmount,
            availableRefundAmount,
            receipt.ReferenceNumber,
            receipt.Notes,
            receipt.ReversalDate,
            receipt.ReversalReasonCode,
            receipt.ReversalNotes,
            receipt.ReversalGeneralLedgerBatchId,
            receipt.ReallocatedFromReceiptId,
            receipt.ReallocationReceiptId,
            receipt.GeneralLedgerBatchId,
            receipt.Allocations.Select(allocation => new AccountsReceivableReceiptAllocationView(
                allocation.Id,
                allocation.AccountsReceivableInvoiceId,
                allocation.AllocationAmount,
                allocation.AllocationDate)).ToArray());
    }

    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();
    }
}
