using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
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<Uom> _uoms;
    private readonly IRepository<Warehouse> _warehouses;
    private readonly IRepository<Bin> _bins;
    private readonly IRepository<Vendor> _vendors;
    private readonly IRepository<Batch> _batches;
    private readonly IRepository<UomConversion> _conversions;
    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;

    public GrnService(
        IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
        IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
        IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
        IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
        IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
    {
        _grns = grns;
        _pos = pos;
        _poLines = poLines;
        _items = items;
        _uoms = uoms;
        _warehouses = warehouses;
        _bins = bins;
        _vendors = vendors;
        _batches = batches;
        _conversions = conversions;
        _layers = layers;
        _ledger = ledger;
        _fifo = fifo;
        _numbers = numbers;
        _currentUser = currentUser;
        _uow = uow;
    }

    public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
    {
        var grn = await _grns.Query().AsNoTracking()
            .Include(g => g.Lines)
            .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 (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
                throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} 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: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
            decimal unitCost;
            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);

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

                unitCost = poLine.UnitPrice;
            }
            else
            {
                unitCost = input.UnitCost;
            }

            var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);

            lines.Add(new GrnLine
            {
                PoLineId = input.PoLineId,
                ItemId = input.ItemId,
                UomId = input.UomId,
                BinId = input.BinId,
                Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
                Qty = input.Qty,
                UnitCost = unitCost,
                ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
                HoldStatus = input.HoldStatus
            });
        }

        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))
            {
                var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
                var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token);

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

            grn.Status = GrnStatus.Confirmed;
            grn.PostedAt = now;

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

        return new GrnConfirmResultDto(
            grn.GrnId, grn.Status, now,
            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
    }

    private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
        Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
    {
        if (uomId == item.BaseUomId)
            return (qty, unitCostPerUom);

        var conv = await _conversions.Query().AsNoTracking()
            .FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
            ?? throw new DomainException(ErrorCodes.Validation,
                $"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);

        return (qty * conv.Factor, unitCostPerUom / conv.Factor);
    }

    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,
            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.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
            l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
}
