using ERPCore.Domain.Enums;
using ERPCore.System.Errors;

namespace ERPCore.Services.Production;

/// <summary>
/// Validates a template stage graph (FR-MFG-02, FR-MFG-04, FR-MFG-05) and throws the
/// matching <c>422 GRAPH_*</c> domain error on the first violation.
/// </summary>
/// <remarks>
/// <para>This is an <b>algorithm, not a service</b> — pure, synchronous, no database and
/// no DI, in the spirit of <c>FifoCostingService</c> being the one place FIFO lives. It
/// deliberately has no interface: <c>Services/Interfaces/</c> exists for things that get
/// injected, and registering this would buy nothing.</para>
/// <para>It works entirely in <b>keys</b>, never database ids, so the identical code path
/// runs for a POST (where nothing has an id yet) and a PUT (where most things do). All
/// database-dependent checks — does this item exist, is it Active, does this UOM exist —
/// stay in <c>ProductionTemplateService</c> so this stays free of I/O.</para>
/// <para>Checks run cheapest-first and fail fast, and every message names the offending
/// stage or edge so the canvas can focus it (docs/21 §2).</para>
/// </remarks>
public static class ProductionGraphValidator
{
    public sealed record InputDraft(
        int Index, StageInputSource Source, int? ItemId, string? FromOutputKey, StageQtyUnit QtyUnit);

    public sealed record OutputDraft(string Key, string Name, int? ItemId, int? UomId);

    public sealed record StageDraft(
        string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);

    public readonly record struct EdgeDraft(string ParentKey, string ChildKey);

    /// <summary>
    /// Throws on the first rule violation; returns silently for a valid graph.
    /// </summary>
    public static void Validate(IReadOnlyList<StageDraft> stages, IReadOnlyList<EdgeDraft> edges)
    {
        // 1 — structural hygiene. These are all blocked in the canvas at draw time
        // (docs/21 §2); this is the server backstop for a hand-rolled request.
        if (stages.Count == 0)
            throw Invalid("A template must have at least one stage.");

        var stageByKey = new Dictionary<string, StageDraft>(StringComparer.Ordinal);
        foreach (var s in stages)
        {
            if (string.IsNullOrWhiteSpace(s.Key))
                throw Invalid("Every stage requires a key.");
            if (!stageByKey.TryAdd(s.Key, s))
                throw Invalid($"Duplicate stage key '{s.Key}'.");
        }

        // Output keys are unique across the whole template, not just within a stage —
        // an Upstream input names one by key alone, so a collision would be ambiguous.
        var outputOwner = new Dictionary<string, string>(StringComparer.Ordinal);
        foreach (var s in stages)
            foreach (var o in s.Outputs)
            {
                if (string.IsNullOrWhiteSpace(o.Key))
                    throw Invalid($"Every output of stage '{s.Name}' requires a key.");
                if (!outputOwner.TryAdd(o.Key, s.Key))
                    throw Invalid($"Duplicate output key '{o.Key}' (stage '{s.Name}').");
            }

        var edgeSet = new HashSet<(string, string)>();
        foreach (var e in edges)
        {
            if (!stageByKey.ContainsKey(e.ParentKey) || !stageByKey.ContainsKey(e.ChildKey))
                throw Invalid($"Edge '{e.ParentKey}' → '{e.ChildKey}' references a stage that is not in the payload.");
            if (string.Equals(e.ParentKey, e.ChildKey, StringComparison.Ordinal))
                throw Invalid($"Stage '{stageByKey[e.ParentKey].Name}' cannot connect to itself.");
            if (!edgeSet.Add((e.ParentKey, e.ChildKey)))
                throw Invalid($"Duplicate edge '{stageByKey[e.ParentKey].Name}' → '{stageByKey[e.ChildKey].Name}'.");
        }

        var children = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
        var parents = stages.ToDictionary(s => s.Key, _ => new List<string>(), StringComparer.Ordinal);
        foreach (var (parent, child) in edgeSet)
        {
            children[parent].Add(child);
            parents[child].Add(parent);
        }

        // 2 — cycle detection by Kahn's algorithm. If the toposort can't reach every
        // stage, the unreached set is exactly the stages trapped in (or downstream of)
        // a cycle, which is what the client highlights.
        var indegree = stages.ToDictionary(s => s.Key, s => parents[s.Key].Count, StringComparer.Ordinal);
        var queue = new Queue<string>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
        var sorted = 0;
        while (queue.Count > 0)
        {
            var key = queue.Dequeue();
            sorted++;
            foreach (var child in children[key])
                if (--indegree[child] == 0)
                    queue.Enqueue(child);
        }

        if (sorted != stages.Count)
        {
            var trapped = indegree.Where(kv => kv.Value > 0).Select(kv => stageByKey[kv.Key].Name);
            throw new DomainException(ErrorCodes.GraphCycle,
                $"The stage graph contains a cycle involving: {string.Join(", ", trapped)}.", 422);
        }

        // 3 — exactly one terminal stage (multiple starts may converge, but the line must
        // end in one place, because that single stage is what receives the finished good).
        var terminals = stages.Where(s => children[s.Key].Count == 0).ToList();
        if (terminals.Count != 1)
            throw new DomainException(ErrorCodes.GraphTerminalCount,
                terminals.Count == 0
                    ? "The stage graph has no final stage."
                    : $"The stage graph must converge to exactly one final stage, but {terminals.Count} have no outgoing connection: {string.Join(", ", terminals.Select(t => t.Name))}.",
                422);

        var terminal = terminals[0];
        var entries = stages.Where(s => parents[s.Key].Count == 0).ToList();

        // 4 — connectivity. One traversal each way subsumes all three of "no disconnected
        // stages", "every stage reachable from an entry" and "every stage reaches the
        // terminal": an isolated stage simply appears in neither set. A lone stage is both
        // an entry and the terminal, so it falls out correctly with no special case.
        //
        // Kept as defence in depth, but note it is unreachable once checks 2 and 3 pass:
        // in an acyclic graph every stage is reachable from some source, and if exactly one
        // stage lacks an outbound edge then every stage necessarily reaches it. An isolated
        // stage therefore surfaces as GRAPH_TERMINAL_COUNT (it is a second terminal), which
        // is the better message anyway because it names both offenders. Verified by the M2
        // smoke test, which asserts that behaviour explicitly.
        var fromEntry = Reach(entries.Select(s => s.Key), children);
        var toTerminal = Reach([terminal.Key], parents);

        var stranded = stages.Where(s => !fromEntry.Contains(s.Key) || !toTerminal.Contains(s.Key)).ToList();
        if (stranded.Count > 0)
            throw new DomainException(ErrorCodes.GraphDisconnected,
                $"Every stage must sit on a path from a starting stage to '{terminal.Name}', but these do not: {string.Join(", ", stranded.Select(s => s.Name))}.",
                422);

        // 5 — input sources (FR-MFG-04). An Upstream input may only draw from an output of
        // a DIRECT parent: allowing a grandparent's output would mean WIP skipping a stage.
        foreach (var s in stages)
        {
            var allowed = parents[s.Key]
                .SelectMany(p => stageByKey[p].Outputs.Select(o => o.Key))
                .ToHashSet(StringComparer.Ordinal);

            foreach (var input in s.Inputs)
            {
                if (input.Source == StageInputSource.Upstream)
                {
                    if (input.ItemId is not null)
                        throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
                            $"Input {input.Index + 1} of stage '{s.Name}' is Upstream and cannot also reference an item.", 422);
                    if (string.IsNullOrWhiteSpace(input.FromOutputKey))
                        throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
                            $"Input {input.Index + 1} of stage '{s.Name}' is Upstream but names no source output.", 422);
                    if (!allowed.Contains(input.FromOutputKey))
                    {
                        var owner = outputOwner.TryGetValue(input.FromOutputKey, out var ownerKey)
                            ? $"'{stageByKey[ownerKey].Name}' is not a direct parent of '{s.Name}'"
                            : $"output '{input.FromOutputKey}' does not exist";
                        throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
                            $"Input {input.Index + 1} of stage '{s.Name}' must draw from a direct parent's output — {owner}.", 422);
                    }
                }
                else
                {
                    if (input.ItemId is null)
                        throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
                            $"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and requires an item.", 422);
                    if (!string.IsNullOrWhiteSpace(input.FromOutputKey))
                        throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
                            $"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
                }

                // WIP has no content size — it is counted in whatever unit its source output
                // declares — so only a Stock input may be expressed in ml/g.
                if (input.Source == StageInputSource.Upstream && input.QtyUnit != StageQtyUnit.Pack)
                    throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
                        $"Input {input.Index + 1} of stage '{s.Name}' is Upstream, so its quantity must be in whole units of its source output, not content units.", 422);
            }
        }

        // 6 — outputs (FR-MFG-05). The terminal stage produces exactly one real Item (the
        // finished good the receipt creates a layer for); every other output is internal
        // WIP and must stay item-less, or it would imply stock that never exists.
        if (terminal.Outputs.Count != 1)
            throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
                $"The final stage '{terminal.Name}' must have exactly one output, but has {terminal.Outputs.Count}.", 422);

        if (terminal.Outputs[0].ItemId is null)
            throw new DomainException(ErrorCodes.TerminalOutputItemRequired,
                $"The output of the final stage '{terminal.Name}' must reference the finished item.", 422);

        foreach (var s in stages.Where(s => !ReferenceEquals(s, terminal)))
            foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
                throw Invalid(
                    $"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");

        // 7 — output units. An item-bearing output already has a unit (the item's base UOM),
        // so carrying a second one could only contradict it. WIP has no item to ask, so it
        // must name its own — the label the run board and any downstream input display.
        foreach (var s in stages)
            foreach (var o in s.Outputs)
            {
                if (o.ItemId is null && o.UomId is null)
                    throw new DomainException(ErrorCodes.WipUnitRequired,
                        $"Output '{o.Name}' of stage '{s.Name}' is work-in-progress and must declare a unit.", 422);
                if (o.ItemId is not null && o.UomId is not null)
                    throw Invalid(
                        $"Output '{o.Name}' of stage '{s.Name}' references an item, so its unit comes from that item and must not be set.");
            }
    }

    /// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
    private static HashSet<string> Reach(IEnumerable<string> roots, Dictionary<string, List<string>> next)
    {
        var seen = new HashSet<string>(roots, StringComparer.Ordinal);
        var queue = new Queue<string>(seen);
        while (queue.Count > 0)
            foreach (var n in next[queue.Dequeue()])
                if (seen.Add(n))
                    queue.Enqueue(n);
        return seen;
    }

    /// <summary>
    /// Structural violations the client already prevents and docs/30 §D.4 assigns no
    /// dedicated code to — still 422, still carrying a message that names the culprit.
    /// </summary>
    private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
}
