using System.Text.Json;
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
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.Production;

/// <summary>
/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19).
///
/// Deliberately posts no GL journal entry anywhere in this file, for the same reason
/// as <see cref="TransferService"/>: every movement here — raw material consumed
/// (<see cref="StartStageAsync"/>), finished goods received (<see cref="PostReceiptAsync"/>),
/// leftovers/cancellation returned to stock — stays inside the single shared Inventory
/// GL account (raw materials, WIP, and finished goods are not separate GL accounts in
/// this chart of accounts). And unlike a real write-off, scrap here is never removed
/// from the cost pool — <see cref="PostReceiptAsync"/> divides the full consumed value
/// by the GOOD quantity only, so a scrapped unit's cost is absorbed into the surviving
/// units' cost rather than expensed. So there is no value entering, leaving, or being
/// destroyed anywhere in a run — nothing for a journal entry to say.
/// </summary>
public sealed class ProductionRunService : IProductionRunService
{
    private readonly IRepository<ProductionRun> _runs;
    private readonly IRepository<ProductionTemplate> _templates;
    private readonly IRepository<Warehouse> _warehouses;
    private readonly IRepository<Bin> _bins;
    private readonly IRepository<Item> _items;
    private readonly IRepository<ReasonCode> _reasonCodes;
    private readonly IFifoCostingService _fifo;
    private readonly IItemMeasure _measure;
    private readonly INumberSequenceService _numbers;
    private readonly IUnitOfWork _uow;
    private readonly ICurrentUser _currentUser;

    public ProductionRunService(
        IRepository<ProductionRun> runs, IRepository<ProductionTemplate> templates,
        IRepository<Warehouse> warehouses, IRepository<Bin> bins,
        IRepository<Item> items, IRepository<ReasonCode> reasonCodes,
        IFifoCostingService fifo, IItemMeasure measure,
        INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser)
    {
        _runs = runs;
        _templates = templates;
        _warehouses = warehouses;
        _bins = bins;
        _items = items;
        _reasonCodes = reasonCodes;
        _fifo = fifo;
        _measure = measure;
        _numbers = numbers;
        _uow = uow;
        _currentUser = currentUser;
    }

    // --- reads ---------------------------------------------------------------

    public async Task<PagedResponse<RunSummaryDto>> ListAsync(
        PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId,
        CancellationToken ct = default)
    {
        var q = _runs.Query().AsNoTracking();

        if (!string.IsNullOrWhiteSpace(query.Q))
        {
            var term = query.Q.Trim();
            q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
        }

        if (status is not null) q = q.Where(r => r.Status == status);
        if (templateId is not null) q = q.Where(r => r.TemplateId == templateId);
        if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);

        var total = await q.CountAsync(ct);

        // Projected to an anonymous type first, then mapped client-side. EF Core 10 cannot
        // translate a record constructor call that sits inside a projection alongside
        // aggregates — the same failure mode already recorded for WarehouseValuationDto in
        // Backend/PROGRESS.md (2026-07-28).
        var rows = await q
            .OrderByDescending(r => r.RunId) // newest first (docs/30 §D.2)
            .Skip(query.Skip).Take(query.PageSize)
            .Select(r => new
            {
                r.RunId,
                r.DocNo,
                r.TemplateId,
                TemplateName = r.Template!.Name,
                r.TargetQty,
                r.Status,
                r.ReworkCount,
                r.WarehouseId,
                Waiting = r.Stages.Count(s => s.Status == ProductionStageStatus.Waiting),
                Ready = r.Stages.Count(s => s.Status == ProductionStageStatus.Ready),
                InProgress = r.Stages.Count(s => s.Status == ProductionStageStatus.InProgress),
                Done = r.Stages.Count(s => s.Status == ProductionStageStatus.Done),
                Approved = r.Stages.Count(s => s.Status == ProductionStageStatus.Approved),
                // Only the terminal output carries an item, so this is unambiguous.
                Finished = r.Stages.SelectMany(s => s.Outputs)
                    .Where(o => o.ItemId != null)
                    .Select(o => new { o.ItemId, ItemName = o.Item!.Name })
                    .FirstOrDefault(),
                r.CreatedBy,
                r.CreatedAt,
                r.CompletedAt
            })
            .ToListAsync(ct);

        var items = rows.Select(r => new RunSummaryDto(
            r.RunId, r.DocNo, r.TemplateId, r.TemplateName, r.TargetQty, r.Status, r.ReworkCount,
            new StageSummaryDto(r.Waiting, r.Ready, r.InProgress, r.Done, r.Approved),
            r.WarehouseId, r.Finished?.ItemId, r.Finished?.ItemName,
            r.CreatedBy, r.CreatedAt, r.CompletedAt)).ToList();

        return PagedResponse<RunSummaryDto>.Create(items, query.Page, query.PageSize, total);
    }

    public async Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default)
    {
        var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
        return run is null ? null : new ETagged<RunGraphDto>(ToGraphDto(run), run.RowVersion);
    }

    // --- creation ------------------------------------------------------------

    public async Task<ETagged<RunGraphDto>> CreateAsync(CreateRunRequest request, CancellationToken ct = default)
    {
        var template = await _templates.Query().AsNoTracking()
            .Include(t => t.Stages).ThenInclude(s => s.Inputs)
            .Include(t => t.Stages).ThenInclude(s => s.Outputs)
            .Include(t => t.Edges)
            .FirstOrDefaultAsync(t => t.TemplateId == request.TemplateId, ct)
            ?? throw new NotFoundException($"Production template {request.TemplateId} was not found.");

        if (template.Status != EntityStatus.Active)
            throw new DomainException(ErrorCodes.TemplateInactive,
                $"Template '{template.Code}' is inactive and cannot start new runs.", 422);

        if (!await _warehouses.Query().AsNoTracking().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
            throw new DomainException(ErrorCodes.Validation,
                $"Warehouse {request.WarehouseId} does not exist.", 422);

        if (request.OutputBinId is not null)
        {
            var binWarehouse = await _bins.Query().AsNoTracking()
                .Where(b => b.BinId == request.OutputBinId)
                .Select(b => (int?)b.WarehouseId).FirstOrDefaultAsync(ct);

            if (binWarehouse is null)
                throw new DomainException(ErrorCodes.Validation, $"Bin {request.OutputBinId} does not exist.", 422);
            if (binWarehouse != request.WarehouseId)
                throw new DomainException(ErrorCodes.Validation,
                    $"Bin {request.OutputBinId} belongs to warehouse {binWarehouse}, not {request.WarehouseId}.", 422);
        }

        // Re-derive the terminal defensively. The graph was validated when the template was
        // saved, but a run is a long-lived copy and starting from a malformed graph would
        // produce a run that can never complete.
        var hasOutbound = template.Edges.Select(e => e.ParentStageId).ToHashSet();
        var hasInbound = template.Edges.Select(e => e.ChildStageId).ToHashSet();
        var terminals = template.Stages.Where(s => !hasOutbound.Contains(s.StageId)).ToList();

        if (terminals.Count != 1)
            throw new DomainException(ErrorCodes.GraphTerminalCount,
                $"Template '{template.Code}' does not have exactly one final stage and cannot be run.", 422);

        var terminalOutput = terminals[0].Outputs.SingleOrDefault()
            ?? throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
                $"The final stage of template '{template.Code}' must have exactly one output.", 422);

        if (terminalOutput.ItemId is null)
            throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
                $"The final stage of template '{template.Code}' must produce a real item.", 422);

        if (terminalOutput.QtyPerBatch <= 0)
            throw new DomainException(ErrorCodes.Validation,
                "The final stage's output quantity per batch must be greater than zero.", 422);

        // Scale from the UNROUNDED ratio and round each quantity once, so a repeating
        // scale factor (e.g. 50/3) does not compound its error across every stage. The
        // stored ScaleFactor is the same ratio rounded to the column's 6 dp, for display.
        var ratio = request.TargetQty / terminalOutput.QtyPerBatch;

        var run = new ProductionRun
        {
            TemplateId = template.TemplateId,
            WarehouseId = request.WarehouseId,
            OutputBinId = request.OutputBinId,
            TargetQty = request.TargetQty,
            ScaleFactor = Math.Round(ratio, 6, MidpointRounding.AwayFromZero),
            Status = ProductionRunStatus.InProgress,
            ReworkCount = 0,
            CreatedBy = _currentUser.AuditUserId,
            CreatedAt = DateTime.UtcNow
        };

        // Wired through navigation properties so EF resolves every generated key itself on a
        // single SaveChanges — no intermediate saves to materialise ids.
        var stageByTemplateStageId = new Dictionary<int, RunStage>();
        var outputByTemplateOutputId = new Dictionary<int, RunStageOutput>();

        // Pass 1 — stages and outputs.
        foreach (var ts in template.Stages.OrderBy(s => s.StageId))
        {
            var isEntry = !hasInbound.Contains(ts.StageId);
            var rs = new RunStage
            {
                Run = run,
                TemplateStageId = ts.StageId,
                Name = ts.Name,
                RoleLabel = ts.RoleLabel,
                EstimatedMinutes = ts.EstimatedMinutes,
                PosX = ts.PosX,
                PosY = ts.PosY,
                // FR-MFG-09: entry stages have nothing to wait for.
                Status = isEntry ? ProductionStageStatus.Ready : ProductionStageStatus.Waiting,
                FieldDefs = ts.FieldDefs,
                FieldValues = null
            };
            run.Stages.Add(rs);
            stageByTemplateStageId[ts.StageId] = rs;

            foreach (var o in ts.Outputs.OrderBy(o => o.OutputId))
            {
                var ro = new RunStageOutput
                {
                    RunStage = rs,
                    ItemId = o.ItemId,
                    Name = o.Name,
                    UomId = o.UomId,
                    PlannedQty = Scale(o.QtyPerBatch, ratio)
                };
                rs.Outputs.Add(ro);
                outputByTemplateOutputId[o.OutputId] = ro;
            }
        }

        // Pass 2 — inputs, which may point at any output built above.
        foreach (var ts in template.Stages.OrderBy(s => s.StageId))
        {
            var rs = stageByTemplateStageId[ts.StageId];
            foreach (var i in ts.Inputs.OrderBy(i => i.InputId))
            {
                rs.Inputs.Add(new RunStageInput
                {
                    RunStage = rs,
                    Source = i.Source,
                    ItemId = i.ItemId,
                    FromRunOutput = i.FromOutputId is null ? null : outputByTemplateOutputId[i.FromOutputId.Value],
                    QtyUnit = i.QtyUnit,
                    PlannedQty = Scale(i.QtyPerBatch, ratio)
                });
            }
        }

        // Pass 3 — edges, copied so a later template edit can never rewrite this run's shape.
        foreach (var e in template.Edges)
        {
            run.Edges.Add(new RunEdge
            {
                Run = run,
                ParentRunStage = stageByTemplateStageId[e.ParentStageId],
                ChildRunStage = stageByTemplateStageId[e.ChildStageId]
            });
        }

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            // Must be inside the transaction — NumberSequenceService enlists in it.
            run.DocNo = await _numbers.NextAsync(DocumentTypes.Production, token);
            await _runs.AddAsync(run, token);
        }, ct);

        return await GetAsync(run.RunId, ct)
               ?? throw new NotFoundException($"Production run {run.RunId} was not found after creation.");
    }

    private static decimal Scale(decimal qtyPerBatch, decimal ratio)
        => Math.Round(qtyPerBatch * ratio, 4, MidpointRounding.AwayFromZero);

    // --- quantity override ---------------------------------------------------

    public async Task<RunStageDto> UpdateStageQuantitiesAsync(
        int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default)
    {
        RunStage? stage = null;

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token)
                ?? throw new NotFoundException($"Production run {runId} was not found.");

            if (run.Status != ProductionRunStatus.InProgress)
                throw new ConflictException($"Run {run.DocNo} is {run.Status}; quantities can only be edited while it is in progress.");

            stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
                ?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");

            // FR-MFG-08: editable until the stage starts. Once started, consumption has
            // already happened against the planned figures.
            if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
                throw new DomainException(ErrorCodes.StageNotEditable,
                    $"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);

            foreach (var line in request.Inputs)
            {
                var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
                    ?? throw new DomainException(ErrorCodes.Validation,
                        $"Input {line.Id} is not on stage '{stage.Name}'.", 422);
                input.PlannedQty = line.PlannedQty;
            }

            foreach (var line in request.Outputs)
            {
                var output = stage.Outputs.FirstOrDefault(o => o.RunOutputId == line.Id)
                    ?? throw new DomainException(ErrorCodes.Validation,
                        $"Output {line.Id} is not on stage '{stage.Name}'.", 422);
                output.PlannedQty = line.PlannedQty;
            }

            // Raising an upstream input's planned quantity means the deliveries that made
            // this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
            // does not state this; it is the only behaviour consistent with FR-MFG-09.
            RecomputeReadiness(stage);

            AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
            {
                inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
                outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
            });
        }, ct);

        var refreshed = await GetAsync(runId, ct)
                        ?? throw new NotFoundException($"Production run {runId} was not found.");
        return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
    }

    // --- stage actions -------------------------------------------------------

    public async Task<StartStageResultDto> StartStageAsync(
        int runId, int runStageId, CancellationToken ct = default)
    {
        var consumed = new List<ConsumedInputDto>();
        var ledgerRefs = new List<StockLedger>();

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var (run, stage) = await LoadForActionAsync(runId, runStageId, token);

            if (stage.Status != ProductionStageStatus.Ready)
                throw new DomainException(ErrorCodes.StageNotReady,
                    $"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);

            var now = DateTime.UtcNow;
            var actor = _currentUser.AuditUserId;

            // Running balances accumulated per item across the loop: layers written in this
            // transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
            // the same item would otherwise both post the same RunningBalance. Same pattern
            // as TransferService.DispatchAsync.
            var balances = new Dictionary<int, decimal>();

            foreach (var input in stage.Inputs
                         .Where(i => i.Source == StageInputSource.Stock)
                         .OrderBy(i => i.RunInputId))
            {
                var item = await _items.Query().AsNoTracking()
                    .FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
                    ?? throw new DomainException(ErrorCodes.Validation,
                        $"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);

                // PlannedQty is in the input's declared unit — content (ml/g) for an item with
                // a content size, otherwise packs. ConsumedQty is always packs, the only unit
                // FIFO and the ledger speak, so resolve before comparing. 2000 ml of a 500 ml
                // bottle is 4 bottles; 300 ml is 0.6 of one, which (18,4) stores exactly.
                var plannedBase = _measure.ToPacks(item, input.PlannedQty, input.QtyUnit);
                var delta = plannedBase - input.ConsumedQty;
                if (delta <= 0) continue; // rework restart with no increase — nothing to draw

                var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
                var value = segments.Sum(s => s.Qty * s.UnitCost);
                var unitCost = delta == 0 ? 0m : value / delta;

                if (!balances.TryGetValue(item.ItemId, out var balance))
                    balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
                balance -= delta;
                balances[item.ItemId] = balance;

                var entry = await _fifo.PostLedgerAsync(
                    item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
                    Direction.Out, delta, unitCost, balance,
                    LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
                ledgerRefs.Add(entry);

                input.ConsumedQty += delta;
                input.ConsumedValue += value;

                consumed.Add(new ConsumedInputDto(
                    input.RunInputId, item.ItemId, delta, value,
                    segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
            }

            stage.Status = ProductionStageStatus.InProgress;
            stage.ActualStartAt = now;
            stage.ActualEndAt = null;

            AddEvent(run, stage, RunStageEventType.Start, null, new
            {
                consumed = consumed.Select(c => new
                {
                    c.RunInputId, c.ItemId, c.Qty, c.Value,
                    layers = c.ConsumedLayers.Select(l => new { l.LayerId, l.Qty, l.UnitCost })
                })
            });
        }, ct);

        var stageDto = await RequireStageAsync(runId, runStageId, ct);
        return new StartStageResultDto(
            runStageId, stageDto.Status, stageDto.ActualStartAt,
            consumed, ledgerRefs.Select(l => l.LedgerId).ToList(), stageDto);
    }

    public async Task<RunStageDto> CompleteStageAsync(
        int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default)
    {
        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var (run, stage) = await LoadForActionAsync(runId, runStageId, token);

            if (stage.Status != ProductionStageStatus.InProgress)
                throw new DomainException(ErrorCodes.StageNotInProgress,
                    $"Stage '{stage.Name}' is {stage.Status}; only an in-progress stage can be completed.", 409);

            ValidateRequiredFields(stage, request.FieldValues);

            foreach (var line in request.Outputs)
            {
                var output = stage.Outputs.FirstOrDefault(o => o.RunOutputId == line.RunOutputId)
                    ?? throw new DomainException(ErrorCodes.Validation,
                        $"Output {line.RunOutputId} is not on stage '{stage.Name}'.", 422);

                if (line.ScrappedQty > 0)
                {
                    if (line.ScrapReasonCodeId is null)
                        throw new DomainException(ErrorCodes.ReasonCodeRequired,
                            $"A scrap reason code is required for output '{output.Name}'.", 400);
                    await EnsureProductionReasonAsync(line.ScrapReasonCodeId.Value, token);
                }

                var good = line.ProducedQty - line.ScrappedQty;
                if (good < 0)
                    throw new DomainException(ErrorCodes.Validation,
                        $"Scrapped {line.ScrappedQty} exceeds produced {line.ProducedQty} on output '{output.Name}'.", 422);

                // A re-complete after a rework OVERWRITES rather than accumulates — adding
                // would double the produced quantity on every rework pass. Not stated in
                // docs/30; recorded as a decision.
                if (good < output.TransferredQty)
                    throw new DomainException(ErrorCodes.TransferExceedsAvailable,
                        $"Output '{output.Name}' has already transferred {output.TransferredQty}; "
                        + $"the new good quantity {good} cannot be lower.", 422);

                output.ProducedQty = line.ProducedQty;
                output.ScrappedQty = line.ScrappedQty;
                output.ScrapReasonCodeId = line.ScrappedQty > 0 ? line.ScrapReasonCodeId : null;
            }

            stage.Status = ProductionStageStatus.Done;
            stage.ActualEndAt = DateTime.UtcNow;
            stage.FieldValues = request.FieldValues is null
                ? null
                : ProductionJson.Serialize(request.FieldValues);

            AddEvent(run, stage, RunStageEventType.Complete, null, new
            {
                outputs = request.Outputs.Select(o => new
                {
                    o.RunOutputId, o.ProducedQty, o.ScrappedQty, o.ScrapReasonCodeId
                }),
                fieldValues = request.FieldValues
            });
        }, ct);

        return await RequireStageAsync(runId, runStageId, ct);
    }

    public async Task<ApproveStageResultDto> ApproveStageAsync(
        int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default)
    {
        var transfers = new List<TransferDto>();
        var ledgerRefs = new List<StockLedger>();
        Receipt? pending = null;
        CostPoolDto? costPool = null;
        var runStatus = ProductionRunStatus.InProgress;

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var (run, stage) = await LoadForActionAsync(runId, runStageId, token);

            if (stage.Status != ProductionStageStatus.Done)
                throw new DomainException(ErrorCodes.StageNotDone,
                    $"Stage '{stage.Name}' is {stage.Status}; only a completed stage can be approved.", 409);

            var now = DateTime.UtcNow;

            if (IsTerminal(run, stage))
            {
                (pending, costPool, var entry) = await PostReceiptAsync(run, stage, now, token);
                ledgerRefs.Add(entry);

                stage.Status = ProductionStageStatus.Approved;
                run.Status = ProductionRunStatus.Completed;
                run.CompletedAt = now;
                runStatus = run.Status;

                // Layer/ledger ids are still 0 here — they are database-generated and only
                // populated once this transaction's SaveChanges runs. The event payload and
                // the response DTO are therefore both built after the commit, below.
                AddEvent(run, stage, RunStageEventType.Approve, null, new
                {
                    itemId = pending.ItemId,
                    qtyReceived = pending.QtyBase,
                    unitCost = pending.UnitCost,
                    value = pending.LedgerValue,
                    costPool
                });
                return;
            }

            foreach (var output in stage.Outputs.OrderBy(o => o.RunOutputId))
            {
                var available = output.ProducedQty - output.ScrappedQty - output.TransferredQty;
                var line = request.Transfers.FirstOrDefault(t => t.RunOutputId == output.RunOutputId);

                // Default is "transfer everything available" (FR-MFG-12); an explicit line
                // makes it a partial and leaves the remainder on the stage.
                var qty = line?.Qty ?? available;
                if (qty <= 0) continue;

                transfers.AddRange(Deliver(run, output, qty, line?.RunInputId, available));
            }

            stage.Status = ProductionStageStatus.Approved;
            AddEvent(run, stage, RunStageEventType.Approve, null, new { transfers });
        }, ct);

        var stageDto = await RequireStageAsync(runId, runStageId, ct);

        // Mapped after the commit so the generated layer id is populated (mirrors the
        // "map ledger ids after commit" fix recorded for Count/Return in Backend/PROGRESS.md).
        var receipt = pending is null
            ? null
            : new ReceiptDto(
                pending.Layer.LayerId, pending.ItemId, pending.WarehouseId, pending.BinId,
                pending.QtyBase, pending.UnitCost, pending.LedgerValue);

        return new ApproveStageResultDto(
            runStageId, stageDto.Status, runStatus, transfers,
            receipt, costPool, ledgerRefs.Select(l => l.LedgerId).ToList(), stageDto);
    }

    /// <summary>
    /// A posted receipt whose layer id is not yet known. Held across the transaction
    /// boundary so <see cref="ReceiptDto"/> can be built once EF has assigned the id.
    /// </summary>
    private sealed record Receipt(
        StockLayer Layer, int ItemId, int WarehouseId, int? BinId,
        decimal QtyBase, decimal UnitCost, decimal LedgerValue);

    public async Task<TransferResultDto> TransferAsync(
        int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default)
    {
        var transfers = new List<TransferDto>();

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var (run, stage) = await LoadForActionAsync(runId, runStageId, token);

            if (stage.Status != ProductionStageStatus.Approved)
                throw new ConflictException(
                    $"Stage '{stage.Name}' is {stage.Status}; a remainder can only be transferred from an approved stage.");

            var output = stage.Outputs.FirstOrDefault(o => o.RunOutputId == request.RunOutputId)
                ?? throw new DomainException(ErrorCodes.Validation,
                    $"Output {request.RunOutputId} is not on stage '{stage.Name}'.", 422);

            var available = output.ProducedQty - output.ScrappedQty - output.TransferredQty;
            transfers.AddRange(Deliver(run, output, request.Qty, request.RunInputId, available));

            AddEvent(run, stage, RunStageEventType.Transfer, null, new { transfers });
        }, ct);

        return new TransferResultDto(runStageId, transfers, await RequireStageAsync(runId, runStageId, ct));
    }

    public async Task<ReturnLeftoverResultDto> ReturnLeftoverAsync(
        int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default)
    {
        StockLayer? layer = null;
        StockLedger? entry = null;
        decimal unitCost = 0, returnValue = 0, returnedQty = 0;
        CostPoolDto pool = new(0, 0, 0);

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token)
                ?? throw new NotFoundException($"Production run {runId} was not found.");

            // The cost pool is closed by the terminal receipt — returning afterwards would
            // change a figure the finished layer has already been costed from (FR-MFG-13).
            if (run.Status == ProductionRunStatus.Completed)
                throw new DomainException(ErrorCodes.RunCostClosed,
                    $"Run {run.DocNo} is complete; its cost pool is closed to further returns.", 409);
            if (run.Status != ProductionRunStatus.InProgress)
                throw new ConflictException($"Run {run.DocNo} is {run.Status}; leftovers cannot be returned.");

            var input = run.Stages.SelectMany(s => s.Inputs)
                            .FirstOrDefault(i => i.RunInputId == runInputId)
                        ?? throw new NotFoundException($"Input {runInputId} is not part of run {runId}.");

            if (input.Source != StageInputSource.Stock || input.ItemId is null)
                throw new DomainException(ErrorCodes.Validation,
                    "Only a Stock input can be returned — upstream work-in-progress never entered stock.", 422);

            if (input.ConsumedQty <= 0)
                throw new DomainException(ErrorCodes.Validation,
                    "Nothing has been consumed on this input yet, so there is nothing to return.", 422);

            if (request.ReasonCodeId is null)
                throw new DomainException(ErrorCodes.ReasonCodeRequired,
                    "A reason code is required to return leftover material.", 400);
            await EnsureProductionReasonAsync(request.ReasonCodeId.Value, token);

            var remaining = input.ConsumedQty - input.ReturnedQty;
            if (request.Qty > remaining)
                throw new DomainException(ErrorCodes.LeftoverExceedsConsumed,
                    $"Cannot return {request.Qty}: only {remaining} remains "
                    + $"(consumed {input.ConsumedQty} - already returned {input.ReturnedQty}).", 422);

            var now = DateTime.UtcNow;
            var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);

            // Weighted cost actually consumed for THIS input — cost-preserving by
            // construction, so what leaves the pool is exactly what entered it.
            var weighted = input.ConsumedValue / input.ConsumedQty;
            unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
            returnedQty = request.Qty;

            // Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
            // then multiplying lets successive partial returns accumulate drift until the
            // last one can exceed ConsumedValue.
            returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
                // Full return: take the exact residual so the input's contribution to the
                // pool lands on precisely zero rather than a rounding crumb.
                ? input.ConsumedValue - input.ReturnedValue
                : Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);

            layer = await _fifo.CreateInboundLayerAsync(
                input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
                request.Qty, unitCost, now, token);

            var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;

            // binId stays null: this is raw material going back to general stock, not into the
            // run's finished-goods output bin.
            entry = await _fifo.PostLedgerAsync(
                input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
                _currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
                LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);

            input.ReturnedQty += request.Qty;
            input.ReturnedValue += returnValue;

            var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
            var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
            pool = new CostPoolDto(consumedTotal, returnedTotal, consumedTotal - returnedTotal);

            // RUN_STAGE_INPUT has no reason-code column (docs/30 Part C), so the reason lives
            // in the event payload. Adequate as an audit trail; a per-input reportable reason
            // would need its own line table.
            AddEvent(run, stage, RunStageEventType.LeftoverReturn, null, new
            {
                runInputId,
                qty = request.Qty,
                unitCost,
                value = returnValue,
                reasonCodeId = request.ReasonCodeId
            });
        }, ct);

        return new ReturnLeftoverResultDto(
            runInputId, returnedQty, returnValue,
            new ProductionCreatedLayerDto(layer!.LayerId, unitCost),
            [entry!.LedgerId], pool);
    }

    public async Task<RejectIntakeResultDto> RejectIntakeAsync(
        int runId, int runStageId, RejectRequest request, CancellationToken ct = default)
    {
        var pulled = new List<PulledBackDto>();

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var (run, stage) = await LoadForActionAsync(runId, runStageId, token);

            // docs/30 §D.3 says Ready only; docs/21 §5 offers the action on "a Ready/Waiting
            // stage with deliveries". Allowing both is the reconciliation: a partially
            // delivered Waiting stage is a real case, and the delivered intake — not the
            // status — is what makes the reject meaningful.
            if (stage.Status is not (ProductionStageStatus.Ready or ProductionStageStatus.Waiting))
                throw new DomainException(ErrorCodes.StageRejectInvalid,
                    $"Stage '{stage.Name}' is {stage.Status}; intake can only be rejected before the stage starts.", 409);

            var delivered = stage.Inputs
                .Where(i => i.Source == StageInputSource.Upstream && i.DeliveredQty > 0)
                .OrderBy(i => i.RunInputId)
                .ToList();

            if (delivered.Count == 0)
                throw new DomainException(ErrorCodes.StageRejectInvalid,
                    $"Stage '{stage.Name}' has received no work, so there is nothing to reject.", 409);

            foreach (var input in delivered)
            {
                var parentOutput = run.Stages.SelectMany(s => s.Outputs)
                    .FirstOrDefault(o => o.RunOutputId == input.FromRunOutputId);
                if (parentOutput is null) continue;

                var parentStage = run.Stages.First(s => s.RunStageId == parentOutput.RunStageId);
                var priorStatus = parentStage.Status;

                // DECREMENT, never zero. The parent may also have fed a different child; on
                // its re-approve, available = produced − scrapped − transferred must still
                // account for that other delivery. This decrement is what keeps the model
                // self-consistent.
                parentOutput.TransferredQty = Math.Max(0, parentOutput.TransferredQty - input.DeliveredQty);

                pulled.Add(new PulledBackDto(
                    input.RunInputId, parentStage.RunStageId, parentOutput.RunOutputId,
                    input.DeliveredQty, priorStatus,
                    priorStatus == ProductionStageStatus.Approved
                        ? ProductionStageStatus.InProgress
                        : priorStatus));

                input.DeliveredQty = 0;

                if (parentStage.Status == ProductionStageStatus.Approved)
                {
                    parentStage.Status = ProductionStageStatus.InProgress;
                    // Cleared because the stage must be completed again; ActualStartAt is
                    // deliberately PRESERVED so the original start time stands (FR-MFG-19).
                    parentStage.ActualEndAt = null;
                }
            }

            stage.Status = ProductionStageStatus.Waiting;
            stage.ActualStartAt = null;
            stage.ActualEndAt = null;
            RecomputeReadiness(stage);

            AddEvent(run, stage, RunStageEventType.RejectIntake, request.Note, new { pulledBack = pulled });
        }, ct);

        var refreshed = await GetAsync(runId, ct)
                        ?? throw new NotFoundException($"Production run {runId} was not found.");
        return new RejectIntakeResultDto(
            runStageId, refreshed.Value.Stages.First(s => s.RunStageId == runStageId).Status,
            pulled, refreshed.Value);
    }

    public async Task<TerminalRejectResultDto> RejectTerminalAsync(
        int runId, int runStageId, RejectRequest request, CancellationToken ct = default)
    {
        var reworkNumber = 0;

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var (run, stage) = await LoadForActionAsync(runId, runStageId, token);

            if (!IsTerminal(run, stage))
                throw new DomainException(ErrorCodes.StageRejectInvalid,
                    $"Stage '{stage.Name}' is not the final stage; use reject-intake instead.", 409);

            // Only from Done. Approving the terminal completes the run and closes the cost
            // pool, so there is no rework path back from Approved.
            if (stage.Status != ProductionStageStatus.Done)
                throw new DomainException(ErrorCodes.StageNotDone,
                    $"Stage '{stage.Name}' is {stage.Status}; only a completed final stage can be rejected.", 409);

            var hasInbound = run.Edges.Select(e => e.ChildRunStageId).ToHashSet();
            reworkNumber = run.ReworkCount + 1;

            // Snapshot BEFORE resetting — one event row for the whole pass, not one per
            // stage: reconstructing "what did rework #2 look like" from N rows is painful.
            var snapshot = new
            {
                reworkNumber,
                note = request.Note,
                rejectedAt = DateTime.UtcNow,
                stages = run.Stages.OrderBy(s => s.RunStageId).Select(s => new
                {
                    runStageId = s.RunStageId,
                    name = s.Name,
                    status = s.Status.ToString(),
                    actualStartAt = s.ActualStartAt,
                    actualEndAt = s.ActualEndAt,
                    fieldValues = ParseJson(s.FieldValues),
                    outputs = s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new
                    {
                        o.RunOutputId, o.ProducedQty, o.ScrappedQty, o.ScrapReasonCodeId, o.TransferredQty
                    }),
                    inputs = s.Inputs.OrderBy(i => i.RunInputId).Select(i => new
                    {
                        i.RunInputId, i.DeliveredQty, i.ConsumedQty, i.ConsumedValue,
                        i.ReturnedQty, i.ReturnedValue
                    })
                })
            };

            AddEvent(run, stage, RunStageEventType.TerminalReject, request.Note, snapshot);

            run.ReworkCount = reworkNumber;

            foreach (var s in run.Stages)
            {
                // Entry stages become Ready, everything else Waiting.
                s.Status = hasInbound.Contains(s.RunStageId)
                    ? ProductionStageStatus.Waiting
                    : ProductionStageStatus.Ready;

                // A fresh pass: timings and captured field values are cleared so required
                // fields must be answered again. FieldDefs, PosX/PosY, Name, RoleLabel,
                // EstimatedMinutes and TemplateStageId all persist.
                s.ActualStartAt = null;
                s.ActualEndAt = null;
                s.FieldValues = null;

                foreach (var o in s.Outputs)
                {
                    o.ProducedQty = 0;
                    o.ScrappedQty = 0;
                    o.ScrapReasonCodeId = null;
                    o.TransferredQty = 0;
                    // PlannedQty PERSISTS — per-run edits must survive the rework, and
                    // FR-MFG-16 explicitly expects planned quantities to be raised for it.
                }

                foreach (var i in s.Inputs)
                {
                    // WIP is gone...
                    i.DeliveredQty = 0;
                    // ...but consumed and returned figures PERSIST: already-consumed material
                    // remains in the cost pool (FR-MFG-16). No stock is reversed, no ledger
                    // entry is written. The delta rule in StartStageAsync is what makes the
                    // next start consume only any increase.
                }
            }
        }, ct);

        var refreshed = await GetAsync(runId, ct)
                        ?? throw new NotFoundException($"Production run {runId} was not found.");
        return new TerminalRejectResultDto(runStageId, reworkNumber, refreshed.Value);
    }

    public async Task<CancelRunResultDto> CancelAsync(
        int runId, CancelRunRequest request, CancellationToken ct = default)
    {
        var returns = new List<(StockLayer Layer, int ItemId, decimal Qty, decimal UnitCost)>();
        var writeOffs = new List<ScrapWriteOffDto>();
        var ledgerRefs = new List<StockLedger>();

        await _uow.ExecuteInTransactionAsync(async token =>
        {
            var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token)
                ?? throw new NotFoundException($"Production run {runId} was not found.");

            if (run.Status != ProductionRunStatus.InProgress)
                throw new DomainException(ErrorCodes.RunNotCancellable,
                    $"Run {run.DocNo} is {run.Status} and cannot be cancelled.", 409);

            if (request.ReasonCodeId is null)
                throw new DomainException(ErrorCodes.ReasonCodeRequired,
                    "A reason code is required to cancel a run.", 400);
            await EnsureProductionReasonAsync(request.ReasonCodeId.Value, token);

            var now = DateTime.UtcNow;
            var actor = _currentUser.AuditUserId;

            // Same accumulation as TransferService: layers created in this transaction are
            // invisible to GetOnHandAsync until SaveChanges, so without this every
            // RunningBalance after the first for a repeated item would be wrong.
            var balances = new Dictionary<int, decimal>();

            foreach (var input in run.Stages.SelectMany(s => s.Inputs)
                         .Where(i => i.Source == StageInputSource.Stock && i.ItemId is not null)
                         .OrderBy(i => i.RunInputId))
            {
                var qty = input.ConsumedQty - input.ReturnedQty;
                if (qty <= 0) continue;

                var unitCost = Math.Round(input.ConsumedValue / input.ConsumedQty, 6, MidpointRounding.AwayFromZero);
                // Exact residual, so the input's pool contribution lands on zero.
                var value = input.ConsumedValue - input.ReturnedValue;

                var layer = await _fifo.CreateInboundLayerAsync(
                    input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
                    qty, unitCost, now, token);

                if (!balances.TryGetValue(input.ItemId.Value, out var balance))
                    balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token);
                balance += qty;
                balances[input.ItemId.Value] = balance;

                var entry = await _fifo.PostLedgerAsync(
                    input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
                    actor, Direction.In, qty, unitCost, balance,
                    LedgerSourceTypes.ProductionCancelReturn, run.RunId, now, token,
                    valueOverride: Math.Round(value, 4, MidpointRounding.AwayFromZero));

                ledgerRefs.Add(entry);
                returns.Add((layer, input.ItemId.Value, qty, unitCost));

                input.ReturnedQty = input.ConsumedQty;
                input.ReturnedValue = input.ConsumedValue;
            }

            // FR-MFG-17 says "net consumed − returned − scrapped", but scrap is not
            // computable at the input level: ScrappedQty lives on OUTPUTS, in output UOM,
            // with no per-input scrap anywhere in the model. Physically the doc's intent is
            // already satisfied — scrap is recorded against outputs (finished/intermediate
            // WIP) which never entered stock, so there is nothing to deduct from an input
            // return. The scrapped quantities are recorded as the written-off figure instead.
            writeOffs.AddRange(run.Stages.SelectMany(s => s.Outputs)
                .Where(o => o.ScrappedQty > 0)
                .Select(o => new ScrapWriteOffDto(o.RunOutputId, o.Name, o.ScrappedQty)));

            run.Status = ProductionRunStatus.Cancelled;
            run.CancelReasonCodeId = request.ReasonCodeId;
            // CompletedAt stays null — a cancelled run never completed.

            AddEvent(run, null, RunStageEventType.Cancel, request.Note, new
            {
                reasonCodeId = request.ReasonCodeId,
                returns = returns.Select(r => new { r.ItemId, r.Qty, r.UnitCost }),
                scrappedWrittenOff = writeOffs
            });
        }, ct);

        return new CancelRunResultDto(
            runId, ProductionRunStatus.Cancelled,
            returns.Select(r => new CancelReturnDto(r.ItemId, r.Qty, r.UnitCost, r.Layer.LayerId)).ToList(),
            writeOffs,
            ledgerRefs.Select(l => l.LedgerId).ToList());
    }

    // --- action helpers ------------------------------------------------------

    /// <summary>
    /// Hands <paramref name="qty"/> of an output to the child inputs that draw from it, and
    /// re-evaluates each child's readiness.
    /// </summary>
    /// <remarks>
    /// Routing is by <c>RunStageInput.FromRunOutputId</c>, not by <see cref="RunEdge"/>.
    /// FR-MFG-12 describes transfers "per outbound edge", but the entity model connects an
    /// output to a specific input — an edge can exist with no input drawing from it, and one
    /// output can feed inputs on several children. Recorded as a contract clarification.
    /// <para>When several inputs draw from one output and no explicit target is named, they
    /// are filled in <c>runInputId</c> order up to each one's outstanding need, with any
    /// overflow going to the last. Deterministic, and identical to "give it all to the one
    /// target" in the overwhelmingly common 1:1 case.</para>
    /// <para>WIP quantities are moved at face value in their declared UOM — no conversion.
    /// Intermediate outputs are internal work-in-progress with no item and no ledger entry
    /// (FR-MFG-04/05), so there is no base UOM to convert to.</para>
    /// </remarks>
    private List<TransferDto> Deliver(
        ProductionRun run, RunStageOutput output, decimal qty, int? explicitInputId, decimal available)
    {
        if (qty > available)
            throw new DomainException(ErrorCodes.TransferExceedsAvailable,
                $"Cannot transfer {qty} from output '{output.Name}': only {available} is available "
                + $"(produced {output.ProducedQty} - scrapped {output.ScrappedQty} - transferred {output.TransferredQty}).",
                422);

        var targets = run.Stages
            .SelectMany(s => s.Inputs)
            .Where(i => i.Source == StageInputSource.Upstream && i.FromRunOutputId == output.RunOutputId)
            .OrderBy(i => i.RunInputId)
            .ToList();

        if (explicitInputId is not null)
        {
            targets = targets.Where(i => i.RunInputId == explicitInputId).ToList();
            if (targets.Count == 0)
                throw new DomainException(ErrorCodes.Validation,
                    $"Input {explicitInputId} does not draw from output '{output.Name}'.", 422);
        }

        // An output nothing consumes is legal (the graph validator does not require every
        // output to be wired). Record the transfer against the output and stop there.
        if (targets.Count == 0)
        {
            output.TransferredQty += qty;
            return [];
        }

        var results = new List<TransferDto>();
        var remaining = qty;

        for (var idx = 0; idx < targets.Count && remaining > 0; idx++)
        {
            var target = targets[idx];
            var isLast = idx == targets.Count - 1;
            var need = target.PlannedQty - target.DeliveredQty;
            var give = isLast ? remaining : Math.Min(remaining, Math.Max(need, 0));
            if (give <= 0) continue;

            target.DeliveredQty += give;
            remaining -= give;

            var childStage = run.Stages.First(s => s.RunStageId == target.RunStageId);
            RecomputeReadiness(childStage);

            results.Add(new TransferDto(
                output.RunOutputId, target.RunInputId, childStage.RunStageId, give,
                target.DeliveredQty, childStage.Status));
        }

        output.TransferredQty += qty - remaining;
        return results;
    }

    /// <summary>
    /// Terminal approve = production receipt (FR-MFG-13): creates the finished-goods layer
    /// costed at <c>costPool / goodQty</c> and posts the inbound ledger entry.
    /// </summary>
    private async Task<(Receipt Receipt, CostPoolDto Pool, StockLedger Entry)> PostReceiptAsync(
        ProductionRun run, RunStage stage, DateTime now, CancellationToken ct)
    {
        var output = stage.Outputs.SingleOrDefault()
            ?? throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
                $"The final stage '{stage.Name}' must have exactly one output.", 422);

        if (output.ItemId is null)
            throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
                $"The final stage '{stage.Name}' must produce a real item.", 422);

        var good = output.ProducedQty - output.ScrappedQty;
        if (good <= 0)
            throw new DomainException(ErrorCodes.Validation,
                $"The final stage produced no good quantity ({output.ProducedQty} - {output.ScrappedQty}); "
                + "there is nothing to receive.", 422);

        var item = await _items.Query().AsNoTracking()
            .FirstOrDefaultAsync(i => i.ItemId == output.ItemId, ct)
            ?? throw new DomainException(ErrorCodes.Validation, $"Item {output.ItemId} no longer exists.", 422);

        // docs/30 defines no batch/serial creation on receipt, so a tracked finished good has
        // no valid path. Refuse explicitly rather than silently creating untracked stock.
        if (item.TrackingMode != TrackingMode.None)
            throw new DomainException(ErrorCodes.Validation,
                $"Item {item.Sku} is {item.TrackingMode}-tracked; batch/serial-tracked finished goods "
                + "are not supported in this phase.", 422);

        // Output quantities are always a count of the finished item's base UOM — produced and
        // scrapped are recorded in whole bottles, not millilitres — so nothing to resolve.
        var qtyBase = good;

        var consumedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
        var returnedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
        var pool = consumedValue - returnedValue;

        var unitCost = Math.Round(pool / qtyBase, 6, MidpointRounding.AwayFromZero);

        var layer = await _fifo.CreateInboundLayerAsync(
            item.ItemId, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
            qtyBase, unitCost, now, ct);

        var balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct) + qtyBase;

        // valueOverride carries the pool exactly. unitCost is rounded to 6 dp, so at 100+
        // units qty × unitCost drifts from the pool by more than the ledger's 4 dp tick and
        // Σ ledger value would no longer reconcile to Σ consumed − Σ returned.
        var ledgerValue = Math.Round(pool, 4, MidpointRounding.AwayFromZero);
        var entry = await _fifo.PostLedgerAsync(
            item.ItemId, run.WarehouseId, run.OutputBinId, batchId: null, serialId: null,
            _currentUser.AuditUserId, Direction.In, qtyBase, unitCost, balance,
            LedgerSourceTypes.ProductionReceipt, run.RunId, now, ct, valueOverride: ledgerValue);

        // StockLayer carries no bin, so outputBinId reaches the ledger only; echo the run's
        // bin rather than reading it back off the layer.
        var receipt = new Receipt(
            layer, item.ItemId, run.WarehouseId, run.OutputBinId, qtyBase, unitCost, ledgerValue);

        return (receipt, new CostPoolDto(consumedValue, returnedValue, pool), entry);
    }

    private static bool IsTerminal(ProductionRun run, RunStage stage)
        => !run.Edges.Any(e => e.ParentRunStageId == stage.RunStageId);

    /// <summary>
    /// Loads the run graph tracked and resolves the stage, enforcing that the run is still
    /// open. Shared by every stage action so the guards can't diverge.
    /// </summary>
    private async Task<(ProductionRun Run, RunStage Stage)> LoadForActionAsync(
        int runId, int runStageId, CancellationToken ct)
    {
        var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, ct)
            ?? throw new NotFoundException($"Production run {runId} was not found.");

        if (run.Status != ProductionRunStatus.InProgress)
            throw new ConflictException($"Run {run.DocNo} is {run.Status}; no further stage actions are possible.");

        var stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
            ?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");

        return (run, stage);
    }

    /// <summary>FR-MFG-07: every field marked required must carry a non-empty value.</summary>
    private static void ValidateRequiredFields(RunStage stage, JsonElement? values)
    {
        var defs = ProductionJson.Deserialize<List<FieldDefDto>>(stage.FieldDefs, []);
        var required = defs.Where(d => d.Required).ToList();
        if (required.Count == 0) return;

        var missing = new List<string>();
        foreach (var def in required)
        {
            if (values is null || values.Value.ValueKind != JsonValueKind.Object
                || !values.Value.TryGetProperty(def.Key, out var value)
                || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
                || (value.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(value.GetString())))
            {
                missing.Add(def.Label);
            }
        }

        if (missing.Count > 0)
            throw new DomainException(ErrorCodes.RequiredFieldMissing,
                $"Required field(s) not provided for stage '{stage.Name}': {string.Join(", ", missing)}.", 400);
    }

    private async Task EnsureProductionReasonAsync(int reasonCodeId, CancellationToken ct)
    {
        var reason = await _reasonCodes.Query().AsNoTracking()
            .FirstOrDefaultAsync(r => r.ReasonCodeId == reasonCodeId, ct)
            ?? throw new DomainException(ErrorCodes.Validation,
                $"Reason code {reasonCodeId} does not exist.", 422);

        if (reason.Context != ReasonContext.Production)
            throw new DomainException(ErrorCodes.Validation,
                $"Reason code '{reason.Code}' is a {reason.Context} reason, not a Production reason.", 422);
    }

    private async Task<RunStageDto> RequireStageAsync(int runId, int runStageId, CancellationToken ct)
    {
        var run = await GetAsync(runId, ct)
                  ?? throw new NotFoundException($"Production run {runId} was not found.");
        return run.Value.Stages.First(s => s.RunStageId == runStageId);
    }

    // --- shared helpers ------------------------------------------------------

    private IQueryable<ProductionRun> LoadGraphQuery() =>
        _runs.Query()
            .Include(r => r.Template)
            .Include(r => r.Stages).ThenInclude(s => s.Inputs)
            .Include(r => r.Stages).ThenInclude(s => s.Outputs)
            .Include(r => r.Edges)
            .Include(r => r.Events);

    /// <summary>
    /// Re-evaluates a single stage's Waiting/Ready state against its upstream deliveries
    /// (FR-MFG-09 — an all-parents join, per-edge full delivery).
    /// </summary>
    /// <remarks>
    /// Only ever moves between Waiting and Ready; a started stage is left alone, so callers
    /// can invoke it on any stage without guarding. A stage with no upstream inputs is an
    /// entry stage and is always Ready. The comparison is <c>&gt;=</c>, not <c>==</c>, so a
    /// partial-then-full transfer sequence still satisfies it.
    /// </remarks>
    internal static void RecomputeReadiness(RunStage stage)
    {
        if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready)) return;

        var upstream = stage.Inputs.Where(i => i.Source == StageInputSource.Upstream).ToList();
        var satisfied = upstream.Count == 0 || upstream.All(i => i.DeliveredQty >= i.PlannedQty);

        stage.Status = satisfied ? ProductionStageStatus.Ready : ProductionStageStatus.Waiting;
    }

    private void AddEvent(ProductionRun run, RunStage? stage, RunStageEventType type, string? note, object? payload)
    {
        var ev = new RunStageEvent
        {
            Run = run,
            RunStage = stage,
            EventType = type,
            Note = note,
            Payload = payload is null ? null : ProductionJson.Serialize(payload),
            UserId = _currentUser.AuditUserId,
            CreatedAt = DateTime.UtcNow
        };
        run.Events.Add(ev);
        stage?.Events.Add(ev);
    }

    // --- mapping -------------------------------------------------------------

    private static RunGraphDto ToGraphDto(ProductionRun run)
    {
        var hasOutbound = run.Edges.Select(e => e.ParentRunStageId).ToHashSet();
        var hasInbound = run.Edges.Select(e => e.ChildRunStageId).ToHashSet();

        var consumed = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
        var returned = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);

        var stages = run.Stages
            .OrderBy(s => s.RunStageId)
            .Select(s => new RunStageDto(
                s.RunStageId, s.TemplateStageId, s.Name, s.RoleLabel, s.EstimatedMinutes, s.PosX, s.PosY,
                s.Status,
                !hasOutbound.Contains(s.RunStageId),
                !hasInbound.Contains(s.RunStageId),
                s.ActualStartAt, s.ActualEndAt,
                s.ActualStartAt is not null && s.ActualEndAt is not null
                    ? (int)Math.Round((s.ActualEndAt.Value - s.ActualStartAt.Value).TotalMinutes)
                    : null,
                ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
                ParseJson(s.FieldValues),
                s.Inputs.OrderBy(i => i.RunInputId).Select(i => new RunStageInputDto(
                    i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.QtyUnit,
                    i.PlannedQty, i.ConsumedQty, i.ConsumedValue,
                    i.DeliveredQty, i.ReturnedQty, i.ReturnedValue)).ToList(),
                s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new RunStageOutputDto(
                    o.RunOutputId, o.ItemId, o.Name, o.UomId,
                    o.PlannedQty, o.ProducedQty, o.ScrappedQty, o.ScrapReasonCodeId,
                    o.TransferredQty,
                    o.ProducedQty - o.ScrappedQty - o.TransferredQty)).ToList()))
            .ToList();

        var edges = run.Edges
            .OrderBy(e => e.RunEdgeId)
            .Select(e => new RunEdgeDto(e.RunEdgeId, e.ParentRunStageId, e.ChildRunStageId))
            .ToList();

        var events = run.Events
            .OrderBy(e => e.EventId)
            .Select(e => new RunEventDto(
                e.EventId, e.RunStageId, e.EventType, e.Note, ParseJson(e.Payload), e.UserId, e.CreatedAt))
            .ToList();

        return new RunGraphDto(
            run.RunId, run.DocNo, run.TemplateId, run.Template?.Name ?? string.Empty,
            run.WarehouseId, run.OutputBinId, run.TargetQty, run.ScaleFactor,
            run.Status, run.ReworkCount, run.CancelReasonCodeId,
            new CostPoolDto(consumed, returned, consumed - returned),
            stages, edges, events,
            run.CreatedBy, run.CreatedAt, run.CompletedAt);
    }

    private static JsonElement? ParseJson(string? json)
        => string.IsNullOrWhiteSpace(json) ? null : JsonDocument.Parse(json).RootElement.Clone();
}
