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

namespace ERPCore.Services;

/// <summary>
/// Goods-receipt service. Create builds a Draft GRN (cost derived from the PO line
/// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced).
/// Confirm posts FIFO layers + inbound ledger and updates PO receipts inside one
/// UoW transaction (NFR-02/05). Quantities are converted to the item's base UOM
/// for the ledger/layers (FR-MD-03).
/// </summary>
public sealed class GrnService : IGrnService
{
    // Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10).
    private const decimal OverReceiptTolerance = 0m;

    private readonly IRepository<Grn> _grns;
    private readonly IRepository<PurchaseOrder> _pos;
    private readonly IRepository<PoLine> _poLines;
    private readonly IRepository<Item> _items;
    private readonly IRepository<Warehouse> _warehouses;
    private readonly IRepository<Bin> _bins;
    private readonly IRepository<Vendor> _vendors;
    private readonly IRepository<Batch> _batches;
    private readonly IRepository<StockLayer> _layers;
    private readonly IRepository<StockLedger> _ledger;
    private readonly IFifoCostingService _fifo;
    private readonly INumberSequenceService _numbers;
    private readonly ICurrentUser _currentUser;
    private readonly IUnitOfWork _uow;
    private readonly IGeneralLedgerService _gl;
    private readonly string _glInventoryAccountCode;
    private readonly string _glVatRecoverableAccountCode;
    private readonly string _glClearingAccountCode;

    public GrnService(
        IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
        IRepository<Item> items, IRepository<Warehouse> warehouses,
        IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
        IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
        IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
        IGeneralLedgerService gl, IConfiguration configuration)
    {
        _grns = grns;
        _pos = pos;
        _poLines = poLines;
        _items = items;
        _warehouses = warehouses;
        _bins = bins;
        _vendors = vendors;
        _batches = batches;
        _layers = layers;
        _ledger = ledger;
        _fifo = fifo;
        _numbers = numbers;
        _currentUser = currentUser;
        _uow = uow;
        _gl = gl;
        _glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
        _glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
        _glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
    }

    public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
        PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
    {
        var q = _grns.Query().AsNoTracking();

        if (!string.IsNullOrWhiteSpace(query.Q))
        {
            var term = query.Q.Trim();
            q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
        }
        if (status is not null) q = q.Where(g => g.Status == status);
        if (poId is not null) q = q.Where(g => g.PoId == poId);
        if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
        if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);

        var total = await q.CountAsync(ct);
        var rows = await q.OrderByDescending(g => g.GrnId)
            .Skip(query.Skip).Take(query.PageSize)
            .Select(g => new GrnSummaryDto(
                g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
                g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
            .ToListAsync(ct);

        return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
    }

    public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
    {
        var grn = await _grns.Query().AsNoTracking()
            .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
            .FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
        return grn is null ? null : Map(grn);
    }

    public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
    {
        if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
            throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);

        // Resolve vendor + PO context.
        PurchaseOrder? po = null;
        int vendorId;
        if (request.PoId is not null)
        {
            po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
                .FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
                ?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
            if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
                throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
            vendorId = po.VendorId;
        }
        else
        {
            if (request.VendorId is null)
                throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
            if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
                throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
            vendorId = request.VendorId.Value;
        }

        var lines = new List<GrnLine>();
        var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();

        foreach (var input in request.Lines)
        {
            var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
                ?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
            if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
                throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);

            // Cost: for a PO line, the PO price is used unless an override is entered (then it
            // wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
            // revised). Direct receipts always use the entered cost.
            decimal unitCost;
            decimal? poUnitPrice = null;
            if (input.PoLineId is not null)
            {
                var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
                    ?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
                if (poLine.ItemId != input.ItemId)
                    throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);

                // Both sides are counts of the item's base UOM — the GRN line no longer carries
                // a unit of its own — so this comparison and the accrual below are like-for-like.
                var openQty = poLine.Qty - poLine.QtyReceived;
                if (input.Qty > openQty * (1 + OverReceiptTolerance))
                    throw new DomainException(ErrorCodes.OverReceiptTolerance,
                        $"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);

                poUnitPrice = poLine.UnitPrice;
                unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
            }
            else
            {
                unitCost = input.UnitCost;
            }

            // Derived figures are always computed server-side, never accepted from the client.
            var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
            var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
            var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);

            var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
            var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);

            lines.Add(new GrnLine
            {
                PoLineId = input.PoLineId,
                ItemId = input.ItemId,
                BinId = input.BinId,
                Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
                Qty = input.Qty,
                UnitCost = unitCost,
                PoUnitPrice = poUnitPrice,
                DiscountPct = input.DiscountPct,
                NetUnitCost = netUnitCost,
                VatPct = input.VatPct,
                VatAmount = vatAmount,
                ReceivedValue = receivedValue,
                LineTotal = receivedValue + vatAmount,
                HoldStatus = input.HoldStatus,
                WarrantyNumbers = warrantyNumbers
            });
        }

        var grn = await _uow.ExecuteInTransactionAsync(async token =>
        {
            var docNo = await _numbers.NextAsync(DocumentTypes.Grn, token);
            var entity = new Grn
            {
                DocNo = docNo,
                PoId = request.PoId,
                VendorId = vendorId,
                WarehouseId = request.WarehouseId,
                Status = GrnStatus.Draft,
                CreatedBy = _currentUser.AuditUserId,
                CreatedAt = DateTime.UtcNow,
                Lines = lines
            };
            await _grns.AddAsync(entity, token);
            return entity;
        }, ct);

        return Map(grn);
    }

    public async Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default)
    {
        var grn = await _grns.Query()
            .Include(g => g.Lines)
            .FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
            ?? throw new NotFoundException($"GRN {grnId} was not found.");

        // Idempotent replay: an already-confirmed GRN returns its existing result.
        if (grn.Status == GrnStatus.Confirmed)
            return await BuildConfirmResultAsync(grn, ct);
        if (grn.Status == GrnStatus.Closed)
            throw new ConflictException($"GRN {grnId} is closed.");

        var actor = _currentUser.AuditUserId;
        var now = DateTime.UtcNow;
        var runningBalance = new Dictionary<(int, int), decimal>();
        var createdLayers = new List<StockLayer>();
        var ledgerRefs = new List<StockLedger>();

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
            {
                // FIFO layer costs at the after-discount net price; VAT is recoverable and never
                // enters stock value (docs/10 FR-GRN-06, revised). The line quantity is already
                // a count of the item's base UOM, so it layers exactly as entered.
                var qtyBase = line.Qty;
                var unitCostBase = line.NetUnitCost;

                var layer = await _fifo.CreateInboundLayerAsync(
                    line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
                    qtyBase, unitCostBase, now, token);
                createdLayers.Add(layer);

                var key = (line.ItemId, grn.WarehouseId);
                if (!runningBalance.TryGetValue(key, out var bal))
                    bal = await _fifo.GetOnHandAsync(line.ItemId, grn.WarehouseId, token);
                bal += qtyBase;
                runningBalance[key] = bal;

                var entry = await _fifo.PostLedgerAsync(
                    line.ItemId, grn.WarehouseId, line.BinId, line.BatchId, null, actor,
                    Direction.In, qtyBase, unitCostBase, bal, DocumentTypes.Grn, grn.GrnId, now, token);
                ledgerRefs.Add(entry);

                if (line.PoLineId is not null)
                {
                    var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
                    if (poLine is not null) poLine.QtyReceived += line.Qty;
                }
            }

            // Post the real GL journal entry for this receipt before committing — if GL
            // rejects it or is unreachable, the exception propagates out of this callback
            // and the whole transaction (FIFO layers, stock ledger, PO accrual) rolls back
            // with it, so inventory and the ledger never diverge (user-approved "fails
            // atomically" behavior; residual risk if GL posts but the local commit still
            // fails afterward is accepted for this phase, see the integration plan).
            var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
            var glLines = new List<GlJournalEntryLineRequest>();
            decimal totalPayable = 0m;
            foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
            {
                glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
                if (line.VatAmount > 0)
                    glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
                totalPayable += line.LineTotal;
            }
            glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));

            var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
            {
                PeriodId = period.PeriodId,
                EntryDate = DateOnly.FromDateTime(now),
                SourceModule = "GRN",
                Reference = grn.DocNo,
                Narration = $"Goods received - GRN {grn.DocNo}",
                Lines = glLines
            }, token);

            grn.Status = GrnStatus.Confirmed;
            grn.PostedAt = now;
            grn.GlJournalNo = posted.JournalNo;
            grn.GlPostedAt = now;
            grn.PaidAmount = 0m;
            grn.BalanceAmount = totalPayable;

            await UpdatePoStatusAsync(grn.PoId, token);
            return 0;
        }, ct);

        return new GrnConfirmResultDto(
            grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
            createdLayers.Select(ToCreatedLayer).ToList(),
            ledgerRefs.Select(l => l.LedgerId).ToList(),
            await GetPoStatusAsync(grn.PoId, ct));
    }

    public async Task<ReleaseLineResultDto> ReleaseLineAsync(int grnId, int grnLineId, string action, CancellationToken ct = default)
    {
        var grn = await _grns.Query()
            .Include(g => g.Lines)
            .FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
            ?? throw new NotFoundException($"GRN {grnId} was not found.");

        var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId)
            ?? throw new NotFoundException($"GRN line {grnLineId} was not found on GRN {grnId}.");

        if (grn.Status != GrnStatus.Confirmed)
            throw new ConflictException($"GRN {grnId} must be confirmed before releasing holds.");
        if (line.HoldStatus != HoldStatus.OnHold)
            throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not OnHold.");

        if (string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase))
        {
            line.HoldStatus = HoldStatus.Available;
            await _uow.SaveChangesAsync(ct);
            return new ReleaseLineResultDto(grnLineId, HoldStatus.Available);
        }

        // Reject: remove the held stock from on-hand and post a reversing ledger entry.
        // Linking rejected stock to a formal purchase return is deferred (§3.4).
        var actor = _currentUser.AuditUserId;
        var now = DateTime.UtcNow;
        await _uow.ExecuteInTransactionAsync(async token =>
        {
            line.HoldStatus = HoldStatus.Rejected;
            var layers = await _layers.Query()
                .Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0).ToListAsync(token);

            foreach (var layer in layers)
            {
                var bal = await _fifo.GetOnHandAsync(layer.ItemId, layer.WarehouseId, token) - layer.QtyRemaining;
                await _fifo.PostLedgerAsync(
                    layer.ItemId, layer.WarehouseId, line.BinId, layer.BatchId, null, actor,
                    Direction.Out, layer.QtyRemaining, layer.UnitCost, bal, DocumentTypes.Grn, grn.GrnId, now, token);
                layer.QtyRemaining = 0;
            }
            return 0;
        }, ct);

        return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
    }

    private async Task<Batch?> ResolveBatchAsync(
        Item item, BatchInput? batch, Dictionary<(int, string), Batch> cache, CancellationToken ct)
    {
        if (item.TrackingMode == TrackingMode.Batch && batch is null)
            throw new DomainException(ErrorCodes.Validation, $"Item {item.ItemId} is batch-tracked; a batch is required.", 422);
        if (batch is null) return null;

        var key = (item.ItemId, batch.BatchNo.Trim());
        if (cache.TryGetValue(key, out var cached)) return cached;

        var existing = await _batches.Query().FirstOrDefaultAsync(b => b.ItemId == item.ItemId && b.BatchNo == key.Item2, ct);
        if (existing is not null)
        {
            cache[key] = existing;
            return existing;
        }

        var created = new Batch { ItemId = item.ItemId, BatchNo = key.Item2, ExpiryDate = batch.ExpiryDate };
        await _batches.AddAsync(created, ct);
        cache[key] = created;
        return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
    }

    /// <summary>
    /// A warranty-tracked item requires exactly one warranty number per received unit —
    /// same shape of rule as <see cref="ResolveBatchAsync"/> for batch tracking. The coverage
    /// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
    /// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
    /// </summary>
    private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty)
    {
        if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
        if (item.WarrantyPeriodMonths is null)
            throw new DomainException(
                ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);

        var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
        if (trimmed.Count != qty)
            throw new DomainException(
                ErrorCodes.Validation,
                $"Item {item.Sku} is under warranty; provide exactly {qty} warranty number(s), got {trimmed.Count}.", 422);
        if (trimmed.Distinct(StringComparer.OrdinalIgnoreCase).Count() != trimmed.Count)
            throw new DomainException(ErrorCodes.Validation, $"Warranty numbers for item {item.Sku} must be unique.", 422);

        return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
    }

    private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
    {
        if (poId is null) return;
        var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
        if (po is null) return;

        po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
            ? PurchaseOrderStatus.FullyReceived
            : PurchaseOrderStatus.PartiallyReceived;
        po.UpdatedAt = DateTime.UtcNow;
    }

    private async Task<PurchaseOrderStatus?> GetPoStatusAsync(int? poId, CancellationToken ct)
    {
        if (poId is null) return null;
        return await _pos.Query().AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct);
    }

    private async Task<GrnConfirmResultDto> BuildConfirmResultAsync(Grn grn, CancellationToken ct)
    {
        var lineIds = grn.Lines.Select(l => l.GrnLineId).ToList();
        var layers = await _layers.Query().AsNoTracking()
            .Where(l => l.GrnLineId != null && lineIds.Contains(l.GrnLineId.Value)).ToListAsync(ct);
        var ledgerRefs = await _ledger.Query().AsNoTracking()
            .Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grn.GrnId && l.Direction == Direction.In)
            .Select(l => l.LedgerId).ToListAsync(ct);

        return new GrnConfirmResultDto(
            grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
            layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
    }

    private static CreatedLayerDto ToCreatedLayer(StockLayer l) => new(
        l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);

    private static GrnDto Map(Grn g) => new(
        g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
        g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
        g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
            l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
            l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
            l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
            l.HoldStatus, l.BatchId,
            l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList());
}
