using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

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

/// <summary>
///     Issue #473 — every materialised Part 9 condition must carry the mandatory <c>BaseEventType</c>
///     identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. The SDK does NOT synthesise
///     them on this path: <see cref="NodeState.Create"/> initialises from the type's embedded definition,
///     which declares all three as mandatory children with NO default value, and the auto-filling
///     <see cref="BaseEventState.Initialize(ISystemContext, NodeState, EventSeverity, LocalizedText)"/>
///     overload (which would set SourceNode/SourceName from a source node) is only used for transient
///     events. <c>ReportEvent</c> and <c>InstanceStateSnapshot</c> copy the children verbatim and synthesise
///     nothing — so a field left null at materialise arrives null on the wire on EVERY condition event.
///     <para>
///         The contract locked in here (see <c>docs/AlarmTracking.md</c>): the condition node IS the source
///         node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so
///         <c>SourceNode</c> self-references the condition's own NodeId and <c>SourceName</c> carries the
///         same identifying id string (<c>alarmNodeId</c>: the RawPath for native, the ScriptedAlarmId for
///         scripted), matching <c>ConditionId</c>. The leaf/display name stays on <c>ConditionName</c>, so
///         no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
///     </para>
///     <para>
///         Issue #475 — the same mechanism leaves the mandatory <c>ConditionType</c> classification fields
///         <c>ConditionClassId</c> / <c>ConditionClassName</c> unset. The contract locked in here: a server that
///         does not model condition classes must still report <c>BaseConditionClassType</c> (Part 9's
///         "no class modelled" value) rather than null. See <c>docs/AlarmTracking.md</c>.
///     </para>
/// </summary>
public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
{
    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    private const string RawDeviceFolder = "Plant/Modbus/dev1";
    private const string RawAlarmPath = "Plant/Modbus/dev1/HR200";

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

    /// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
    /// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
    /// remains on ConditionName so the short name is still available to clients.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Native_condition_carries_EventType_SourceNode_and_SourceName()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;
        var rawNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw);

        nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
        nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Raw, isNative: true);

        var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
        condition.ShouldNotBeNull();

        // EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
        // where-clause) can resolve the type.
        condition.EventType.ShouldNotBeNull();
        condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);

        // SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
        // for an alarm-bearing raw tag). Equal to ConditionId.
        condition.SourceNode.ShouldNotBeNull();
        condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));

        // SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
        condition.SourceName.ShouldNotBeNull();
        condition.SourceName.Value.ShouldBe(RawAlarmPath);

        // The leaf name is NOT lost — it stays on ConditionName.
        condition.ConditionName!.Value.ShouldBe("HR200");

    }

    /// <summary>A SCRIPTED condition (UNS realm, ConditionId = ScriptedAlarmId) follows the SAME uniform rule:
    /// SourceNode = its own NodeId, SourceName = the ScriptedAlarmId. Scripted and native conditions share the
    /// materialise path, so neither may regress independently.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Scripted_condition_carries_EventType_SourceNode_and_SourceName()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;
        var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);

        nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Station 1", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("tank-overflow", "eq-1", "Tank Overflow", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Uns, isNative: false);

        var condition = nm.TryGetAlarmCondition("tank-overflow", AddressSpaceRealm.Uns);
        condition.ShouldNotBeNull();

        condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
        condition.SourceNode!.Value.ShouldBe(new NodeId("tank-overflow", unsNs));
        condition.SourceName!.Value.ShouldBe("tank-overflow");
        // The human-readable name stays on ConditionName.
        condition.ConditionName!.Value.ShouldBe("Tank Overflow");

    }

    /// <summary>EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type
    /// falls back to the base <see cref="AlarmConditionState"/> and its EventType must report that base type
    /// rather than a subtype the node does not have.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task EventType_reflects_the_fallback_base_type_for_an_unknown_alarm_type()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Station 2", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-x", "eq-2", "Generic", "LimitAlarm", 500,
            realm: AddressSpaceRealm.Uns, isNative: false);

        var condition = nm.TryGetAlarmCondition("alm-x", AddressSpaceRealm.Uns);
        condition.ShouldNotBeNull();
        condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base-type fallback
        condition.EventType!.Value.ShouldBe(ObjectTypeIds.AlarmConditionType);

    }

    /// <summary>The identity fields survive a re-materialise that DROPS and re-creates the node (the native↔scripted
    /// kind-swap path), so a redeploy can never leave a condition emitting null identity.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Identity_fields_survive_a_kind_swap_rematerialise()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;
        var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);

        nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Station 3", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Uns, isNative: false);
        // Kind swap (scripted → native) drops the prior instance and re-creates it.
        nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Uns, isNative: true);

        var condition = nm.TryGetAlarmCondition("alm-swap", AddressSpaceRealm.Uns);
        condition.ShouldNotBeNull();
        condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
        condition.SourceNode!.Value.ShouldBe(new NodeId("alm-swap", unsNs));
        condition.SourceName!.Value.ShouldBe("alm-swap");

    }

    /// <summary>#475 — a NATIVE condition (Raw realm) carries the mandatory ConditionType classification fields.
    /// We do not model condition classes, so Part 9's "no class modelled" value — BaseConditionClassType — is the
    /// conformant answer; the point is that it is a resolvable class NodeId a client can bucket on rather than a
    /// null that drops the alarm into an unclassified bin.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Native_condition_carries_ConditionClassId_and_ConditionClassName()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
        nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Raw, isNative: true);

        var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
        condition.ShouldNotBeNull();

        condition.ConditionClassId.ShouldNotBeNull();
        condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);

        condition.ConditionClassName.ShouldNotBeNull();
        condition.ConditionClassName.Value.ShouldNotBeNull();
        condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass");
    }

    /// <summary>#475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted
    /// share the materialise path, so neither may regress independently.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Scripted_condition_carries_ConditionClassId_and_ConditionClassName()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        nm.EnsureFolder("eq-4", parentNodeId: null, displayName: "Station 4", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("tank-dry", "eq-4", "Tank Dry", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Uns, isNative: false);

        var condition = nm.TryGetAlarmCondition("tank-dry", AddressSpaceRealm.Uns);
        condition.ShouldNotBeNull();

        condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
        condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
    }

    /// <summary>#477 — a freshly materialised NATIVE condition reports BadWaitingForInitialData quality (not the
    /// accidentally-Good default), matching the value-variable "no driver data yet" convention. Its quality only
    /// becomes Good once its driver's connectivity confirms it (Layer 2).</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Native_condition_materialises_with_waiting_for_initial_data_quality()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
        nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Raw, isNative: true);

        var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
        condition.ShouldNotBeNull();
        condition.Quality.ShouldNotBeNull();
        condition.Quality.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
        StatusCode.IsGood(condition.Quality.Value).ShouldBeFalse();
    }

    /// <summary>#477 — a SCRIPTED condition is script-computed and always "live" in v1, so it materialises Good.
    /// (Scripted worst-of-input quality is deferred to Layer 3.)</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task Scripted_condition_materialises_with_good_quality()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        nm.EnsureFolder("eq-q1", parentNodeId: null, displayName: "Station Q1", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("scripted-q", "eq-q1", "Scripted Q", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Uns, isNative: false);

        var condition = nm.TryGetAlarmCondition("scripted-q", AddressSpaceRealm.Uns);
        condition.ShouldNotBeNull();
        condition.Quality.ShouldNotBeNull();
        StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
    }

    /// <summary>#477 — WriteAlarmCondition projects the snapshot's Quality onto the live condition node, so a
    /// Bad-quality snapshot (comms-lost source) makes the condition report non-Good.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task WriteAlarmCondition_projects_snapshot_quality_onto_the_condition()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
        nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Raw, isNative: true);

        // A comms-lost snapshot: the alarm state is unchanged but Quality is Bad.
        var badSnapshot = new AlarmConditionSnapshot(
            Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
            Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "HR200",
            Quality: OpcUaQuality.Bad);
        nm.WriteAlarmCondition(RawAlarmPath, badSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);

        var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
        condition.ShouldNotBeNull();
        StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();

        // Recover: a Good snapshot restores Good quality.
        var goodSnapshot = badSnapshot with { Quality = OpcUaQuality.Good };
        nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
        StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
    }

    /// <summary>#477 Layer 2 — the dedicated connectivity-quality path sets ONLY the condition's Quality
    /// (comms lost → Bad, restored → Good) and never clobbers the node's severity / message / alarm state:
    /// it is a pure annotation applied out-of-band from any alarm transition.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task WriteAlarmQuality_sets_only_quality_without_touching_state_or_severity()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
        nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
            realm: AddressSpaceRealm.Raw, isNative: true);

        var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
        condition.ShouldNotBeNull();
        var severityBefore = condition.Severity!.Value;
        var messageBefore = condition.Message!.Value?.Text;

        // Device comes online → Good.
        nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
        StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();

        // Device goes comms-lost → Bad, but severity / message / active-ack state are untouched.
        nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
        StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
        condition.Severity.Value.ShouldBe(severityBefore);
        condition.Message.Value?.Text.ShouldBe(messageBefore);
        condition.ActiveState!.Id!.Value.ShouldBeFalse(); // unchanged: annotation only
    }

    /// <summary>#477 Layer 2 — WriteAlarmQuality on an unknown / unmaterialised node is a safe no-op (never
    /// throws), mirroring the other write paths: a mid-rebuild race must not fault a connectivity update.</summary>
    [Trait("Category", "Unit")]
    [Fact]
    public async Task WriteAlarmQuality_is_a_noop_for_an_unknown_condition()
    {
        await using var host = await BootAsync();
        var nm = host.Nm;

        Should.NotThrow(() =>
            nm.WriteAlarmQuality("no/such/node", OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
    }

    /// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
    /// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
    private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
    {
        public OtOpcUaNodeManager Nm { get; } = nm;
        public ValueTask DisposeAsync() => host.DisposeAsync();
    }

    private async Task<BootedServer> BootAsync()
    {
        var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.AlarmSourceFieldsTest",
                ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            NullLogger<OpcUaApplicationHost>.Instance);

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

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

    public void Dispose()
    {
        if (Directory.Exists(_pkiRoot))
        {
            try { Directory.Delete(_pkiRoot, recursive: true); }
            catch { /* best-effort */ }
        }
    }
}
