using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;

/// <summary>
///     Materializes the canonical Unified Namespace browse tree for an Equipment-kind
///     <see cref="Configuration.Entities.Namespace"/> from the Config DB's
///     <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
///     address-space build per <see cref="IDriver"/> whose
///     <c>Namespace.Kind = Equipment</c>; SystemPlatform-kind namespaces (Galaxy) are
///     exempt per decision #120 and reach this walker only indirectly through
///     <see cref="ITagDiscovery.DiscoverAsync"/>.
/// </summary>
/// <remarks>
///     <para>
///         <b>Composition strategy.</b> ADR-001 (2026-04-20) accepted Option A — Config
///         primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
///         snapshot as the authoritative published surface. Every Equipment row becomes a
///         folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
///         Equipment (non-null <see cref="Tag.EquipmentId"/>) becomes a variable node under
///         it. Driver-discovered tags that have no Config-DB row are not added by this
///         walker — the ITagDiscovery path continues to exist for the SystemPlatform case +
///         for enrichment, but Equipment-kind composition is fully Tag-row-driven.
///     </para>
///
///     <para>
///         <b>Under each Equipment node.</b> Five identifier properties per decision #121
///         (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
///         <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
///         resolve equipment by whichever identifier they natively use without a sidecar.
///         <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
///         Identification sub-folder with the nine decision-#139 fields when at least one
///         is non-null; when all nine are null the sub-folder is omitted rather than
///         appearing empty.
///     </para>
///
///     <para>
///         <b>Address resolution.</b> Variable nodes carry the driver-side full reference
///         in <see cref="DriverAttributeInfo.FullName"/> copied from <c>Tag.TagConfig</c>
///         (the wire-level address JSON blob whose interpretation is driver-specific). At
///         runtime the dispatch layer routes Read/Write calls through the configured
///         capability invoker; an unreachable address surfaces as an OPC UA Bad status via
///         the natural driver-read failure path, NOT as a build-time reject. The ADR calls
///         this "BadNotFound placeholder" behavior — legible to operators via their Admin
///         UI + OPC UA client inspection of node status.
///     </para>
///
///     <para>
///         <b>Pure function.</b> This class has no dependency on the OPC UA SDK, no
///         Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
///         into the supplied <see cref="IAddressSpaceBuilder"/>. The server-side wiring
///         (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
///         PR alongside <c>NodeScopeResolver</c>'s Config-DB join.
///     </para>
/// </remarks>
public static class EquipmentNodeWalker
{
    /// <summary>
    ///     Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>.
    ///     The builder is scoped to the Equipment-kind namespace root; the walker emits
    ///     Area → Line → Equipment folders under it, then identifier properties + the
    ///     Identification sub-folder + variable nodes per bound Tag under each Equipment.
    /// </summary>
    /// <param name="namespaceBuilder">
    ///     The builder scoped to the Equipment-kind namespace root. Caller is responsible for
    ///     creating this (e.g. <c>rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)</c>).
    /// </param>
    /// <param name="content">Pre-loaded + pre-filtered rows for a single published generation.</param>
    public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
    {
        ArgumentNullException.ThrowIfNull(namespaceBuilder);
        ArgumentNullException.ThrowIfNull(content);

        // Group lines by area + equipment by line + tags by equipment up-front. Avoids an
        // O(N·M) re-scan at each UNS level on large fleets.
        var linesByArea = content.Lines
            .GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);

        var equipmentByLine = content.Equipment
            .GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);

        var tagsByEquipment = content.Tags
            .Where(t => !string.IsNullOrEmpty(t.EquipmentId))
            .GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);

        var virtualTagsByEquipment = (content.VirtualTags ?? [])
            .Where(v => v.Enabled)
            .GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);

        var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
            .Where(a => a.Enabled)
            .GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);

        foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
        {
            var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
            if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue;

            foreach (var line in areaLines)
            {
                var lineBuilder = areaBuilder.Folder(line.Name, line.Name);
                if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue;

                foreach (var equipment in lineEquipment)
                {
                    var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name);
                    AddIdentifierProperties(equipmentBuilder, equipment);
                    IdentificationFolderBuilder.Build(equipmentBuilder, equipment);

                    if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
                        foreach (var tag in equipmentTags)
                            AddTagVariable(equipmentBuilder, tag);

                    if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
                        foreach (var vtag in vTags)
                            AddVirtualTagVariable(equipmentBuilder, vtag);

                    if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
                        foreach (var alarm in alarms)
                            AddScriptedAlarmVariable(equipmentBuilder, alarm);
                }
            }
        }
    }

    /// <summary>
    ///     Adds the five operator-facing identifiers from decision #121 as OPC UA properties
    ///     on the Equipment node. EquipmentId + EquipmentUuid are always populated;
    ///     MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
    ///     the data model so they're skipped when null to avoid empty-string noise in the
    ///     browse tree.
    /// </summary>
    private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
    {
        equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId);
        equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString());
        equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode);
        if (!string.IsNullOrEmpty(equipment.ZTag))
            equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag);
        if (!string.IsNullOrEmpty(equipment.SAPID))
            equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID);
    }

    /// <summary>
    ///     Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
    ///     full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
    ///     JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
    ///     Unreachable-address behavior per ADR-001 Option A: the variable is created; the
    ///     driver's natural Read failure surfaces an OPC UA Bad status at runtime.
    /// </summary>
    private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
    {
        var attr = new DriverAttributeInfo(
            FullName: tag.TagConfig,
            DriverDataType: ParseDriverDataType(tag.DataType),
            IsArray: false,
            ArrayDim: null,
            SecurityClass: SecurityClassification.FreeAccess,
            IsHistorized: false);
        equipmentBuilder.Variable(tag.Name, tag.Name, attr);
    }

    /// <summary>
    ///     Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
    ///     name string, decision #138) into the enum value. Unknown names fall back to
    ///     <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
    ///     abort the whole walk; the underlying driver still sees the original TagConfig
    ///     address + can surface its own typed value via the OPC UA variant at read time.
    /// </summary>
    private static DriverDataType ParseDriverDataType(string raw) =>
        Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;

    /// <summary>
    ///     Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
    ///     variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
    ///     addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
    ///     the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
    ///     driver.
    /// </summary>
    private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
    {
        var attr = new DriverAttributeInfo(
            FullName: vtag.VirtualTagId,
            DriverDataType: ParseDriverDataType(vtag.DataType),
            IsArray: false,
            ArrayDim: null,
            SecurityClass: SecurityClassification.FreeAccess,
            IsHistorized: vtag.Historize,
            IsAlarm: false,
            WriteIdempotent: false,
            Source: NodeSourceKind.Virtual,
            VirtualTagId: vtag.VirtualTagId,
            ScriptedAlarmId: null);
        equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
    }

    /// <summary>
    ///     Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
    ///     variable node. The OPC UA Part 9 alarm-condition materialization happens at the
    ///     node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
    ///     per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
    ///     anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
    ///     materialization path.
    /// </summary>
    private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
    {
        var attr = new DriverAttributeInfo(
            FullName: alarm.ScriptedAlarmId,
            DriverDataType: DriverDataType.Boolean,
            IsArray: false,
            ArrayDim: null,
            SecurityClass: SecurityClassification.FreeAccess,
            IsHistorized: false,
            IsAlarm: true,
            WriteIdempotent: false,
            Source: NodeSourceKind.ScriptedAlarm,
            VirtualTagId: null,
            ScriptedAlarmId: alarm.ScriptedAlarmId);
        equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
    }
}

/// <summary>
///     Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
///     DB rows. All four collections are scoped to the same
///     <see cref="Configuration.Entities.ConfigGeneration"/> + the same
///     <see cref="Configuration.Entities.Namespace"/> row. The walker assumes this filter
///     was applied by the caller + does no cross-generation or cross-namespace validation.
/// </summary>
public sealed record EquipmentNamespaceContent(
    IReadOnlyList<UnsArea> Areas,
    IReadOnlyList<UnsLine> Lines,
    IReadOnlyList<Equipment> Equipment,
    IReadOnlyList<Tag> Tags,
    IReadOnlyList<VirtualTag>? VirtualTags = null,
    IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);
