using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
///     Side-effecting orchestrator over <see cref="Phase7Plan"/>. Drives an
///     <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
///     <see cref="Phase7CompositionResult"/> snapshots:
///
///     <list type="bullet">
///         <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
///             node id then call <c>RebuildAddressSpace</c> at the end so the sink can
///             actually tear down the OPC UA folders + variables.</item>
///         <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
///             will repopulate from the persisted artifact). For now we record the work.</item>
///         <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
///             that lands in F10b will decide between in-place property writes and
///             tear-down + rebuild.</item>
///     </list>
///
///     This is the side-effecting layer Task 47 deferred to F14. It stays pure-of-SDK so
///     production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
///     and tests can capture every call.
/// </summary>
public sealed class Phase7Applier
{
    private readonly IOpcUaAddressSpaceSink _sink;
    private readonly ILogger<Phase7Applier> _logger;

    /// <summary>Initializes a new instance of the Phase7Applier class.</summary>
    /// <param name="sink">The OPC UA address space sink to apply changes to.</param>
    /// <param name="logger">The logger instance.</param>
    public Phase7Applier(IOpcUaAddressSpaceSink sink, ILogger<Phase7Applier> logger)
    {
        ArgumentNullException.ThrowIfNull(sink);
        ArgumentNullException.ThrowIfNull(logger);
        _sink = sink;
        _logger = logger;
    }

    /// <summary>
    ///     Apply <paramref name="plan"/> to the sink. Returns a summary of what was applied so
    ///     callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
    /// </summary>
    /// <param name="plan">The plan to apply.</param>
    /// <returns>A Phase7ApplyOutcome summarizing the applied changes.</returns>
    public Phase7ApplyOutcome Apply(Phase7Plan plan)
    {
        ArgumentNullException.ThrowIfNull(plan);

        if (plan.IsEmpty)
        {
            _logger.LogDebug("Phase7Applier: plan is empty; skipping sink writes");
            return new Phase7ApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false);
        }

        var ts = DateTime.UtcNow;
        var removedCount = 0;
        foreach (var eq in plan.RemovedEquipment)
        {
            SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts);
            removedCount++;
        }
        foreach (var alarm in plan.RemovedAlarms)
        {
            SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts);
            removedCount++;
        }

        var changedCount =
            plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
            plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count +
            plan.ChangedEquipmentVirtualTags.Count;
        var addedCount =
            plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
            plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count +
            plan.AddedEquipmentVirtualTags.Count;

        // Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, Equipment tag, or Equipment
        // VirtualTag topology requires a real address-space rebuild. Driver-instance changes don't
        // touch the address-space topology directly — they go through DriverHostActor's spawn-plan
        // in Runtime.
        // TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
        var needsRebuild =
            plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
            plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
            plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0 ||
            plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
            plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;

        if (needsRebuild)
        {
            try
            {
                _sink.RebuildAddressSpace();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw");
            }
        }

        _logger.LogInformation(
            "Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
            addedCount, removedCount, changedCount, needsRebuild);

        return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
    }

    /// <summary>
    ///     #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
    ///     composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
    ///     clients browsing the server see proper folder structure instead of flat tag ids.
    ///     Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
    ///     present, so re-applies are cheap.
    /// </summary>
    /// <param name="composition">The composition result containing the hierarchy to materialise.</param>
    public void MaterialiseHierarchy(Phase7CompositionResult composition)
    {
        ArgumentNullException.ThrowIfNull(composition);

        foreach (var area in composition.UnsAreas)
        {
            SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName);
        }
        foreach (var line in composition.UnsLines)
        {
            SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName);
        }
        foreach (var equipment in composition.EquipmentNodes)
        {
            // Equipment with no UnsLineId (legacy / dev rows) hang under the root.
            var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
            SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName);
        }

        _logger.LogInformation(
            "Phase7Applier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})",
            composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
    }

    /// <summary>
    ///     Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot:
    ///     for each <see cref="GalaxyTagPlan"/>, ensure its FolderPath segment exists (a folder
    ///     under the namespace root), then ensure a Variable node sits inside that folder for
    ///     the leaf <see cref="GalaxyTagPlan.DisplayName"/>. Variable starts with BadWaitingForInitialData;
    ///     the Galaxy driver's <c>OnDataChange</c> path fills the value in once SubscribeBulk lands.
    ///     Idempotent.
    /// </summary>
    /// <param name="composition">The composition result containing the Galaxy tags to materialise.</param>
    public void MaterialiseGalaxyTags(Phase7CompositionResult composition)
    {
        ArgumentNullException.ThrowIfNull(composition);
        if (composition.GalaxyTags.Count == 0) return;

        // Folders first — each distinct FolderPath becomes one folder under the root.
        var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
        foreach (var tag in composition.GalaxyTags)
        {
            if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
            if (!foldersCreated.Add(tag.FolderPath)) continue;
            SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath);
        }

        // Variables: NodeId is "<FolderPath>.<DisplayName>" so it matches the MXAccess ref the
        // Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical
        // resolution; flat NodeId keeps the address space lookup cheap.
        foreach (var tag in composition.GalaxyTags)
        {
            var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef;
            var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath;
            SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType);
        }

        _logger.LogInformation(
            "Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})",
            composition.GalaxyTags.Count, foldersCreated.Count);
    }

    /// <summary>
    ///     Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal
    ///     analogue of <see cref="MaterialiseGalaxyTags"/>. For each <see cref="EquipmentTagPlan"/>,
    ///     ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
    ///     ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
    ///     start BadWaitingForInitialData; the driver fills live values in a later milestone.
    ///     Idempotent.
    ///     <para>
    ///         <b>Task 0 architecture decisions (recorded per the equipment-namespace-structure
    ///         plan).</b> Decision #1 = <b>A</b> — a sink-based pass, NOT a reuse of
    ///         <c>EquipmentNodeWalker</c>: no sink-backed <c>IAddressSpaceBuilder</c> adapter exists
    ///         (<c>GenericDriverNodeManager.CapturingBuilder</c> decorates another builder, not the
    ///         sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path
    ///         NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the
    ///         already-materialised equipment folders (decision #4). Decision #4 = this pass adds
    ///         ONLY variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns
    ///         the equipment folders and this pass never re-creates them. The sink's
    ///         <c>EnsureVariable</c> takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
    ///     </para>
    /// </summary>
    /// <param name="composition">The composition result containing the equipment tags to materialise.</param>
    public void MaterialiseEquipmentTags(Phase7CompositionResult composition)
    {
        ArgumentNullException.ThrowIfNull(composition);
        if (composition.EquipmentTags.Count == 0) return;

        // Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
        // (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
        // equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create
        // the equipment folder here).
        var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
        foreach (var tag in composition.EquipmentTags)
        {
            if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
            var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
            if (!foldersCreated.Add(folderNodeId)) continue;
            SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
        }

        // Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
        // ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
        // would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
        // signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
        // route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
        // Like the Galaxy pass, per-variable idempotency relies on the sink's own EnsureVariable.
        foreach (var tag in composition.EquipmentTags)
        {
            var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
                ? tag.EquipmentId
                : EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
            var nodeId = $"{parent}/{tag.Name}";
            SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType);
        }

        _logger.LogInformation(
            "Phase7Applier: equipment tags materialised (tags={Tags}, equipment={Equipment})",
            composition.EquipmentTags.Count,
            composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count());
    }

    /// <summary>
    ///     Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
    ///     analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
    ///     ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
    ///     practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
    ///     a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
    ///     (<c>parent/Name</c>) — NOT the <see cref="EquipmentVirtualTagPlan.VirtualTagId"/> or
    ///     <see cref="EquipmentVirtualTagPlan.Expression"/> — so identically-named VirtualTags on
    ///     different equipments never collide in the sink (which keys on NodeId). Variables start
    ///     BadWaitingForInitialData; <c>VirtualTagActor</c> fills live values in a later milestone.
    ///     Idempotent (per-variable idempotency relies on the sink's own <c>EnsureVariable</c>).
    /// </summary>
    /// <param name="composition">The composition result containing the equipment VirtualTags to materialise.</param>
    public void MaterialiseEquipmentVirtualTags(Phase7CompositionResult composition)
    {
        ArgumentNullException.ThrowIfNull(composition);
        if (composition.EquipmentVirtualTags.Count == 0) return;

        // Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder
        // (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the
        // equipment folder, which MaterialiseHierarchy already created (never re-create it here).
        var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
        foreach (var v in composition.EquipmentVirtualTags)
        {
            if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
            var folderNodeId = EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
            if (!foldersCreated.Add(folderNodeId)) continue;
            SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath);
        }

        // Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
        // Parent is the FolderPath sub-folder when set, else the equipment folder directly.
        foreach (var v in composition.EquipmentVirtualTags)
        {
            var parent = string.IsNullOrWhiteSpace(v.FolderPath)
                ? v.EquipmentId
                : EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
            var nodeId = $"{parent}/{v.Name}";
            SafeEnsureVariable(nodeId, parent, v.Name, v.DataType);
        }

        _logger.LogInformation(
            "Phase7Applier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment})",
            composition.EquipmentVirtualTags.Count,
            composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count());
    }

    /// <summary>
    ///     T14 — materialise real OPC UA Part 9 <c>AlarmConditionState</c> nodes from a composition
    ///     snapshot. For each <b>enabled</b> <see cref="EquipmentScriptedAlarmPlan"/>, register a
    ///     condition node (keyed by its <see cref="EquipmentScriptedAlarmPlan.ScriptedAlarmId"/>, which
    ///     is the same id <c>OpcUaPublishActor.AlarmStateUpdate</c> targets) under its equipment folder.
    ///     Disabled alarms are skipped — they expose no node. Must run AFTER
    ///     <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
    ///     <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
    /// </summary>
    /// <param name="composition">The composition result containing the scripted alarms to materialise.</param>
    public void MaterialiseScriptedAlarms(Phase7CompositionResult composition)
    {
        ArgumentNullException.ThrowIfNull(composition);
        if (composition.EquipmentScriptedAlarms.Count == 0) return;

        var materialised = 0;
        foreach (var alarm in composition.EquipmentScriptedAlarms)
        {
            if (!alarm.Enabled) continue;
            SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity);
            materialised++;
        }

        _logger.LogInformation(
            "Phase7Applier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment})",
            materialised,
            composition.EquipmentScriptedAlarms.Where(a => a.Enabled)
                .Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count());
    }

    /// <summary>Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
    /// folder so two equipments' identically-named sub-folders never collide.</summary>
    private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
        $"{equipmentId}/{folderPath}";

    private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
    {
        try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
        catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
    }

    private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType)
    {
        try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType); }
        catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
    }

    /// <summary>The "no-event" condition state written to a removed equipment / alarm node before the
    /// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
    /// Drives Retain to false so a removed condition stops replaying on ConditionRefresh.</summary>
    private static readonly AlarmConditionSnapshot RemovedConditionState = new(
        Active: false,
        Acknowledged: true,
        Confirmed: true,
        Enabled: true,
        Shelving: AlarmShelvingKind.Unshelved,
        Severity: 0,
        Message: string.Empty);

    private void SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
    {
        try { _sink.WriteAlarmCondition(nodeId, state, ts); }
        catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmCondition threw for {Node}", nodeId); }
    }

    private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
    {
        try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); }
        catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); }
    }
}

/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.</summary>
public sealed record Phase7ApplyOutcome(
    int RemovedNodes,
    int AddedNodes,
    int ChangedNodes,
    bool RebuildCalled);
