using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;

/// <summary>
///     Pins GalaxyDriver's <c>IAlarmSource</c> implementation. The driver bridges the
///     gateway's session-less alarm feed (<see cref="IGalaxyAlarmFeed"/>, production:
///     <c>GatewayGalaxyAlarmFeed</c>) onto <c>IAlarmSource.OnAlarmEvent</c> and forwards
///     Acknowledge through <see cref="IGalaxyAlarmAcknowledger"/> (production:
///     <c>GatewayGalaxyAlarmAcknowledger</c> calling the session-less
///     <c>AcknowledgeAlarm</c> RPC).
/// </summary>
public sealed class GalaxyDriverAlarmSourceTests
{
    /// <summary>Verifies that SubscribeAlarmsAsync starts the alarm feed and events fire on transition.</summary>
    [Fact]
    public async Task SubscribeAlarmsAsync_starts_feed_and_event_fires_on_transition()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
        handle.ShouldNotBeNull();
        feed.Started.ShouldBeTrue("SubscribeAlarmsAsync must start the alarm feed");

        var observed = new List<AlarmEventArgs>();
        driver.OnAlarmEvent += (_, args) => observed.Add(args);

        feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
            GalaxyAlarmTransitionKind.Raise, AlarmSeverity.Critical));

        observed.ShouldHaveSingleItem();
        observed[0].ConditionId.ShouldBe("Tank01.Level.HiHi");
        observed[0].SourceNodeId.ShouldBe("Tank01");
        observed[0].AlarmType.ShouldBe("AnalogLimitAlarm.HiHi");
        observed[0].Severity.ShouldBe(AlarmSeverity.Critical);
        observed[0].SubscriptionHandle.ShouldBe(handle);
    }

    /// <summary>
    ///     Verifies that the driver maps each Galaxy transition kind onto the matching
    ///     <see cref="AlarmTransitionKind"/> on the surfaced <see cref="AlarmEventArgs"/>,
    ///     so a Part 9 consumer can derive active/ack state. The Galaxy kind is passed by
    ///     name because <c>GalaxyAlarmTransitionKind</c> is internal to the driver and so
    ///     cannot appear in a public test method signature.
    /// </summary>
    /// <param name="galaxyKindName">The <c>GalaxyAlarmTransitionKind</c> member name fed into the alarm feed.</param>
    /// <param name="expectedKind">The expected <see cref="AlarmTransitionKind"/> on the surfaced event.</param>
    [Theory]
    [InlineData(nameof(GalaxyAlarmTransitionKind.Raise), AlarmTransitionKind.Raise)]
    [InlineData(nameof(GalaxyAlarmTransitionKind.Acknowledge), AlarmTransitionKind.Acknowledge)]
    [InlineData(nameof(GalaxyAlarmTransitionKind.Clear), AlarmTransitionKind.Clear)]
    [InlineData(nameof(GalaxyAlarmTransitionKind.Retrigger), AlarmTransitionKind.Retrigger)]
    [InlineData(nameof(GalaxyAlarmTransitionKind.Unspecified), AlarmTransitionKind.Unspecified)]
    public async Task Transition_kind_maps_onto_AlarmEventArgs_Kind(
        string galaxyKindName, AlarmTransitionKind expectedKind)
    {
        var galaxyKind = Enum.Parse<GalaxyAlarmTransitionKind>(galaxyKindName);
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
        var observed = new List<AlarmEventArgs>();
        driver.OnAlarmEvent += (_, args) => observed.Add(args);

        feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01", galaxyKind, AlarmSeverity.High));

        observed.ShouldHaveSingleItem();
        observed[0].Kind.ShouldBe(expectedKind);
    }

    /// <summary>Verifies that OnAlarmEvent does not fire before any alarm subscription.</summary>
    [Fact]
    public void OnAlarmEvent_does_not_fire_before_any_alarm_subscription()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        var observed = new List<AlarmEventArgs>();
        driver.OnAlarmEvent += (_, args) => observed.Add(args);

        // No SubscribeAlarmsAsync → the feed is never wired onto the driver, so a
        // transition surfaces nowhere.
        feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
            GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));

        observed.ShouldBeEmpty();
    }

    /// <summary>Verifies that UnsubscribeAlarmsAsync stops event flow.</summary>
    [Fact]
    public async Task UnsubscribeAlarmsAsync_stops_event_flow()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
        var observed = new List<AlarmEventArgs>();
        driver.OnAlarmEvent += (_, args) => observed.Add(args);

        await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None);

        // The feed keeps running (it is session-less and shared), but with no active
        // subscription the driver suppresses the bridged event.
        feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
            GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));

        observed.ShouldBeEmpty();
    }

    /// <summary>
    ///     Regression for the session-less alarm-handle leak: the gateway's StreamAlarms feed survives an
    ///     in-place reconnect, so <c>DriverInstanceActor</c> re-subscribes on every Connected re-entry
    ///     (after dropping its own cached handle). The driver must COLLAPSE to a single live handle on each
    ///     subscribe rather than accumulate one per reconnect. Observable proof (the handle set is private):
    ///     after three re-subscribes, releasing only the LATEST handle fully gates the feed — which can only
    ///     happen if the earlier handles were not retained. Before the fix the set kept h1/h2/h3, so
    ///     releasing h3 left the gate open and a transition still surfaced.
    /// </summary>
    [Fact]
    public async Task Re_subscribe_collapses_to_a_single_handle_no_accumulation()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        // Simulate the actor re-subscribing on each in-place reconnect.
        var h1 = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
        var h2 = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
        var latest = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);

        // Each subscribe issues a DISTINCT handle (not a reused singleton) — otherwise the collapse
        // semantics would be vacuous.
        h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
        h2.DiagnosticId.ShouldNotBe(latest.DiagnosticId);

        var observed = new List<AlarmEventArgs>();
        driver.OnAlarmEvent += (_, args) => observed.Add(args);

        // Releasing ONLY the latest handle must fully gate the feed — proving the driver held exactly one
        // live handle, not the three issued across the simulated reconnects.
        await driver.UnsubscribeAlarmsAsync(latest, CancellationToken.None);
        feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
            GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
        observed.ShouldBeEmpty("re-subscribe must collapse to one handle; releasing it gates the feed");

        // And the latest subscribe is the live one: a fresh subscribe re-opens the gate.
        var reopened = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
        feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
            GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
        observed.ShouldHaveSingleItem();
        observed[0].SubscriptionHandle.ShouldBe(reopened);
    }

    /// <summary>Verifies that UnsubscribeAlarmsAsync throws for a foreign handle.</summary>
    [Fact]
    public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        var foreignHandle = new ForeignAlarmHandle();
        await Should.ThrowAsync<ArgumentException>(() =>
            driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
    }

    /// <summary>Verifies that AcknowledgeAsync routes each request to the acknowledger.</summary>
    [Fact]
    public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        var requests = new[]
        {
            new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", "shift handover"),
            new AlarmAcknowledgeRequest("Tank02", "Tank02.Level.HiHi", "investigating"),
        };

        await driver.AcknowledgeAsync(requests, CancellationToken.None);

        ack.Calls.Count.ShouldBe(2);
        ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
        ack.Calls[0].Comment.ShouldBe("shift handover");
        ack.Calls[0].Operator.ShouldBe(string.Empty); // null OperatorUser must fall back to empty string
        ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
    }

    /// <summary>Verifies that AcknowledgeAsync falls back to SourceNodeId when ConditionId is empty.</summary>
    [Fact]
    public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        await driver.AcknowledgeAsync(
            [new AlarmAcknowledgeRequest("Tank01.Level.HiHi", string.Empty, null)],
            CancellationToken.None);

        ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
    }

    /// <summary>
    ///     Verifies that AcknowledgeAsync forwards the request's authenticated principal
    ///     (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>) to the acknowledger so the
    ///     gateway records WHO acked, rather than the generic empty-string fallback.
    /// </summary>
    [Fact]
    public async Task AcknowledgeAsync_forwards_OperatorUser()
    {
        var feed = new FakeAlarmFeed();
        var ack = new RecordingAcknowledger();
        using var driver = NewDriver(feed, ack);

        await driver.AcknowledgeAsync(
            [new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice")],
            CancellationToken.None);

        ack.Calls.ShouldHaveSingleItem();
        ack.Calls[0].Operator.ShouldBe("alice");
    }

    /// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
    [Fact]
    public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
    {
        var feed = new FakeAlarmFeed();
        using var driver = NewDriver(feed, alarmAcknowledger: null);

        await Should.ThrowAsync<NotSupportedException>(() =>
            driver.AcknowledgeAsync(
                [new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", null)],
                CancellationToken.None));
    }

    private static GalaxyDriver NewDriver(
        IGalaxyAlarmFeed alarmFeed, IGalaxyAlarmAcknowledger? alarmAcknowledger)
    {
        var options = new GalaxyDriverOptions(
            new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
            new GalaxyMxAccessOptions("AlarmSourceTest"),
            new GalaxyRepositoryOptions(),
            new GalaxyReconnectOptions());
        return new GalaxyDriver(
            driverInstanceId: "drv-1",
            options: options,
            hierarchySource: null,
            alarmAcknowledger: alarmAcknowledger,
            alarmFeed: alarmFeed);
    }

    private static GalaxyAlarmTransition NewTransition(
        string alarmFullReference,
        string sourceObjectReference,
        GalaxyAlarmTransitionKind kind,
        AlarmSeverity severity)
        => new(
            AlarmFullReference: alarmFullReference,
            SourceObjectReference: sourceObjectReference,
            AlarmTypeName: "AnalogLimitAlarm.HiHi",
            TransitionKind: kind,
            SeverityBucket: severity,
            OpcUaSeverity: 800,
            RawMxAccessSeverity: 750,
            OriginalRaiseTimestampUtc: null,
            TransitionTimestampUtc: DateTime.UtcNow,
            OperatorUser: string.Empty,
            OperatorComment: string.Empty,
            Category: "Process",
            Description: "Tank 01 high-high level");

    /// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
    private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
    {
        /// <summary>Gets a value indicating whether the feed has been started.</summary>
        public bool Started { get; private set; }

        /// <summary>Occurs when an alarm transition is emitted.</summary>
        public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;

        /// <summary>Marks the feed as started.</summary>
        public void Start() => Started = true;

        /// <summary>Emits an alarm transition to all subscribers.</summary>
        /// <param name="transition">The transition to emit.</param>
        public void Emit(GalaxyAlarmTransition transition)
            => OnAlarmTransition?.Invoke(this, transition);

        /// <summary>Disposes the fake feed.</summary>
        /// <returns>A completed task.</returns>
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }

    /// <summary>Test double that records all acknowledge calls.</summary>
    private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
    {
        /// <summary>Gets the list of acknowledge calls recorded.</summary>
        public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];

        /// <summary>Records an acknowledge call.</summary>
        /// <param name="alarmFullReference">The alarm full reference.</param>
        /// <param name="comment">The acknowledgment comment.</param>
        /// <param name="operatorUser">The operator user.</param>
        /// <param name="cancellationToken">Cancellation token.</param>
        /// <returns>A completed task.</returns>
        public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
        {
            Calls.Add((alarmFullReference, comment, operatorUser));
            return Task.CompletedTask;
        }
    }

    /// <summary>Test double that represents a foreign alarm subscription handle.</summary>
    private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
    {
        /// <summary>Gets the diagnostic ID for this handle.</summary>
        public string DiagnosticId => "foreign";
    }
}
