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 ForeignExchangeRevaluationRepository : IForeignExchangeRevaluationRepository
{
    private const long AccountsReceivableAccountId = 1101L;
    private const long AccountsPayableAccountId = 2001L;
    private const long CashAccountId = 1001L;
    private const long ForeignExchangeGainAccountId = 5101L;
    private const long ForeignExchangeLossAccountId = 6101L;

    private readonly CashLineErpDbContext _dbContext;

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

    public IReadOnlyCollection<ForeignExchangeRevaluationDetailView> GetForeignExchangeRevaluations() =>
        _dbContext.Set<ForeignExchangeRevaluation>()
            .AsNoTracking()
            .Include(entry => entry.Lines)
            .OrderByDescending(entry => entry.RevaluationDate)
            .ThenByDescending(entry => entry.Id)
            .ToArray()
            .Select(Map)
            .ToArray();

    public ForeignExchangeRevaluationDetailView? GetForeignExchangeRevaluation(long foreignExchangeRevaluationId)
    {
        var revaluation = _dbContext.Set<ForeignExchangeRevaluation>()
            .AsNoTracking()
            .Include(entry => entry.Lines)
            .SingleOrDefault(entry => entry.Id == foreignExchangeRevaluationId);

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

    public ForeignExchangeRevaluationDetailView Create(CreateForeignExchangeRevaluationCommand command)
    {
        if (!command.IncludeAccountsReceivable && !command.IncludeAccountsPayable && !command.IncludeCashAccounts)
        {
            throw new InvalidOperationException("FX revaluation requires at least one exposure source.");
        }

        var period = _dbContext.AccountingPeriods.Single(entry => entry.Id == command.AccountingPeriodId);
        EnsureRevaluationDateWithinPeriod(period, command.RevaluationDate);
        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, command.RevaluationDate, "FX revaluation");

        var conversion = CurrencyConversionContext.Create(_dbContext);
        var revaluation = new ForeignExchangeRevaluation(
            revaluationNumber: $"FXR-{((_dbContext.Set<ForeignExchangeRevaluation>().Max(entry => (long?)entry.Id) ?? 0L) + 1L):D6}",
            accountingPeriodId: command.AccountingPeriodId,
            revaluationDate: command.RevaluationDate,
            baseCurrencyCode: conversion.BaseCurrencyCode,
            notes: NormalizeOptional(command.Notes));

        var lineNumber = 1;
        if (command.IncludeAccountsReceivable)
        {
            foreach (var exposure in BuildAccountsReceivableLines(conversion, command.RevaluationDate))
            {
                revaluation.AddLine(
                    lineNumber++,
                    exposure.SourceTypeCode,
                    exposure.SourceDocumentId,
                    exposure.SourceDocumentNumber,
                    exposure.SourceGeneralLedgerAccountId,
                    exposure.BranchId,
                    exposure.PartyId,
                    exposure.SourceDocumentDate,
                    exposure.CurrencyCode,
                    exposure.ForeignAmount,
                    exposure.CarryingRateToBase,
                    exposure.RevaluationRateToBase,
                    exposure.CarryingBaseAmount,
                    exposure.RevaluedBaseAmount,
                    exposure.VarianceBaseAmount);
            }
        }

        if (command.IncludeAccountsPayable)
        {
            foreach (var exposure in BuildAccountsPayableLines(conversion, command.RevaluationDate))
            {
                revaluation.AddLine(
                    lineNumber++,
                    exposure.SourceTypeCode,
                    exposure.SourceDocumentId,
                    exposure.SourceDocumentNumber,
                    exposure.SourceGeneralLedgerAccountId,
                    exposure.BranchId,
                    exposure.PartyId,
                    exposure.SourceDocumentDate,
                    exposure.CurrencyCode,
                    exposure.ForeignAmount,
                    exposure.CarryingRateToBase,
                    exposure.RevaluationRateToBase,
                    exposure.CarryingBaseAmount,
                    exposure.RevaluedBaseAmount,
                    exposure.VarianceBaseAmount);
            }
        }

        if (command.IncludeCashAccounts)
        {
            foreach (var exposure in BuildCashAccountLines(conversion, command.RevaluationDate))
            {
                revaluation.AddLine(
                    lineNumber++,
                    exposure.SourceTypeCode,
                    exposure.SourceDocumentId,
                    exposure.SourceDocumentNumber,
                    exposure.SourceGeneralLedgerAccountId,
                    exposure.BranchId,
                    exposure.PartyId,
                    exposure.SourceDocumentDate,
                    exposure.CurrencyCode,
                    exposure.ForeignAmount,
                    exposure.CarryingRateToBase,
                    exposure.RevaluationRateToBase,
                    exposure.CarryingBaseAmount,
                    exposure.RevaluedBaseAmount,
                    exposure.VarianceBaseAmount);
            }
        }

        if (revaluation.Lines.Count == 0)
        {
            throw new InvalidOperationException("FX revaluation did not find any foreign-currency exposure requiring revaluation.");
        }

        _dbContext.Add(revaluation);
        _dbContext.SaveChanges();
        return GetForeignExchangeRevaluation(revaluation.Id)!;
    }

    public ForeignExchangeRevaluationDetailView Submit(long foreignExchangeRevaluationId, long actorUserId)
    {
        var revaluation = _dbContext.Set<ForeignExchangeRevaluation>()
            .Include(entry => entry.Lines)
            .Single(entry => entry.Id == foreignExchangeRevaluationId);
        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, revaluation.RevaluationDate, "FX revaluation");
        revaluation.Submit(actorUserId);

        var workflowInstance = new WorkflowInstance(
            moduleCode: "FINANCE",
            documentType: "FX_REVALUATION",
            documentId: revaluation.Id,
            makerUserId: actorUserId,
            workflowRuleId: 1L);
        workflowInstance.Submit(actorUserId, "FX revaluation submitted for checker approval.");
        _dbContext.WorkflowInstances.Add(workflowInstance);
        _dbContext.SaveChanges();
        return GetForeignExchangeRevaluation(revaluation.Id)!;
    }

    public ForeignExchangeRevaluationDetailView Approve(long foreignExchangeRevaluationId, long actorUserId)
    {
        var revaluation = _dbContext.Set<ForeignExchangeRevaluation>()
            .Include(entry => entry.Lines)
            .Single(entry => entry.Id == foreignExchangeRevaluationId);
        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, revaluation.RevaluationDate, "FX revaluation");
        revaluation.Approve(actorUserId);

        var workflowInstance = _dbContext.WorkflowInstances
            .Include(entry => entry.Actions)
            .SingleOrDefault(entry => entry.DocumentType == "FX_REVALUATION" && entry.DocumentId == revaluation.Id);
        workflowInstance?.Approve(actorUserId, "FX revaluation approved by checker.");

        _dbContext.SaveChanges();
        return GetForeignExchangeRevaluation(revaluation.Id)!;
    }

    public ForeignExchangeRevaluationDetailView Post(long foreignExchangeRevaluationId, long actorUserId)
    {
        var revaluation = _dbContext.Set<ForeignExchangeRevaluation>()
            .Include(entry => entry.Lines)
            .Single(entry => entry.Id == foreignExchangeRevaluationId);

        if (revaluation.StatusId == WorkflowStatusSeedIds.Posted)
        {
            return GetForeignExchangeRevaluation(revaluation.Id)!;
        }

        var period = _dbContext.AccountingPeriods.Single(entry => entry.Id == revaluation.AccountingPeriodId);
        if (string.Equals(period.CloseStatusCode, "CLOSED", StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException("Cannot post FX revaluation into a closed accounting period.");
        }

        EnsureRevaluationDateWithinPeriod(period, revaluation.RevaluationDate);
        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, revaluation.RevaluationDate, "FX revaluation");

        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: "FX_REVALUATION",
            branchId: null,
            periodId: revaluation.AccountingPeriodId,
            descriptionText: $"FX revaluation posting for {revaluation.RevaluationNumber}",
            sourceModule: "FINANCE",
            sourceDocumentId: revaluation.Id);

        var journal = new GeneralLedgerJournal(
            generalLedgerBatchId: batch.Id,
            journalNumber: $"JRNL-{nextBatchSequence:D6}",
            journalType: "FX_REVALUATION",
            postingDate: revaluation.RevaluationDate,
            taxDate: revaluation.RevaluationDate,
            documentDate: revaluation.RevaluationDate,
            descriptionText: revaluation.Notes ?? $"FX revaluation {revaluation.RevaluationNumber}");

        var lineNumber = 1;
        foreach (var line in revaluation.Lines.OrderBy(entry => entry.LineNumber))
        {
            var absoluteVariance = Math.Abs(line.VarianceBaseAmount);
            if (absoluteVariance == 0m)
            {
                continue;
            }

            if (string.Equals(line.SourceTypeCode, "AP", StringComparison.OrdinalIgnoreCase))
            {
                if (line.VarianceBaseAmount > 0m)
                {
                    journal.AddLine(lineNumber++, line.SourceGeneralLedgerAccountId, line.BranchId, null, line.PartyId, null, 0m, absoluteVariance, line.CurrencyCode, line.RevaluationRateToBase, $"FX revaluation liability increase for {line.SourceDocumentNumber}");
                    journal.AddLine(lineNumber++, ForeignExchangeLossAccountId, line.BranchId, null, line.PartyId, null, absoluteVariance, 0m, line.CurrencyCode, line.RevaluationRateToBase, $"FX loss on {line.SourceDocumentNumber}");
                }
                else
                {
                    journal.AddLine(lineNumber++, line.SourceGeneralLedgerAccountId, line.BranchId, null, line.PartyId, null, absoluteVariance, 0m, line.CurrencyCode, line.RevaluationRateToBase, $"FX revaluation liability decrease for {line.SourceDocumentNumber}");
                    journal.AddLine(lineNumber++, ForeignExchangeGainAccountId, line.BranchId, null, line.PartyId, null, 0m, absoluteVariance, line.CurrencyCode, line.RevaluationRateToBase, $"FX gain on {line.SourceDocumentNumber}");
                }
            }
            else
            {
                if (line.VarianceBaseAmount > 0m)
                {
                    journal.AddLine(lineNumber++, line.SourceGeneralLedgerAccountId, line.BranchId, null, line.PartyId, null, absoluteVariance, 0m, line.CurrencyCode, line.RevaluationRateToBase, $"FX revaluation asset increase for {line.SourceDocumentNumber}");
                    journal.AddLine(lineNumber++, ForeignExchangeGainAccountId, line.BranchId, null, line.PartyId, null, 0m, absoluteVariance, line.CurrencyCode, line.RevaluationRateToBase, $"FX gain on {line.SourceDocumentNumber}");
                }
                else
                {
                    journal.AddLine(lineNumber++, line.SourceGeneralLedgerAccountId, line.BranchId, null, line.PartyId, null, 0m, absoluteVariance, line.CurrencyCode, line.RevaluationRateToBase, $"FX revaluation asset decrease for {line.SourceDocumentNumber}");
                    journal.AddLine(lineNumber++, ForeignExchangeLossAccountId, line.BranchId, null, line.PartyId, null, absoluteVariance, 0m, line.CurrencyCode, line.RevaluationRateToBase, $"FX loss on {line.SourceDocumentNumber}");
                }
            }
        }

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

    private IReadOnlyCollection<ComputedExposure> BuildAccountsReceivableLines(CurrencyConversionContext conversion, DateOnly revaluationDate)
    {
        var postedInvoices = _dbContext.AccountsReceivableInvoices
            .AsNoTracking()
            .ToArray()
            .Where(entry => entry.StatusId == WorkflowStatusSeedIds.Posted && !string.Equals(entry.CurrencyCode, conversion.BaseCurrencyCode, StringComparison.OrdinalIgnoreCase))
            .ToArray();

        var postedReceipts = _dbContext.AccountsReceivableReceipts
            .AsNoTracking()
            .ToArray()
            .Where(entry => entry.StatusId == WorkflowStatusSeedIds.Posted)
            .ToArray();
        var postedReceiptIds = postedReceipts.Select(entry => entry.Id).ToArray();
        var allocationsByInvoiceId = _dbContext.AccountsReceivableReceiptAllocations
            .AsNoTracking()
            .ToArray()
            .Where(entry => postedReceiptIds.Contains(entry.AccountsReceivableReceiptId))
            .GroupBy(entry => entry.AccountsReceivableInvoiceId)
            .ToDictionary(group => group.Key, group => group.Sum(entry => entry.AllocationAmount));
        var salesOrdersById = _dbContext.SalesOrders
            .AsNoTracking()
            .ToArray()
            .ToDictionary(entry => entry.Id);

        return postedInvoices
            .Select(invoice =>
            {
                var originalAmount = GetAccountsReceivableSignedAmount(invoice);
                var outstandingAmount = originalAmount - allocationsByInvoiceId.GetValueOrDefault(invoice.Id, 0m);
                if (outstandingAmount == 0m)
                {
                    return null;
                }

                var carryingRate = conversion.ResolveRateToBase(invoice.CurrencyCode, invoice.InvoiceDate);
                var revaluationRate = conversion.ResolveRateToBase(invoice.CurrencyCode, revaluationDate);
                var carryingBaseAmount = Math.Round(outstandingAmount * carryingRate, 2, MidpointRounding.AwayFromZero);
                var revaluedBaseAmount = Math.Round(outstandingAmount * revaluationRate, 2, MidpointRounding.AwayFromZero);
                var varianceBaseAmount = Math.Round(revaluedBaseAmount - carryingBaseAmount, 2, MidpointRounding.AwayFromZero);
                if (varianceBaseAmount == 0m)
                {
                    return null;
                }

                long? branchId = null;
                if (invoice.SalesOrderId.HasValue && salesOrdersById.TryGetValue(invoice.SalesOrderId.Value, out var salesOrder))
                {
                    branchId = salesOrder.BranchId;
                }

                return new ComputedExposure(
                    "AR",
                    invoice.Id,
                    invoice.InvoiceNumber,
                    AccountsReceivableAccountId,
                    branchId,
                    invoice.CustomerId,
                    invoice.InvoiceDate,
                    invoice.CurrencyCode,
                    outstandingAmount,
                    carryingRate,
                    revaluationRate,
                    carryingBaseAmount,
                    revaluedBaseAmount,
                    varianceBaseAmount);
            })
            .Where(entry => entry is not null)
            .Cast<ComputedExposure>()
            .ToArray();
    }

    private IReadOnlyCollection<ComputedExposure> BuildAccountsPayableLines(CurrencyConversionContext conversion, DateOnly revaluationDate)
    {
        var postedBills = _dbContext.AccountsPayableBills
            .AsNoTracking()
            .ToArray()
            .Where(entry => entry.StatusId == WorkflowStatusSeedIds.Posted && !string.Equals(entry.CurrencyCode, conversion.BaseCurrencyCode, StringComparison.OrdinalIgnoreCase))
            .ToArray();

        var postedPayments = _dbContext.AccountsPayablePayments
            .AsNoTracking()
            .ToArray()
            .Where(entry => entry.StatusId == WorkflowStatusSeedIds.Posted)
            .ToArray();
        var postedPaymentIds = postedPayments.Select(entry => entry.Id).ToArray();
        var allocationsByBillId = _dbContext.AccountsPayablePaymentAllocations
            .AsNoTracking()
            .ToArray()
            .Where(entry => postedPaymentIds.Contains(entry.AccountsPayablePaymentId))
            .GroupBy(entry => entry.AccountsPayableBillId)
            .ToDictionary(group => group.Key, group => group.Sum(entry => entry.AllocationAmount));

        return postedBills
            .Select(bill =>
            {
                var originalAmount = GetAccountsPayableSignedAmount(bill);
                var outstandingAmount = originalAmount - allocationsByBillId.GetValueOrDefault(bill.Id, 0m);
                if (outstandingAmount == 0m)
                {
                    return null;
                }

                var carryingRate = conversion.ResolveRateToBase(bill.CurrencyCode, bill.BillDate);
                var revaluationRate = conversion.ResolveRateToBase(bill.CurrencyCode, revaluationDate);
                var carryingBaseAmount = Math.Round(outstandingAmount * carryingRate, 2, MidpointRounding.AwayFromZero);
                var revaluedBaseAmount = Math.Round(outstandingAmount * revaluationRate, 2, MidpointRounding.AwayFromZero);
                var varianceBaseAmount = Math.Round(revaluedBaseAmount - carryingBaseAmount, 2, MidpointRounding.AwayFromZero);
                if (varianceBaseAmount == 0m)
                {
                    return null;
                }

                return new ComputedExposure(
                    "AP",
                    bill.Id,
                    bill.BillNumber,
                    AccountsPayableAccountId,
                    null,
                    bill.SupplierId,
                    bill.BillDate,
                    bill.CurrencyCode,
                    outstandingAmount,
                    carryingRate,
                    revaluationRate,
                    carryingBaseAmount,
                    revaluedBaseAmount,
                    varianceBaseAmount);
            })
            .Where(entry => entry is not null)
            .Cast<ComputedExposure>()
            .ToArray();
    }

    private IReadOnlyCollection<ComputedExposure> BuildCashAccountLines(CurrencyConversionContext conversion, DateOnly revaluationDate)
    {
        var priorDate = revaluationDate.AddDays(-1);
        return _dbContext.CashAccounts
            .AsNoTracking()
            .ToArray()
            .Where(entry => entry.IsActive && entry.CurrentBalanceAmount != 0m && !string.Equals(entry.CurrencyCode, conversion.BaseCurrencyCode, StringComparison.OrdinalIgnoreCase))
            .Select(account =>
            {
                var carryingRate = conversion.ResolveRateToBase(account.CurrencyCode, priorDate);
                var revaluationRate = conversion.ResolveRateToBase(account.CurrencyCode, revaluationDate);
                var carryingBaseAmount = Math.Round(account.CurrentBalanceAmount * carryingRate, 2, MidpointRounding.AwayFromZero);
                var revaluedBaseAmount = Math.Round(account.CurrentBalanceAmount * revaluationRate, 2, MidpointRounding.AwayFromZero);
                var varianceBaseAmount = Math.Round(revaluedBaseAmount - carryingBaseAmount, 2, MidpointRounding.AwayFromZero);
                if (varianceBaseAmount == 0m)
                {
                    return null;
                }

                return new ComputedExposure(
                    "CASH",
                    account.Id,
                    account.AccountCode,
                    CashAccountId,
                    account.BranchId,
                    null,
                    priorDate,
                    account.CurrencyCode,
                    account.CurrentBalanceAmount,
                    carryingRate,
                    revaluationRate,
                    carryingBaseAmount,
                    revaluedBaseAmount,
                    varianceBaseAmount);
            })
            .Where(entry => entry is not null)
            .Cast<ComputedExposure>()
            .ToArray();
    }

    private static ForeignExchangeRevaluationDetailView Map(ForeignExchangeRevaluation revaluation) =>
        new(
            revaluation.Id,
            revaluation.RevaluationNumber,
            revaluation.AccountingPeriodId,
            revaluation.RevaluationDate,
            revaluation.BaseCurrencyCode,
            revaluation.Notes,
            WorkflowStatusSeedIds.ToCode(revaluation.StatusId),
            revaluation.TotalGainBaseAmount,
            revaluation.TotalLossBaseAmount,
            revaluation.NetVarianceBaseAmount,
            revaluation.SubmittedAtUtc,
            revaluation.SubmittedByUserId,
            revaluation.ApprovedAtUtc,
            revaluation.ApprovedByUserId,
            revaluation.PostedAtUtc,
            revaluation.PostedByUserId,
            revaluation.GeneralLedgerBatchId,
            revaluation.Lines.OrderBy(entry => entry.LineNumber)
                .Select(entry => new ForeignExchangeRevaluationLineView(
                    entry.Id,
                    entry.LineNumber,
                    entry.SourceTypeCode,
                    entry.SourceDocumentId,
                    entry.SourceDocumentNumber,
                    entry.SourceGeneralLedgerAccountId,
                    entry.BranchId,
                    entry.PartyId,
                    entry.SourceDocumentDate,
                    entry.CurrencyCode,
                    entry.ForeignAmount,
                    entry.CarryingRateToBase,
                    entry.RevaluationRateToBase,
                    entry.CarryingBaseAmount,
                    entry.RevaluedBaseAmount,
                    entry.VarianceBaseAmount))
                .ToArray());

    private static decimal GetAccountsPayableSignedAmount(Domain.Procurement.AccountsPayableBill bill) =>
        bill.IsDebitNote ? -bill.TotalAmount : bill.TotalAmount;

    private static decimal GetAccountsReceivableSignedAmount(AccountsReceivableInvoice invoice) =>
        string.Equals(invoice.InvoiceType, "CREDIT_NOTE", StringComparison.OrdinalIgnoreCase)
        || string.Equals(invoice.InvoiceType, "CREDIT", StringComparison.OrdinalIgnoreCase)
        || string.Equals(invoice.InvoiceType, "CREDIT_MEMO", StringComparison.OrdinalIgnoreCase)
            ? -invoice.TotalAmount
            : invoice.TotalAmount;

    private static void EnsureRevaluationDateWithinPeriod(AccountingPeriod period, DateOnly revaluationDate)
    {
        if (revaluationDate < period.StartDate || revaluationDate > period.EndDate)
        {
            throw new InvalidOperationException("FX revaluation date must fall within the accounting period.");
        }
    }

    private static string? NormalizeOptional(string? value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    private sealed record ComputedExposure(
        string SourceTypeCode,
        long SourceDocumentId,
        string SourceDocumentNumber,
        long SourceGeneralLedgerAccountId,
        long? BranchId,
        long? PartyId,
        DateOnly SourceDocumentDate,
        string CurrencyCode,
        decimal ForeignAmount,
        decimal CarryingRateToBase,
        decimal RevaluationRateToBase,
        decimal CarryingBaseAmount,
        decimal RevaluedBaseAmount,
        decimal VarianceBaseAmount);
}
