using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;

/// <summary>
///     Translates a Galaxy object hierarchy (from <see cref="IGalaxyHierarchySource"/>) into
///     <see cref="IAddressSpaceBuilder"/> calls — folders for each gobject, variables for
///     each dynamic attribute. Alarm-bearing attributes get all five sub-attribute refs
///     populated via <see cref="AlarmRefBuilder"/> so the server-level alarm subsystem
///     (PR 2.2) can subscribe + ack without help from the driver.
/// </summary>
/// <remarks>
///     Hierarchy materialisation rules (mirror legacy <c>MxAccessGalaxyBackend.DiscoverAsync</c>):
///     <list type="bullet">
///       <item>Browse name = <c>contained_name</c> when present; falls back to <c>tag_name</c>.</item>
///       <item>Folder per gobject; variables placed inside their owner folder.</item>
///       <item>Variable's full reference = <c>tag_name.attribute_name</c> — the format MXAccess
///         expects for read/write addressing (translated from the contained-name browse path).</item>
///       <item>Hierarchy is nested by <c>parent_gobject_id</c>: each gobject's folder is created
///         under the folder of the gobject named by its <c>parent_gobject_id</c> (resolved
///         order-independently and memoised, so the parent can appear before or after the child).
///         A gobject degrades to the driver root when its <c>parent_gobject_id</c> is <c>0</c>,
///         self-referential, or names a gobject not present in the returned set — so a model
///         that carries no parentage still renders flat. If a parent-reference cycle exists
///         (e.g. A→B→A), EVERY member of the cycle degrades to the root — none nests under
///         another cycle member — so materialisation stays finite and flat.</item>
///     </list>
/// </remarks>
public sealed class GalaxyDiscoverer
{
    private readonly IGalaxyHierarchySource _source;

    // v3 RawPath keying. Given a discovered Galaxy attributeRef (tag_name.AttributeName), this
    // resolves the authored RawTagEntry the deploy artifact delivered for it (keyed by attributeRef),
    // or null when the attribute is not authored. When present, the emitted node reference
    // (DriverAttributeInfo.FullName) becomes the entry's RawPath — the v3 wire identity the server
    // hands back on read/write/subscribe — and WriteIdempotent flows off the entry. When absent (no
    // mapping, or the live-browse path with no authored tags) the reference falls back to the
    // attributeRef, reproducing the pre-v3 shape exactly.
    private readonly Func<string, RawTagEntry?>? _rawTagByAttributeRef;

    /// <summary>Initializes a new GalaxyDiscoverer with the specified hierarchy source.</summary>
    /// <param name="source">The Galaxy hierarchy source to use for discovery.</param>
    /// <param name="rawTagByAttributeRef">
    ///     Optional v3 resolver mapping a discovered Galaxy attributeRef to its authored
    ///     <see cref="RawTagEntry"/> (RawPath + WriteIdempotent). Null (or a null return) leaves the
    ///     emitted reference as the attributeRef — the identity / live-browse fallback.
    /// </param>
    public GalaxyDiscoverer(
        IGalaxyHierarchySource source,
        Func<string, RawTagEntry?>? rawTagByAttributeRef = null)
    {
        _source = source ?? throw new ArgumentNullException(nameof(source));
        _rawTagByAttributeRef = rawTagByAttributeRef;
    }

    /// <summary>
    ///     Drive the supplied builder with one folder + N variables per Galaxy object the
    ///     gateway returns. Idempotent — caller can re-invoke after a redeploy event.
    /// </summary>
    /// <param name="builder">The address space builder to populate with discovery results.</param>
    /// <param name="cancellationToken">The cancellation token for the operation.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var objects = await _source.GetHierarchyAsync(cancellationToken).ConfigureAwait(false);

        // Pass 1 — collect gobjects with a usable browse identity (order preserved), and index
        // them by gobject_id so pass 2 can resolve a parent the source returned AFTER the child
        // (order-independent). Only the FIRST gobject per non-zero id is indexed as a parent
        // target; the index excludes id 0 so identity-less / un-keyed objects can't collide.
        var usable = new List<GalaxyObject>();
        var byId = new Dictionary<int, GalaxyObject>();
        foreach (var obj in objects)
        {
            var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName;
            if (string.IsNullOrEmpty(browseName)) continue; // skip objects with no usable identity
            usable.Add(obj);
            if (obj.GobjectId != 0)
            {
                byId.TryAdd(obj.GobjectId, obj);
            }
        }

        // Pass 2 — create each gobject's folder under its parent (memoised), then its variables.
        // Iterating the ordered list (not the index) keeps the flat case stable and emits a
        // folder even for objects whose gobject_id is 0 or duplicated.
        var folders = new Dictionary<int, IAddressSpaceBuilder>();
        foreach (var obj in usable)
        {
            var folder = EnsureFolder(obj, builder, byId, folders);

            foreach (var attr in obj.Attributes)
            {
                if (string.IsNullOrEmpty(attr.AttributeName)) continue;

                // attributeRef = the Galaxy dialled address (tag_name.AttributeName) MXAccess reads/writes.
                var attributeRef = !string.IsNullOrEmpty(attr.FullTagReference)
                    ? StripArraySuffix(attr.FullTagReference)
                    : obj.TagName + "." + attr.AttributeName;

                // v3: the node's driver reference (FullName) is the authored RawPath when this
                // attribute is authored; otherwise it degrades to the attributeRef (identity). The
                // WriteIdempotent platform flag travels on the RawTagEntry, not inside the blob.
                var entry = _rawTagByAttributeRef?.Invoke(attributeRef);
                var rawPath = entry?.RawPath ?? attributeRef;
                var writeIdempotent = entry?.WriteIdempotent ?? false;

                var info = new DriverAttributeInfo(
                    FullName: rawPath,
                    DriverDataType: DataTypeMap.Map(attr.MxDataType),
                    IsArray: attr.IsArray,
                    ArrayDim: attr.IsArray && attr.ArrayDimensionPresent && attr.ArrayDimension > 0
                        ? (uint)attr.ArrayDimension
                        : null,
                    SecurityClass: SecurityMap.Map(attr.SecurityClassification),
                    IsHistorized: attr.IsHistorized,
                    IsAlarm: attr.IsAlarm,
                    WriteIdempotent: writeIdempotent);

                var handle = folder.Variable(attr.AttributeName, attr.AttributeName, info);

                // Alarm-bearing attributes ship the full sub-attribute ref set so the server's
                // AlarmConditionService can subscribe + ack-write without re-deriving the names. The
                // condition identity (SourceName / ConditionId) is the RawPath — matching the value
                // fan-out key — while the five live-state sub-refs dial off the Galaxy attributeRef.
                if (attr.IsAlarm)
                {
                    handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(rawPath, attributeRef));
                }
            }
        }
    }

    // Resolve (and memoise) the folder for a gobject, recursively creating its parent's folder
    // first so the result nests under it. Falls back to the driver root when the parent_gobject_id
    // is 0, self-referential, or absent from the returned set. Before nesting, the gobject's parent
    // chain is walked once (IsRootedTo) to ensure it terminates at a real root — if the chain ever
    // revisits a gobject (a parent-reference cycle), EVERY member of the cycle degrades to the root
    // rather than the outermost member nesting under another cycle member. Memoisation is keyed by
    // gobject_id and only applies to non-zero ids — an id-0 object always gets its own freshly-created
    // folder (it can be neither a parent target nor shared).
    private static IAddressSpaceBuilder EnsureFolder(
        GalaxyObject obj,
        IAddressSpaceBuilder root,
        IReadOnlyDictionary<int, GalaxyObject> byId,
        Dictionary<int, IAddressSpaceBuilder> folders)
    {
        if (obj.GobjectId != 0 && folders.TryGetValue(obj.GobjectId, out var existing)) return existing;

        var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName;

        IAddressSpaceBuilder parentBuilder = root;
        if (obj.ParentGobjectId != 0
            && obj.ParentGobjectId != obj.GobjectId
            && byId.TryGetValue(obj.ParentGobjectId, out var parentObj)
            && IsRootedTo(obj, byId))
        {
            parentBuilder = EnsureFolder(parentObj, root, byId, folders);
        }

        var folder = parentBuilder.Folder(browseName, browseName);
        if (obj.GobjectId != 0)
        {
            folders[obj.GobjectId] = folder;
        }
        return folder;
    }

    // Walk a gobject's parent chain to decide whether it can safely nest. Returns true when the chain
    // terminates at a real root (parent == 0, self-parent, or a parent absent from the returned set);
    // returns false when the walk revisits a gobject already on the chain — i.e. the gobject sits on a
    // parent-reference cycle, so it (and every other member) must degrade to the driver root instead of
    // nesting under another cycle member. The visited-set is seeded with the starting gobject so a
    // mutual cycle (A→B→A) is detected the moment the walk loops back to it.
    private static bool IsRootedTo(GalaxyObject obj, IReadOnlyDictionary<int, GalaxyObject> byId)
    {
        var visited = new HashSet<int> { obj.GobjectId };
        var current = obj;
        while (current.ParentGobjectId != 0
               && current.ParentGobjectId != current.GobjectId
               && byId.TryGetValue(current.ParentGobjectId, out var parent))
        {
            if (!visited.Add(current.ParentGobjectId)) return false; // revisited → cycle
            current = parent;
        }
        return true; // reached a terminal root (parent 0 / self / absent)
    }

    // PR 5.W workaround for mxaccessgw GalaxyRepository.cs:173-175 — the gateway's
    // SQL appends `[]` to array-typed `full_tag_reference` values, but MxAccess COM
    // `IInstance.AddItem` doesn't accept `[]`-suffixed addresses (so any downstream
    // Subscribe/Read/Write through the worker would fail with the suffixed form).
    // Strip defensively here so the parity matrix can run today; remove once the
    // gw fix (mxaccessgw/requirements-array-suffix-fix.md) lands.
    private static string StripArraySuffix(string fullReference) =>
        fullReference.EndsWith("[]", StringComparison.Ordinal)
            ? fullReference[..^2]
            : fullReference;
}
