using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;

namespace ERPCore.Services;

public sealed class SalesPostingService : ISalesPostingService
{
    private readonly IRepository<SalesInvoice> _invoices;
    private readonly IRepository<SalesSlip> _slips;
    private readonly IRepository<BundleSale> _bundles;
    private readonly IRepository<Item> _items;
    private readonly IFifoCostingService _fifo;
    private readonly ISalesDomainService _sales;
    private readonly ICurrentUser _currentUser;
    private readonly IUnitOfWork _uow;
    private readonly IGeneralLedgerService _gl;
    private readonly string _glAccountsReceivableCode;
    private readonly string _glSalesRevenueAccountCode;
    private readonly string _glSalesDiscountAccountCode;
    private readonly string _glTaxPayableAccountCode;
    private readonly string _glCogsAccountCode;
    private readonly string _glInventoryAccountCode;

    public SalesPostingService(
        IRepository<SalesInvoice> invoices,
        IRepository<SalesSlip> slips,
        IRepository<BundleSale> bundles,
        IRepository<Item> items,
        IFifoCostingService fifo,
        ISalesDomainService sales,
        ICurrentUser currentUser,
        IUnitOfWork uow,
        IGeneralLedgerService gl,
        IConfiguration configuration)
    {
        _invoices = invoices;
        _slips = slips;
        _bundles = bundles;
        _items = items;
        _fifo = fifo;
        _sales = sales;
        _currentUser = currentUser;
        _uow = uow;
        _gl = gl;
        _glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
        // Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice
        // just recognizes them immediately on Post instead of waiting for the day's close.
        _glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
        _glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
        _glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
        _glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
        _glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
    }

    public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
    {
        var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
            .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
            ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");

        if (invoice.Status != SalesInvoiceStatus.Draft)
            return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty<SalesInvoicePostingIssueDto>());

        var issues = new List<SalesInvoicePostingIssueDto>();
        foreach (var line in invoice.Lines)
        {
            if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
                continue;
            var requestedQty = line.Qty + line.FreeQty;
            var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
            if (available >= requestedQty) continue;

            var item = await _items.Query().AsNoTracking()
                .Where(x => x.ItemId == line.ItemId)
                .Select(x => new { x.Sku, x.Name })
                .FirstAsync(ct);

            issues.Add(new SalesInvoicePostingIssueDto(
                line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
                requestedQty, available, requestedQty - available, line.IsFreeIssue));
        }

        return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
    }

    public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
    {
        var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
            .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
            ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");

        if (slip.Status != SalesSlipStatus.Draft)
            return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty<SalesSlipPostingIssueDto>());

        var issues = new List<SalesSlipPostingIssueDto>();
        foreach (var line in slip.Lines)
        {
            if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
                continue;
            var requestedQty = line.Qty + line.FreeQty;
            var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
            if (available >= requestedQty) continue;

            var item = await _items.Query().AsNoTracking()
                .Where(x => x.ItemId == line.ItemId)
                .Select(x => new { x.Sku, x.Name })
                .FirstAsync(ct);

            issues.Add(new SalesSlipPostingIssueDto(
                line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
                requestedQty, available, requestedQty - available, line.IsFreeIssue));
        }

        return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
    }

    public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
    {
        var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
            .FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
            ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");

        if (bundle.Status != BundleSaleStatus.Draft)
            return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty<BundleSalePostingIssueDto>());

        var issues = new List<BundleSalePostingIssueDto>();
        foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
        {
            if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
                continue;

            var item = await _items.Query().AsNoTracking()
                .FirstAsync(x => x.ItemId == line.ItemId, ct);
            var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
            if (available >= line.Qty) continue;

            issues.Add(new BundleSalePostingIssueDto(
                line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
        }

        return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
    }

    public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
        => PostAsync(
            load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
            notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
            statusSelector: x => x.Status,
            ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
            getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
            setPosted: x => x.Status = SalesInvoiceStatus.Posted,
            setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
            sourceDocType: DocumentTypes.SalesInvoice,
            getDocId: x => x.SalesInvoiceId,
            ct: ct,
            afterConsumption: PostInvoiceRevenueAsync);

    /// <summary>
    /// Revenue recognition for an invoice, run inside the same transaction as its stock
    /// consumption (so a GL rejection rolls back the posting too, not just leaves it
    /// half-done): Debit Accounts Receivable for the full NetPayable, Credit Sales Revenue
    /// (net of discount) and Output Tax, and Debit COGS / Credit Inventory for whatever the
    /// consumption loop above just cost. Unlike SalesSlip/BundleSale, an invoice is not a
    /// cashier document and never goes through Sales Day End, so this is its only GL entry.
    /// </summary>
    private async Task PostInvoiceRevenueAsync(SalesInvoice invoice, decimal cogs, CancellationToken ct)
    {
        if (invoice.NetPayable <= 0 && cogs <= 0) return;

        var now = DateTime.UtcNow;
        var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
        var lines = new List<GlJournalEntryLineRequest>();
        if (invoice.NetPayable > 0)
        {
            lines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
            if (invoice.DiscountTotal > 0)
                lines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, invoice.DiscountTotal, 0m, "Sales discount"));
            lines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, invoice.Subtotal, "Sales revenue"));
            if (invoice.TaxTotal > 0)
                lines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, invoice.TaxTotal, "Output tax payable"));
        }
        if (cogs > 0)
        {
            lines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
            lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
        }

        var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
        {
            PeriodId = period.PeriodId,
            EntryDate = DateOnly.FromDateTime(now),
            SourceModule = "SALES_INVOICE",
            Reference = invoice.InvoiceNo,
            Narration = $"Sales invoice {invoice.InvoiceNo} posted",
            Lines = lines
        }, ct);
        invoice.GlJournalNo = result.JournalNo;
        invoice.GlPostedAt = now;
    }

    public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
        => PostAsync(
            load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
            notFoundMessage: $"Sales slip {salesSlipId} was not found.",
            statusSelector: x => x.Status,
            ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
            getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
            setPosted: x => x.Status = SalesSlipStatus.Posted,
            setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
            sourceDocType: DocumentTypes.SalesSlip,
            getDocId: x => x.SalesSlipId,
            ct: ct);

    public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
        => PostAsync(
            load: () => _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct),
            notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
            statusSelector: x => x.Status,
            ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
            getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
            setPosted: x => x.Status = BundleSaleStatus.Posted,
            setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
            sourceDocType: DocumentTypes.BundleSale,
            getDocId: x => x.BundleSaleId,
            ct: ct);

    private async Task PostAsync<T>(
        Func<Task<T?>> load,
        string notFoundMessage,
        Func<T, object> statusSelector,
        Func<T, string> ensureDraftMessage,
        Func<T, IEnumerable<PostingLine>> getLines,
        Action<T> setPosted,
        Action<T> setUpdated,
        string sourceDocType,
        Func<T, int> getDocId,
        CancellationToken ct,
        /// <summary>Run inside the same transaction, after consumption and status flip, with the
        /// total COGS this call just consumed — the invoice-only revenue-recognition hook.
        /// Null for slip/bundle posting, which stays GL-silent here (Sales Day End handles them).</summary>
        Func<T, decimal, CancellationToken, Task>? afterConsumption = null)
        where T : class
    {
        var doc = await load() ?? throw new NotFoundException(notFoundMessage);
        var status = statusSelector(doc);
        var statusValue = status?.ToString() ?? string.Empty;
        if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
            throw new ConflictException(ensureDraftMessage(doc));

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var totalCogs = 0m;
            foreach (var line in getLines(doc))
            {
                if (line.Qty <= 0) continue;
                if (!await _sales.IsStockedItemAsync(line.ItemId, token))
                    continue;

                // Line quantities are already a count of the item's base UOM — sales documents
                // carry no unit of their own — so this is the quantity FIFO consumes verbatim.
                var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
                var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
                totalCogs += cost * line.Qty;
                await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
                    Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
            }

            setPosted(doc);
            setUpdated(doc);

            if (afterConsumption is not null) await afterConsumption(doc, totalCogs, token);
        }, ct);
    }

    private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
}
