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

namespace CashLineERP.Infrastructure.Persistence.Repositories;

public sealed class PaymentSettlementBatchRepository : IPaymentSettlementBatchRepository
{
    private readonly CashLineErpDbContext _dbContext;

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

    public IReadOnlyCollection<PaymentSettlementBatchDetailView> GetBatches(long? paymentChannelConfigurationId = null)
    {
        var channels = _dbContext.PaymentChannelConfigurations.AsNoTracking().ToArray().ToDictionary(entry => entry.Id);
        var conversion = CurrencyConversionContext.Create(_dbContext);

        return _dbContext.Set<PaymentSettlementBatch>()
            .AsNoTracking()
            .Include(entry => entry.Lines)
            .Include(entry => entry.Exceptions)
            .Where(entry => !paymentChannelConfigurationId.HasValue || entry.PaymentChannelConfigurationId == paymentChannelConfigurationId.Value)
            .OrderByDescending(entry => entry.SettlementDate)
            .ThenByDescending(entry => entry.Id)
            .ToArray()
            .Select(entry => Map(entry, channels[entry.PaymentChannelConfigurationId], conversion))
            .ToArray();
    }

    public PaymentSettlementBatchDetailView? GetBatch(long paymentSettlementBatchId)
    {
        var batch = _dbContext.Set<PaymentSettlementBatch>()
            .AsNoTracking()
            .Include(entry => entry.Lines)
            .Include(entry => entry.Exceptions)
            .SingleOrDefault(entry => entry.Id == paymentSettlementBatchId);

        if (batch is null)
        {
            return null;
        }

        var channel = _dbContext.PaymentChannelConfigurations
            .AsNoTracking()
            .Single(entry => entry.Id == batch.PaymentChannelConfigurationId);
        var conversion = CurrencyConversionContext.Create(_dbContext);

        return Map(batch, channel, conversion);
    }

    public PaymentSettlementBatchDetailView Create(CreatePaymentSettlementBatchCommand command)
    {
        ValidateCreateCommand(command);
        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, command.SettlementDate, "payment settlement batch");

        var channel = ResolveSettlementChannel(command.PaymentChannelConfigurationId);
        var refunds = ResolveRefunds(channel, command.Lines.Select(line => line.CustomerRefundId).ToArray(), command.SettlementDate);

        var nextSequence = (_dbContext.Set<PaymentSettlementBatch>().Max(entry => (long?)entry.Id) ?? 0L) + 1L;
        var batch = new PaymentSettlementBatch(
            batchNumber: $"PSET-{nextSequence:D6}",
            paymentChannelConfigurationId: channel.Id,
            settlementDate: command.SettlementDate,
            currencyCode: channel.CurrencyCode,
            externalBatchReference: NormalizeOptional(command.ExternalBatchReference));

        foreach (var lineCommand in command.Lines)
        {
            var refund = refunds[lineCommand.CustomerRefundId];
            var hasException = HasLineException(refund, lineCommand);
            batch.AddLine(
                sourceDocumentType: "CUSTOMER_REFUND",
                sourceDocumentId: refund.Id,
                sourceDocumentNumber: refund.RefundNumber,
                partyId: refund.CustomerId,
                internalAmount: refund.AmountAmount,
                settledAmount: NormalizeAmount(lineCommand.SettledAmount),
                feeAmount: NormalizeAmount(lineCommand.FeeAmount),
                netAmount: NormalizeAmount(lineCommand.NetAmount),
                providerReference: NormalizeOptional(lineCommand.ProviderReference),
                notes: NormalizeOptional(lineCommand.Notes),
                hasException: hasException);
        }

        _dbContext.Set<PaymentSettlementBatch>().Add(batch);
        _dbContext.SaveChanges();

        var lineCommandsByRefundId = command.Lines.ToDictionary(line => line.CustomerRefundId);
        var exceptions = new List<PaymentSettlementException>();

        foreach (var line in batch.Lines)
        {
            var lineCommand = lineCommandsByRefundId[line.SourceDocumentId];
            if (string.IsNullOrWhiteSpace(line.ProviderReference))
            {
                exceptions.Add(new PaymentSettlementException(
                    paymentSettlementBatchId: batch.Id,
                    paymentSettlementBatchLineId: line.Id,
                    sourceDocumentType: line.SourceDocumentType,
                    sourceDocumentId: line.SourceDocumentId,
                    sourceDocumentNumber: line.SourceDocumentNumber,
                    partyId: line.PartyId,
                    exceptionCode: "MISSING_PROVIDER_REFERENCE",
                    differenceAmount: 0m,
                    notes: "Provider settlement line did not include a provider reference."));
            }

            if (NormalizeAmount(line.InternalAmount) != NormalizeAmount(lineCommand.SettledAmount))
            {
                exceptions.Add(new PaymentSettlementException(
                    paymentSettlementBatchId: batch.Id,
                    paymentSettlementBatchLineId: line.Id,
                    sourceDocumentType: line.SourceDocumentType,
                    sourceDocumentId: line.SourceDocumentId,
                    sourceDocumentNumber: line.SourceDocumentNumber,
                    partyId: line.PartyId,
                    exceptionCode: "AMOUNT_VARIANCE",
                    differenceAmount: NormalizeAmount(lineCommand.SettledAmount) - NormalizeAmount(line.InternalAmount),
                    notes: "Provider settled amount differs from the posted internal refund amount."));
            }
        }

        if (exceptions.Count > 0)
        {
            _dbContext.Set<PaymentSettlementException>().AddRange(exceptions);
        }

        batch.RefreshExceptionSummary(
            openExceptionCount: exceptions.Count,
            resolvedExceptionCount: 0);
        _dbContext.SaveChanges();
        return GetBatch(batch.Id)!;
    }

    public PaymentSettlementBatchDetailView Submit(long paymentSettlementBatchId, long actorUserId)
    {
        var batch = _dbContext.Set<PaymentSettlementBatch>()
            .Include(entry => entry.Lines)
            .Include(entry => entry.Exceptions)
            .Single(entry => entry.Id == paymentSettlementBatchId);

        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, batch.SettlementDate, "payment settlement batch");
        batch.Submit(actorUserId);

        var workflowInstance = new WorkflowInstance(
            moduleCode: "FINANCE",
            documentType: "PAYMENT_SETTLEMENT_BATCH",
            documentId: batch.Id,
            makerUserId: actorUserId,
            workflowRuleId: 1L);
        workflowInstance.Submit(actorUserId, "Payment settlement batch submitted for checker approval.");

        _dbContext.WorkflowInstances.Add(workflowInstance);
        _dbContext.SaveChanges();
        return GetBatch(batch.Id)!;
    }

    public PaymentSettlementBatchDetailView Approve(long paymentSettlementBatchId, long actorUserId)
    {
        var batch = _dbContext.Set<PaymentSettlementBatch>()
            .Include(entry => entry.Lines)
            .Include(entry => entry.Exceptions)
            .Single(entry => entry.Id == paymentSettlementBatchId);

        batch.Approve(actorUserId);

        var workflowInstance = _dbContext.WorkflowInstances
            .Include(entry => entry.Actions)
            .SingleOrDefault(entry => entry.DocumentType == "PAYMENT_SETTLEMENT_BATCH" && entry.DocumentId == batch.Id);
        workflowInstance?.Approve(actorUserId, "Payment settlement batch approved by checker.");

        _dbContext.SaveChanges();
        return GetBatch(batch.Id)!;
    }

    public PaymentSettlementBatchDetailView Post(long paymentSettlementBatchId, long actorUserId)
    {
        var batch = _dbContext.Set<PaymentSettlementBatch>()
            .Include(entry => entry.Lines)
            .Include(entry => entry.Exceptions)
            .Single(entry => entry.Id == paymentSettlementBatchId);

        if (batch.StatusId == WorkflowStatusSeedIds.Posted)
        {
            return GetBatch(batch.Id)!;
        }

        AccountingPeriodLockGuard.EnsureOpenPeriod(_dbContext, batch.SettlementDate, "payment settlement batch");
        batch.RefreshExceptionSummary(
            openExceptionCount: batch.Exceptions.Count(entry => string.Equals(entry.StatusCode, "OPEN", StringComparison.OrdinalIgnoreCase)),
            resolvedExceptionCount: batch.Exceptions.Count(entry => !string.Equals(entry.StatusCode, "OPEN", StringComparison.OrdinalIgnoreCase)));
        batch.Post(actorUserId);

        OutboxEventWriter.Enqueue(
            _dbContext,
            sourceModuleCode: "FINANCE",
            aggregateType: "PAYMENT_SETTLEMENT_BATCH",
            aggregateId: batch.Id,
            eventType: "PAYMENT_SETTLEMENT_BATCH_POSTED",
            payload: new
            {
                batch.BatchNumber,
                batch.PaymentChannelConfigurationId,
                batch.SettlementDate,
                batch.CurrencyCode,
                batch.TotalTransactionCount,
                batch.TotalInternalAmount,
                batch.TotalSettledAmount,
                batch.TotalFeeAmount,
                batch.TotalNetAmount
            },
            actorUserId: actorUserId,
            correlationKey: batch.BatchNumber);

        _dbContext.SaveChanges();
        return GetBatch(batch.Id)!;
    }

    public IReadOnlyCollection<PaymentSettlementExceptionView> GetExceptions(long? paymentSettlementBatchId = null, string? statusCode = null)
    {
        var normalizedStatusCode = string.IsNullOrWhiteSpace(statusCode) ? null : statusCode.Trim().ToUpperInvariant();
        var channels = _dbContext.PaymentChannelConfigurations.AsNoTracking().ToArray().ToDictionary(entry => entry.Id);
        var batches = _dbContext.Set<PaymentSettlementBatch>()
            .AsNoTracking()
            .ToArray()
            .ToDictionary(entry => entry.Id);

        return _dbContext.Set<PaymentSettlementException>()
            .AsNoTracking()
            .Where(entry => !paymentSettlementBatchId.HasValue || entry.PaymentSettlementBatchId == paymentSettlementBatchId.Value)
            .Where(entry => normalizedStatusCode == null || entry.StatusCode == normalizedStatusCode)
            .OrderByDescending(entry => entry.Id)
            .ToArray()
            .Select(entry => Map(entry, batches[entry.PaymentSettlementBatchId], channels[batches[entry.PaymentSettlementBatchId].PaymentChannelConfigurationId]))
            .ToArray();
    }

    public PaymentSettlementExceptionView? GetException(long paymentSettlementExceptionId)
    {
        var exception = _dbContext.Set<PaymentSettlementException>()
            .AsNoTracking()
            .SingleOrDefault(entry => entry.Id == paymentSettlementExceptionId);

        if (exception is null)
        {
            return null;
        }

        var batch = _dbContext.Set<PaymentSettlementBatch>()
            .AsNoTracking()
            .Single(entry => entry.Id == exception.PaymentSettlementBatchId);
        var channel = _dbContext.PaymentChannelConfigurations
            .AsNoTracking()
            .Single(entry => entry.Id == batch.PaymentChannelConfigurationId);

        return Map(exception, batch, channel);
    }

    public PaymentSettlementExceptionView ConfirmException(long paymentSettlementExceptionId, ResolvePaymentSettlementExceptionCommand command)
    {
        var exception = _dbContext.Set<PaymentSettlementException>().Single(entry => entry.Id == paymentSettlementExceptionId);
        var line = _dbContext.Set<PaymentSettlementBatchLine>().Single(entry => entry.Id == exception.PaymentSettlementBatchLineId);
        var batch = _dbContext.Set<PaymentSettlementBatch>()
            .Include(entry => entry.Exceptions)
            .Single(entry => entry.Id == exception.PaymentSettlementBatchId);

        exception.Confirm(command.ActorUserId, NormalizeOptional(command.Notes));
        RefreshLineAndBatchResolution(batch, line, exception.ResolutionCode);

        _dbContext.SaveChanges();
        return GetException(exception.Id)!;
    }

    public PaymentSettlementExceptionView OverrideException(long paymentSettlementExceptionId, ResolvePaymentSettlementExceptionCommand command)
    {
        var exception = _dbContext.Set<PaymentSettlementException>().Single(entry => entry.Id == paymentSettlementExceptionId);
        var line = _dbContext.Set<PaymentSettlementBatchLine>().Single(entry => entry.Id == exception.PaymentSettlementBatchLineId);
        var batch = _dbContext.Set<PaymentSettlementBatch>()
            .Include(entry => entry.Exceptions)
            .Single(entry => entry.Id == exception.PaymentSettlementBatchId);

        exception.Override(command.ActorUserId, command.Notes ?? string.Empty);
        RefreshLineAndBatchResolution(batch, line, exception.ResolutionCode);

        _dbContext.SaveChanges();
        return GetException(exception.Id)!;
    }

    private PaymentChannelConfiguration ResolveSettlementChannel(long paymentChannelConfigurationId)
    {
        var channel = _dbContext.PaymentChannelConfigurations
            .AsNoTracking()
            .SingleOrDefault(entry => entry.Id == paymentChannelConfigurationId)
            ?? throw new InvalidOperationException("Payment settlement batch requires a valid payment channel.");

        if (!channel.IsActive)
        {
            throw new InvalidOperationException("Payment settlement batch requires an active payment channel.");
        }

        if (!string.Equals(channel.UsageCode, "CUSTOMER_REFUND", StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException("Current settlement batch baseline supports CUSTOMER_REFUND payment channels only.");
        }

        return channel;
    }

    private Dictionary<long, CustomerRefund> ResolveRefunds(PaymentChannelConfiguration channel, IReadOnlyCollection<long> refundIds, DateOnly settlementDate)
    {
        var refunds = _dbContext.CustomerRefunds
            .AsNoTracking()
            .Where(entry => refundIds.Contains(entry.Id))
            .ToArray();

        foreach (var refundId in refundIds)
        {
            if (!refunds.Any(entry => entry.Id == refundId))
            {
                throw new InvalidOperationException($"Payment settlement batch references unknown customer refund {refundId}.");
            }
        }

        var alreadyBatchedRefundIds = _dbContext.Set<PaymentSettlementBatchLine>()
            .AsNoTracking()
            .Where(entry => refundIds.Contains(entry.SourceDocumentId) && entry.SourceDocumentType == "CUSTOMER_REFUND")
            .Select(entry => entry.SourceDocumentId)
            .Distinct()
            .ToArray();

        if (alreadyBatchedRefundIds.Length > 0)
        {
            throw new InvalidOperationException($"Customer refund IDs already belong to a payment settlement batch: {string.Join(", ", alreadyBatchedRefundIds)}.");
        }

        foreach (var refund in refunds)
        {
            if (refund.StatusId != WorkflowStatusSeedIds.Posted)
            {
                throw new InvalidOperationException($"Customer refund {refund.RefundNumber} must be posted before settlement batching.");
            }

            if (refund.PaymentChannelConfigurationId != channel.Id)
            {
                throw new InvalidOperationException($"Customer refund {refund.RefundNumber} is not routed through payment channel {channel.ChannelCode}.");
            }

            if (!string.Equals(refund.CurrencyCode, channel.CurrencyCode, StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidOperationException($"Customer refund {refund.RefundNumber} currency does not match payment settlement batch currency.");
            }

            if (refund.RefundDate > settlementDate)
            {
                throw new InvalidOperationException($"Customer refund {refund.RefundNumber} cannot be settled before its refund date.");
            }
        }

        return refunds.ToDictionary(entry => entry.Id);
    }

    private static void ValidateCreateCommand(CreatePaymentSettlementBatchCommand command)
    {
        if (command.Lines.Count == 0)
        {
            throw new InvalidOperationException("Payment settlement batch must contain at least one settlement line.");
        }

        var duplicateRefundIds = command.Lines
            .GroupBy(line => line.CustomerRefundId)
            .Where(group => group.Count() > 1)
            .Select(group => group.Key)
            .ToArray();

        if (duplicateRefundIds.Length > 0)
        {
            throw new InvalidOperationException($"Payment settlement batch cannot contain duplicate customer refunds: {string.Join(", ", duplicateRefundIds)}.");
        }
    }

    private static bool HasLineException(CustomerRefund refund, CreatePaymentSettlementBatchLineCommand line) =>
        string.IsNullOrWhiteSpace(line.ProviderReference)
        || NormalizeAmount(refund.AmountAmount) != NormalizeAmount(line.SettledAmount);

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

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

    private void RefreshLineAndBatchResolution(PaymentSettlementBatch batch, PaymentSettlementBatchLine line, string? latestResolutionCode)
    {
        var openLineExceptionCount = _dbContext.Set<PaymentSettlementException>()
            .Count(entry => entry.PaymentSettlementBatchLineId == line.Id && entry.StatusCode == "OPEN");

        if (openLineExceptionCount == 0)
        {
            line.MarkResolved(latestResolutionCode ?? "RESOLVED");
        }
        else
        {
            line.MarkException();
        }

        batch.RefreshExceptionSummary(
            openExceptionCount: batch.Exceptions.Count(entry => string.Equals(entry.StatusCode, "OPEN", StringComparison.OrdinalIgnoreCase)),
            resolvedExceptionCount: batch.Exceptions.Count(entry => !string.Equals(entry.StatusCode, "OPEN", StringComparison.OrdinalIgnoreCase)));
    }

    private static PaymentSettlementBatchDetailView Map(PaymentSettlementBatch batch, PaymentChannelConfiguration channel, CurrencyConversionContext conversion)
    {
        var fxRateToBase = conversion.ResolveRateToBase(batch.CurrencyCode, batch.SettlementDate);

        return new(
            batch.Id,
            batch.BatchNumber,
            batch.PaymentChannelConfigurationId,
            channel.ChannelCode,
            channel.ChannelName,
            batch.SettlementDate,
            batch.CurrencyCode,
            conversion.BaseCurrencyCode,
            fxRateToBase,
            batch.ExternalBatchReference,
            WorkflowStatusSeedIds.ToCode(batch.StatusId),
            batch.TotalTransactionCount,
            batch.TotalInternalAmount,
            batch.TotalSettledAmount,
            batch.TotalFeeAmount,
            batch.TotalNetAmount,
            conversion.ConvertToBase(batch.CurrencyCode, batch.TotalInternalAmount, batch.SettlementDate),
            conversion.ConvertToBase(batch.CurrencyCode, batch.TotalSettledAmount, batch.SettlementDate),
            conversion.ConvertToBase(batch.CurrencyCode, batch.TotalFeeAmount, batch.SettlementDate),
            conversion.ConvertToBase(batch.CurrencyCode, batch.TotalNetAmount, batch.SettlementDate),
            batch.OpenExceptionCount,
            batch.ResolvedExceptionCount,
            batch.SubmittedAtUtc,
            batch.SubmittedByUserId,
            batch.ApprovedAtUtc,
            batch.ApprovedByUserId,
            batch.PostedAtUtc,
            batch.PostedByUserId,
            batch.Lines.OrderBy(entry => entry.Id).Select(line => new PaymentSettlementBatchLineView(
                line.Id,
                line.SourceDocumentType,
                line.SourceDocumentId,
                line.SourceDocumentNumber,
                line.PartyId,
                line.InternalAmount,
                line.SettledAmount,
                line.FeeAmount,
                line.NetAmount,
                conversion.ConvertToBase(batch.CurrencyCode, line.InternalAmount, batch.SettlementDate),
                conversion.ConvertToBase(batch.CurrencyCode, line.SettledAmount, batch.SettlementDate),
                conversion.ConvertToBase(batch.CurrencyCode, line.FeeAmount, batch.SettlementDate),
                conversion.ConvertToBase(batch.CurrencyCode, line.NetAmount, batch.SettlementDate),
                line.ProviderReference,
                line.SettlementStatusCode,
                line.Notes)).ToArray());
    }

    private static PaymentSettlementExceptionView Map(
        PaymentSettlementException exception,
        PaymentSettlementBatch batch,
        PaymentChannelConfiguration channel) =>
        new(
            exception.Id,
            batch.Id,
            batch.BatchNumber,
            exception.PaymentSettlementBatchLineId,
            batch.PaymentChannelConfigurationId,
            channel.ChannelCode,
            channel.ChannelName,
            batch.SettlementDate,
            batch.CurrencyCode,
            exception.SourceDocumentType,
            exception.SourceDocumentId,
            exception.SourceDocumentNumber,
            exception.PartyId,
            exception.ExceptionCode,
            exception.StatusCode,
            exception.DifferenceAmount,
            exception.Notes,
            exception.ResolutionCode,
            exception.ResolvedAtUtc,
            exception.ResolvedByUserId);
}
