using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;

/// <summary>
///     v3 Batch 4 (B4-WP4) — the native-alarm <b>multi-notifier</b> wiring +
///     <b>teardown symmetry</b> on <see cref="OtOpcUaNodeManager"/>. A native alarm is a SINGLE Part 9
///     condition materialised once at its raw tag (ConditionId = RawPath, Raw realm);
///     <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> wires that one condition as an event notifier of
///     EACH referencing equipment's UNS folder so one <c>ReportEvent</c> fans one event to every referencing
///     equipment (never re-reported per root). The obligation these tests lock in: the wiring is idempotent,
///     a missing folder is a safe skip, and every teardown path (condition-removal / equipment-subtree-removal
///     / full rebuild + re-wire) removes the notifier pair BIDIRECTIONALLY so no inverse-notifier entry leaks
///     across redeploys (exactly N notifiers after a re-trip, not 2N).
/// </summary>
public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable
{
    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    private const string RawAlarm = "Plant/Modbus/dev1/temp_hi";
    private const string RawDeviceFolder = "Plant/Modbus/dev1";
    private const string Equip1 = "EQ-filling-line1-station1";
    private const string Equip2 = "EQ-filling-line2-station2";

    private readonly string _pkiRoot = Path.Combine(
        Path.GetTempPath(),
        $"otopcua-multi-notifier-{Guid.NewGuid():N}");

    /// <summary>Materialise the raw device folder + the single native condition + two equipment folders, then
    /// wire the condition as a notifier of both. Returns the node manager.</summary>
    private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootWithTwoEquipmentAsync()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        // Raw device folder (the condition's HasComponent parent) + the single native condition at the RawPath.
        nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
        nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
        // Two referencing equipment folders (UNS realm).
        nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
        nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
        return (host, nm);
    }

    /// <summary>The single condition is wired as a notifier of EACH referencing equipment folder: the condition
    /// carries one inverse-notifier entry per folder, each folder carries the inverse back to the condition, and
    /// each folder becomes a root (Server-object) event notifier.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task WireAlarmNotifiers_wires_the_single_condition_to_each_equipment_folder()
    {
        var (host, nm) = await BootWithTwoEquipmentAsync();

        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);

        // One inverse-notifier entry per equipment folder on the single condition.
        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
        // Each equipment folder holds the inverse entry back to the condition + is a root notifier.
        nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
        nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
        nm.IsRootNotifier(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
        nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();

        await host.DisposeAsync();
    }

    /// <summary>Re-wiring the SAME pairs (an idempotent re-apply of the raw subtree pass) does not duplicate the
    /// notifier entries — the SDK dedups by node reference and the tracking dedups its list.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task WireAlarmNotifiers_is_idempotent_no_duplicate_on_re_wire()
    {
        var (host, nm) = await BootWithTwoEquipmentAsync();

        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);

        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
        nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
        nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);

        await host.DisposeAsync();
    }

    /// <summary>A referencing equipment folder that is not (yet) materialised is skipped (no throw); the present
    /// folders are still wired. A missing condition is likewise a no-op.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task WireAlarmNotifiers_missing_folder_or_condition_is_a_safe_skip()
    {
        var (host, nm) = await BootWithTwoEquipmentAsync();

        // "EQ-missing" was never materialised — it is skipped; Equip1 is still wired.
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, "EQ-missing" }, AddressSpaceRealm.Uns);
        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
        nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);

        // An unmaterialised condition id is a no-op (never throws).
        Should.NotThrow(() =>
            nm.WireAlarmNotifiers("Plant/Modbus/dev1/does_not_exist", AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns));

        await host.DisposeAsync();
    }

    /// <summary>Removing the condition in place (surgical raw-alarm-tag removal) tears the notifier pairs down
    /// BIDIRECTIONALLY: the surviving equipment folders lose their inverse-notifier entry back to the removed
    /// condition (no dangling reference).</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task RemoveAlarmConditionNode_unwires_notifiers_from_surviving_folders()
    {
        var (host, nm) = await BootWithTwoEquipmentAsync();
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
        nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);

        nm.RemoveAlarmConditionNode(RawAlarm, AddressSpaceRealm.Raw).ShouldBeTrue();

        // Condition gone; the surviving equipment folders no longer reference it (bidirectional teardown).
        nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
        nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(0);
        nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0);

        await host.DisposeAsync();
    }

    /// <summary>Removing one referencing equipment's subtree unwires ONLY that folder from the surviving raw
    /// condition (which lives in the Raw realm and is untouched by a UNS subtree removal): the condition drops
    /// exactly one notifier and keeps the other.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task RemoveEquipmentSubtree_unwires_only_that_folder_from_surviving_condition()
    {
        var (host, nm) = await BootWithTwoEquipmentAsync();
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);

        nm.RemoveEquipmentSubtree(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();

        // The raw condition SURVIVES (Raw realm) and now notifies only the remaining equipment folder.
        nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
        nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
        nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();

        await host.DisposeAsync();
    }

    /// <summary>Teardown-symmetry / "exactly one copy after a re-trip": a full rebuild + re-materialise +
    /// re-wire leaves EXACTLY the same notifier count (2), not a doubled/leaked set — the rebuild unwired the
    /// prior notifier pairs bidirectionally before dropping the nodes.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Rebuild_then_rewire_has_no_leaked_notifier_duplicates()
    {
        var (host, nm) = await BootWithTwoEquipmentAsync();
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);

        // Redeploy (the full-rebuild path).
        nm.RebuildAddressSpace();
        nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();

        // Re-materialise the whole thing + re-wire (what the applier does every apply).
        nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
        nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
        nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
        nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);

        // Exactly 2 again — no leaked inverse-notifier entries carried across the rebuild.
        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
        nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
        nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);

        await host.DisposeAsync();
    }

    /// <summary>Wave C review M3 — <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> is a RECONCILE: re-wiring
    /// with a SHRUNK folder set unwires (bidirectionally) the dropped equipment folder, so a de-referenced
    /// equipment stops receiving the alarm. (Guards a future surgical ChangedRawTags path; today the shrink
    /// arrives as a full rebuild.)</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set()
    {
        var (host, nm) = await BootWithTwoEquipmentAsync();
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);

        // Re-wire with only Equip1 (Equip2 de-referenced) — reconcile unwires Equip2 bidirectionally.
        nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);

        nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
        nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
        nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0); // no dangling inverse to the condition

        await host.DisposeAsync();
    }

    private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
    {
        var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.MultiNotifierTest",
                ApplicationUri = $"urn:OtOpcUa.MultiNotifierTest:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);

        var server = new OtOpcUaSdkServer();
        await host.StartAsync(server, Ct);
        return (host, server);
    }

    private static int AllocateFreePort()
    {
        using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
        listener.Start();
        var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }

    /// <summary>Cleans up the PKI root directory.</summary>
    public void Dispose()
    {
        if (Directory.Exists(_pkiRoot))
        {
            try { Directory.Delete(_pkiRoot, recursive: true); }
            catch { /* best-effort cleanup */ }
        }
    }
}
