using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;

/// <summary>The mapped result of grafting discovered nodes under an equipment node.</summary>
/// <param name="Folders">
///     Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT
///     globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a
///     global parent-before-child ordering across the whole list.
/// </param>
/// <param name="Variables">Variables to ensure under the (post-collapse) folders.</param>
/// <param name="RoutingByRef">Driver FullReference -> equipment NodeId, for live-value routing.</param>
public sealed record DiscoveredInjectionPlan(
    IReadOnlyList<DiscoveredFolder> Folders,
    IReadOnlyList<DiscoveredVariable> Variables,
    IReadOnlyDictionary<string, string> RoutingByRef);   // driver FullReference -> equipment NodeId

/// <summary>
///     Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
///     authored Config-DB refs and collapsing the single device-host folder. See the design doc
///     2026-06-26-otopcua-fixedtree-equipment-injection-design.md.
/// </summary>
public static class DiscoveredNodeMapper
{
    /// <summary>
    ///     Maps captured <paramref name="nodes"/> into folders + variables (NodeIds scoped under
    ///     <paramref name="equipmentId"/>) plus a driver-FullReference → equipment-NodeId routing map.
    /// </summary>
    /// <param name="equipmentId">The owning equipment's NodeId (root of the grafted subtree).</param>
    /// <param name="nodes">The captured discovery tree (from <c>CapturingAddressSpaceBuilder</c>).</param>
    /// <param name="authoredRefs">
    ///     Driver FullReferences already authored as Config-DB equipment tags for this driver —
    ///     skipped so a discovered node never shadows an authored one.
    /// </param>
    /// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
    public static DiscoveredInjectionPlan Map(
        string equipmentId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
    {
        var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();

        // Device-folder collapse: when every kept node shares one identical index-1 segment (the single
        // device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads
        // FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the
        // level is retained so identical leaf names across devices don't collide (degrades gracefully).
        var collapseIndex1 = kept.Count > 0
            && kept.All(n => n.FolderPathSegments.Count >= 2)
            && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;

        static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
            => collapse ? [segs[0], .. segs.Skip(2)] : segs;

        var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
        var variables = new List<DiscoveredVariable>();
        var routing = new Dictionary<string, string>(StringComparer.Ordinal);

        foreach (var n in kept)
        {
            var segs = Effective(n.FolderPathSegments, collapseIndex1);

            // Ensure every prefix folder, deduped, each parented at its prefix (the first segment's
            // parent is the equipment itself).
            for (var i = 0; i < segs.Count; i++)
            {
                var folderPath = string.Join('/', segs.Take(i + 1));
                var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
                if (folders.ContainsKey(nodeId)) continue;
                var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
                folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
            }

            var varFolderPath = string.Join('/', segs);
            var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
            // Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly
            // at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the
            // EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace).
            var varParent = string.IsNullOrEmpty(varFolderPath)
                ? equipmentId
                : EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
            variables.Add(new DiscoveredVariable(
                varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
            routing[n.FullReference] = varNodeId;
        }

        return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
    }

    /// <summary>
    ///     Maps a <see cref="DriverDataType"/> to the OPC-UA-built-in type STRING that
    ///     <c>OtOpcUaNodeManager.EnsureVariable</c>'s <c>ResolveBuiltInDataType</c> accepts — so a
    ///     discovered variable resolves to the same built-in type as an authored equipment tag. Most
    ///     enum names pass through verbatim; <see cref="DriverDataType.Float32"/>/<see cref="DriverDataType.Float64"/>
    ///     map to the SDK's "Float"/"Double" names, and <see cref="DriverDataType.Reference"/> (a Galaxy
    ///     attribute reference) is carried as an OPC UA String per the enum's own contract.
    /// </summary>
    private static string ToBuiltinTypeString(DriverDataType dt) => dt switch
    {
        DriverDataType.Boolean => "Boolean",
        DriverDataType.Int16 => "Int16",
        DriverDataType.Int32 => "Int32",
        DriverDataType.Int64 => "Int64",
        DriverDataType.UInt16 => "UInt16",
        DriverDataType.UInt32 => "UInt32",
        DriverDataType.UInt64 => "UInt64",
        DriverDataType.Float32 => "Float",
        DriverDataType.Float64 => "Double",
        DriverDataType.String => "String",
        DriverDataType.DateTime => "DateTime",
        DriverDataType.Reference => "String",
        _ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."),
    };
}
