namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
///     Pure diff between two <see cref="AddressSpaceComposition"/> snapshots — the
///     <c>previous</c> currently-applied composition and the <c>next</c> from a freshly-applied
///     deployment. Three lists per entity class (Equipment / DriverInstance / ScriptedAlarm)
///     captured by stable identity: added items are new, removed items have to be torn down,
///     changed items have the same identity but at least one field differs.
///
///     OpcUaPublishActor's <c>RebuildAddressSpace</c> consumes this against a real
///     <see cref="Commons.OpcUa.IOpcUaAddressSpaceSink"/> binding so re-applies only mutate the
///     nodes that actually changed — full tear-down + rebuild is reserved for first-boot or
///     drastic schema flips.
/// </summary>
public sealed record AddressSpacePlan(
    IReadOnlyList<EquipmentNode> AddedEquipment,
    IReadOnlyList<EquipmentNode> RemovedEquipment,
    IReadOnlyList<AddressSpacePlan.EquipmentDelta> ChangedEquipment,
    IReadOnlyList<DriverInstancePlan> AddedDrivers,
    IReadOnlyList<DriverInstancePlan> RemovedDrivers,
    IReadOnlyList<AddressSpacePlan.DriverDelta> ChangedDrivers,
    IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
    IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
    IReadOnlyList<AddressSpacePlan.AlarmDelta> ChangedAlarms)
{
    /// <summary>
    ///     Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
    ///     init-only members (defaulting empty) rather than positional parameters so existing
    ///     <c>AddressSpacePlan</c> construction sites compile unchanged — consistent with how
    ///     <see cref="AddressSpaceComposition.EquipmentTags"/> was added. Without these, an
    ///     incremental deploy that changes ONLY equipment tags produced an empty plan and
    ///     <c>OpcUaPublishActor.HandleRebuild</c> short-circuited before materialising them.
    /// </summary>
    public IReadOnlyList<EquipmentTagPlan> AddedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
    /// <inheritdoc cref="AddedEquipmentTags"/>
    public IReadOnlyList<EquipmentTagPlan> RemovedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
    /// <inheritdoc cref="AddedEquipmentTags"/>
    public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();

    /// <summary>
    ///     Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
    ///     The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
    ///     <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
    ///     VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
    ///     non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
    ///     such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
    ///     compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
    /// </summary>
    public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
    /// <inheritdoc cref="AddedEquipmentVirtualTags"/>
    public IReadOnlyList<EquipmentVirtualTagPlan> RemovedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
    /// <inheritdoc cref="AddedEquipmentVirtualTags"/>
    public IReadOnlyList<EquipmentVirtualTagDelta> ChangedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagDelta>();

    /// <summary>
    ///     UNS Area / Line folder renames: a folder whose stable id is unchanged but
    ///     whose <c>DisplayName</c> differs between the previous + next composition. A deploy whose ONLY
    ///     change is an Area or Line rename produces NO Equipment / Driver / Alarm / Tag / VirtualTag
    ///     delta, so without this set the plan would be <see cref="IsEmpty"/> and
    ///     <c>OpcUaPublishActor.HandleRebuild</c> would short-circuit BEFORE the apply path runs, leaving
    ///     the folder's stale OPC UA <c>DisplayName</c> until some unrelated structural change forced a
    ///     rebuild. <see cref="AddressSpaceApplier"/> applies each rename IN PLACE via
    ///     <see cref="Commons.OpcUa.ISurgicalAddressSpaceSink.UpdateFolderDisplayName"/> (folder NodeId =
    ///     the area's <c>UnsAreaId</c> / line's <c>UnsLineId</c>, the exact ids <c>MaterialiseHierarchy</c>
    ///     uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
    ///     missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
    ///     every existing <c>AddressSpacePlan</c> construction site compiles unchanged — consistent with
    ///     the EquipmentTag / EquipmentVirtualTag diff sets above.
    /// </summary>
    public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();

    /// <summary>
    ///     v3 Batch 4 — the Raw subtree's <b>container</b> diff (Folder/Driver/Device/TagGroup nodes),
    ///     keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
    ///     its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
    ///     is immutable identity. Init-only, defaulting empty, so existing construction sites compile
    ///     unchanged (consistent with the EquipmentTag diff sets above).
    /// </summary>
    public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
    /// <inheritdoc cref="AddedRawContainers"/>
    public IReadOnlyList<RawContainerNode> RemovedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
    /// <inheritdoc cref="AddedRawContainers"/>
    public IReadOnlyList<RawContainerDelta> ChangedRawContainers { get; init; } = Array.Empty<RawContainerDelta>();

    /// <summary>
    ///     v3 Batch 4 — the Raw subtree's <b>tag</b> diff, keyed by <see cref="RawTagPlan.NodeId"/> (the
    ///     RawPath). A raw tag <b>rename</b> changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here
    ///     (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps
    ///     the same NodeId ⇒ a <see cref="RawTagDelta"/>. Init-only, defaulting empty.
    /// </summary>
    public IReadOnlyList<RawTagPlan> AddedRawTags { get; init; } = Array.Empty<RawTagPlan>();
    /// <inheritdoc cref="AddedRawTags"/>
    public IReadOnlyList<RawTagPlan> RemovedRawTags { get; init; } = Array.Empty<RawTagPlan>();
    /// <inheritdoc cref="AddedRawTags"/>
    public IReadOnlyList<RawTagDelta> ChangedRawTags { get; init; } = Array.Empty<RawTagDelta>();

    /// <summary>
    ///     v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by
    ///     <see cref="UnsReferenceVariable.UnsTagReferenceId"/> (the stable row id, NOT the NodeId). Keying on
    ///     the row id is what makes a backing-raw-tag rename a <b>re-point</b> (same reference row, same
    ///     effective name ⇒ same UNS NodeId, but <see cref="UnsReferenceVariable.BackingRawPath"/> — the
    ///     <c>Organizes</c> target + fan-out source — moves) rather than a remove+add; and a
    ///     display-name-override edit a <see cref="UnsReferenceDelta"/> (same row id, NodeId + effective name
    ///     change) with NO raw-side change. Init-only, defaulting empty.
    /// </summary>
    public IReadOnlyList<UnsReferenceVariable> AddedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
    /// <inheritdoc cref="AddedUnsReferenceVariables"/>
    public IReadOnlyList<UnsReferenceVariable> RemovedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
    /// <inheritdoc cref="AddedUnsReferenceVariables"/>
    public IReadOnlyList<UnsReferenceDelta> ChangedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceDelta>();

    /// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
    public bool IsEmpty =>
        AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
        AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
        AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
        AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
        AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
        AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
        AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
        AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 &&
        RenamedFolders.Count == 0;

    public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
    public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
    public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
    public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
    public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);

    /// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
    public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current);
    /// <summary>A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize /
    /// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add.</summary>
    public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current);
    /// <summary>A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing
    /// field — a backing-tag re-point (<c>BackingRawPath</c> moved) or a display-name-override edit (NodeId +
    /// effective name changed).</summary>
    public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current);

    /// <summary>One renamed UNS Area / Line folder: the stable folder
    /// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
    /// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
    /// to apply in place.</summary>
    /// <param name="FolderNodeId">The folder's stable NodeId (area/line id) — unchanged by a rename.</param>
    /// <param name="NewDisplayName">The new display name to apply.</param>
    public sealed record FolderRename(string FolderNodeId, string NewDisplayName);
}

public static class AddressSpacePlanner
{
    /// <summary>
    ///     Diff two compositions, emitting Added/Removed/Changed sets per entity class.
    ///     Identity is the entity's stable id (EquipmentId, DriverInstanceId, ScriptedAlarmId).
    ///     Element equality on the projection records doubles as the "did this change" check,
    ///     so any field difference moves an item from "stable" to ChangedX.
    /// </summary>
    /// <param name="previous">The previous composition result.</param>
    /// <param name="next">The new composition result.</param>
    /// <returns>The diff plan between the two compositions.</returns>
    public static AddressSpacePlan Compute(AddressSpaceComposition previous, AddressSpaceComposition next)
    {
        ArgumentNullException.ThrowIfNull(previous);
        ArgumentNullException.ThrowIfNull(next);

        var (addedEq, removedEq, changedEq) = DiffById(
            previous.EquipmentNodes, next.EquipmentNodes,
            n => n.EquipmentId,
            (a, b) => new AddressSpacePlan.EquipmentDelta(a, b));

        var (addedDrv, removedDrv, changedDrv) = DiffById(
            previous.DriverInstancePlans, next.DriverInstancePlans,
            d => d.DriverInstanceId,
            (a, b) => new AddressSpacePlan.DriverDelta(a, b));

        var (addedAlarm, removedAlarm, changedAlarm) = DiffById(
            previous.ScriptedAlarmPlans, next.ScriptedAlarmPlans,
            a => a.ScriptedAlarmId,
            (a, b) => new AddressSpacePlan.AlarmDelta(a, b));

        var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
            previous.EquipmentTags, next.EquipmentTags,
            t => t.TagId,
            (a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b));

        // VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
        // overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
        // Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
        // list instances, identical contents) correctly diffs to empty; only a real content change is
        // flagged as changed.
        var (addedVTags, removedVTags, changedVTags) = DiffById(
            previous.EquipmentVirtualTags, next.EquipmentVirtualTags,
            t => t.VirtualTagId,
            (a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));

        // Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces
        // as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container
        // changes (none today — a container carries only DisplayName == leaf name, which lives in the
        // RawPath) fall to Changed, kept for completeness / future fields.
        var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById(
            previous.RawContainers, next.RawContainers,
            c => c.NodeId,
            (a, b) => new AddressSpacePlan.RawContainerDelta(a, b));

        // Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an
        // attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record
        // equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty.
        var (addedRawTags, removedRawTags, changedRawTags) = DiffById(
            previous.RawTags, next.RawTags,
            t => t.NodeId,
            (a, b) => new AddressSpacePlan.RawTagDelta(a, b));

        // UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on
        // the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS
        // NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a
        // Changed delta (same row id, NodeId + effective name changed) with no raw-side entry.
        var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById(
            previous.UnsReferenceVariables, next.UnsReferenceVariables,
            v => v.UnsTagReferenceId,
            (a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b));

        // UNS Area / Line renames: a folder whose stable id is unchanged but whose
        // DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
        // change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
        // IS the area/line id (the exact scheme MaterialiseHierarchy uses), so the rename carries it
        // directly. Areas first, then lines; each list is independently sorted by id for determinism.
        var renamedFolders = DiffRenames(previous.UnsAreas, next.UnsAreas, a => a.UnsAreaId, a => a.DisplayName)
            .Concat(DiffRenames(previous.UnsLines, next.UnsLines, l => l.UnsLineId, l => l.DisplayName))
            .ToList();

        return new AddressSpacePlan(
            addedEq, removedEq, changedEq,
            addedDrv, removedDrv, changedDrv,
            addedAlarm, removedAlarm, changedAlarm)
        {
            AddedEquipmentTags = addedEqTags,
            RemovedEquipmentTags = removedEqTags,
            ChangedEquipmentTags = changedEqTags,
            AddedEquipmentVirtualTags = addedVTags,
            RemovedEquipmentVirtualTags = removedVTags,
            ChangedEquipmentVirtualTags = changedVTags,
            AddedRawContainers = addedRawContainers,
            RemovedRawContainers = removedRawContainers,
            ChangedRawContainers = changedRawContainers,
            AddedRawTags = addedRawTags,
            RemovedRawTags = removedRawTags,
            ChangedRawTags = changedRawTags,
            AddedUnsReferenceVariables = addedUnsRefs,
            RemovedUnsReferenceVariables = removedUnsRefs,
            ChangedUnsReferenceVariables = changedUnsRefs,
            RenamedFolders = renamedFolders,
        };
    }

    /// <summary>
    ///     Emit a <see cref="AddressSpacePlan.FolderRename"/> for every folder present in
    ///     BOTH snapshots (matched by stable <paramref name="identity"/>) whose <paramref name="displayName"/>
    ///     differs (ordinal). Added/removed folders are NOT renames — they're handled by the equipment /
    ///     hierarchy rebuild path — so this pass only flags an in-place display-name change on a surviving
    ///     folder. Sorted by id for deterministic ordering.
    /// </summary>
    private static List<AddressSpacePlan.FolderRename> DiffRenames<T>(
        IReadOnlyList<T> previous,
        IReadOnlyList<T> next,
        Func<T, string> identity,
        Func<T, string> displayName) where T : class
    {
        var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
        var renames = new List<AddressSpacePlan.FolderRename>();
        foreach (var n in next)
        {
            if (prevById.TryGetValue(identity(n), out var p)
                && !string.Equals(displayName(p), displayName(n), StringComparison.Ordinal))
            {
                renames.Add(new AddressSpacePlan.FolderRename(identity(n), displayName(n)));
            }
        }
        renames.Sort((a, b) => string.CompareOrdinal(a.FolderNodeId, b.FolderNodeId));
        return renames;
    }

    private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
        DiffById<T, TDelta>(
            IReadOnlyList<T> previous,
            IReadOnlyList<T> next,
            Func<T, string> identity,
            Func<T, T, TDelta> deltaFactory) where T : class
    {
        var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
        var nextById = next.ToDictionary(identity, StringComparer.Ordinal);

        var added = new List<T>();
        var removed = new List<T>();
        var changed = new List<TDelta>();

        foreach (var (id, p) in prevById)
        {
            if (!nextById.TryGetValue(id, out var n)) { removed.Add(p); continue; }
            if (!EqualityComparer<T>.Default.Equals(p, n)) changed.Add(deltaFactory(p, n));
        }
        foreach (var (id, n) in nextById)
        {
            if (!prevById.ContainsKey(id)) added.Add(n);
        }

        added.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
        removed.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
        return (added, removed, changed);
    }
}
