using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
    /// <summary>
    ///     PR C.1 — covers <see cref="SdkAlarmHistorianWriteBackend"/>, the aahClientManaged-bound
    ///     alarm-event writer. The SDK-touching batch loop itself is exercised by the rig-gated
    ///     <c>Live_*</c> tests (D.1); the unit tests below pin the parts that are SDK-type-free:
    ///     <list type="bullet">
    ///       <item><description>connection-unavailable → whole batch deferred as RetryPlease;</description></item>
    ///       <item><description><see cref="SdkAlarmHistorianWriteBackend.ClassifyOutcome"/> error-code mapping;</description></item>
    ///       <item><description><see cref="SdkHistorianConnectionFactory.BuildConnectionArgs"/> read-only-vs-write shaping.</description></item>
    ///     </list>
    /// </summary>
    [Trait("Category", "Unit")]
    public sealed class SdkAlarmHistorianWriteBackendTests
    {
        // ── Connection-unavailable path (deterministic, no SDK load) ──────────

        /// <summary>Verifies that an empty batch returns an empty outcome array.</summary>
        [Fact]
        public async Task Empty_batch_returns_empty_array()
        {
            var backend = new SdkAlarmHistorianWriteBackend(
                Config("any"), new ThrowingConnectionFactory());

            var outcomes = await backend.WriteBatchAsync(
                Array.Empty<AlarmHistorianEventDto>(), CancellationToken.None);

            outcomes.ShouldBeEmpty();
        }

        /// <summary>Verifies that when all nodes are unreachable, the entire batch is deferred as RetryPlease.</summary>
        [Fact]
        public async Task Unreachable_node_defers_whole_batch_as_RetryPlease()
        {
            // No node can be connected — the backend must defer every event so the
            // lmxopcua-side SQLite store-and-forward sink retains the rows rather than
            // dropping them.
            var backend = new SdkAlarmHistorianWriteBackend(
                Config("unreachable"), new ThrowingConnectionFactory());

            var events = new[] { AlarmEvent("E1"), AlarmEvent("E2"), AlarmEvent("E3") };
            var outcomes = await backend.WriteBatchAsync(events, CancellationToken.None);

            outcomes.Length.ShouldBe(events.Length);
            outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
        }

        /// <summary>Verifies that a large batch with unreachable nodes returns one outcome per event.</summary>
        [Fact]
        public async Task Unreachable_node_large_batch_returns_one_outcome_per_event()
        {
            // Guards the outcome-array allocation: WriteBatchAsync must always return exactly
            // as many outcomes as input events, even on the whole-batch-deferred path.
            var backend = new SdkAlarmHistorianWriteBackend(
                Config("unreachable"), new ThrowingConnectionFactory());

            var batch = Enumerable.Range(0, 1000).Select(i => AlarmEvent($"E{i}")).ToArray();
            var outcomes = await backend.WriteBatchAsync(batch, CancellationToken.None);

            outcomes.Length.ShouldBe(1000);
            outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
        }

        /// <summary>Verifies that a connection failure marks the node as failed in the endpoint picker.</summary>
        [Fact]
        public async Task Connect_failure_marks_node_failed_in_picker()
        {
            // Every connect attempt throws → the picker should record the failure so the
            // node enters cooldown (cluster-failover plumbing).
            var cfg = Config("node-a");
            var picker = new HistorianClusterEndpointPicker(cfg);
            var backend = new SdkAlarmHistorianWriteBackend(cfg, new ThrowingConnectionFactory(), picker);

            await backend.WriteBatchAsync(new[] { AlarmEvent("E1") }, CancellationToken.None);

            picker.HealthyNodeCount.ShouldBe(0, "the only node failed to connect and is now in cooldown");
        }

        // ── ClassifyOutcome — error-code → outcome mapping ────────────────────

        /// <summary>Verifies that error codes map to the expected write outcomes.</summary>
        /// <param name="code">The historian access error code to classify.</param>
        /// <param name="expected">The expected write outcome.</param>
        [Theory]
        [InlineData(HistorianAccessError.ErrorValue.Success, AlarmHistorianWriteOutcome.Ack)]
        [InlineData(HistorianAccessError.ErrorValue.FailedToConnect, AlarmHistorianWriteOutcome.RetryPlease)]
        [InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession, AlarmHistorianWriteOutcome.RetryPlease)]
        [InlineData(HistorianAccessError.ErrorValue.NoReply, AlarmHistorianWriteOutcome.RetryPlease)]
        [InlineData(HistorianAccessError.ErrorValue.NotReady, AlarmHistorianWriteOutcome.RetryPlease)]
        [InlineData(HistorianAccessError.ErrorValue.Failure, AlarmHistorianWriteOutcome.RetryPlease)]
        [InlineData(HistorianAccessError.ErrorValue.NoData, AlarmHistorianWriteOutcome.RetryPlease)]
        [InlineData(HistorianAccessError.ErrorValue.InvalidArgument, AlarmHistorianWriteOutcome.PermanentFail)]
        [InlineData(HistorianAccessError.ErrorValue.ValidationFailed, AlarmHistorianWriteOutcome.PermanentFail)]
        [InlineData(HistorianAccessError.ErrorValue.NullPointerArgument, AlarmHistorianWriteOutcome.PermanentFail)]
        [InlineData(HistorianAccessError.ErrorValue.NotImplemented, AlarmHistorianWriteOutcome.PermanentFail)]
        public void ClassifyOutcome_maps_error_code_to_expected_outcome(
            HistorianAccessError.ErrorValue code, AlarmHistorianWriteOutcome expected)
        {
            SdkAlarmHistorianWriteBackend.ClassifyOutcome(code).ShouldBe(expected);
        }

        // ── ToHistorianEvent — EventId handling ───────────────────────────────

        /// <summary>Verifies that a parseable event ID is used verbatim in the historian event.</summary>
        [Fact]
        public void ToHistorianEvent_parseable_event_id_is_used_verbatim()
        {
            // Sanity case: a real GUID round-trips into HistorianEvent.Id.
            var id = Guid.Parse("12345678-1234-1234-1234-123456789abc");
            var dto = new AlarmHistorianEventDto
            {
                EventId = id.ToString(),
                SourceName = "Tank01",
                AlarmType = "AnalogLimitAlarm.HiHi",
                EventTimeUtcTicks = DateTime.UtcNow.Ticks,
            };

#pragma warning disable CS0618
            SdkAlarmHistorianWriteBackend.ToHistorianEvent(dto).Id.ShouldBe(id);
#pragma warning restore CS0618
        }

        /// <summary>Verifies that an unparseable event ID is synthesized as a unique non-empty GUID.</summary>
        [Fact]
        public void ToHistorianEvent_unparseable_event_id_synthesizes_unique_non_empty_Guid()
        {
            // Driver.Historian.Wonderware-004 regression: when EventId is not a parseable
            // GUID (or is empty) the previous implementation silently left HistorianEvent.Id
            // as Guid.Empty, so multiple alarms collided on the same id with no warning.
            // The fix synthesizes a fresh Guid so every event still gets a unique identifier.
            var dtoA = new AlarmHistorianEventDto
            {
                EventId = "not-a-guid",
                SourceName = "Tank01",
                AlarmType = "Active",
                EventTimeUtcTicks = DateTime.UtcNow.Ticks,
            };
            var dtoB = new AlarmHistorianEventDto
            {
                EventId = string.Empty,
                SourceName = "Tank01",
                AlarmType = "Active",
                EventTimeUtcTicks = DateTime.UtcNow.Ticks,
            };

#pragma warning disable CS0618
            var idA = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoA).Id;
            var idB = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoB).Id;
#pragma warning restore CS0618

            idA.ShouldNotBe(Guid.Empty, "unparseable EventId must not collapse to Guid.Empty");
            idB.ShouldNotBe(Guid.Empty, "empty EventId must not collapse to Guid.Empty");
            idA.ShouldNotBe(idB, "every event needs a unique synthesized id");
        }

        /// <summary>Verifies that a write-to-read-only-file error is classified as RetryPlease, not PermanentFail.</summary>
        [Fact]
        public void ClassifyOutcome_WriteToReadOnlyFile_is_RetryPlease_not_PermanentFail()
        {
            // Driver.Historian.Wonderware-001 regression: WriteToReadOnlyFile is a
            // connection-configuration fault (the write session was opened without
            // ReadOnly = false), NOT a malformed-event fault. Routing it to PermanentFail
            // would dead-letter every alarm event in the batch on a misconfigured/regressed
            // connection — data loss. It must be treated as a transient connection-class
            // error so the events are deferred and retried once the connection is corrected.
            SdkAlarmHistorianWriteBackend.ClassifyOutcome(
                    HistorianAccessError.ErrorValue.WriteToReadOnlyFile)
                .ShouldBe(AlarmHistorianWriteOutcome.RetryPlease);
        }

        // ── BuildConnectionArgs — read-only vs write shaping ──────────────────

        /// <summary>Verifies that a write connection is opened with ReadOnly set to false.</summary>
        [Fact]
        public void BuildConnectionArgs_write_connection_is_not_read_only()
        {
            // The alarm-event write path must open ReadOnly=false; AddStreamedValue on a
            // read-only session fails with WriteToReadOnlyFile.
            var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
                Config("h1"), HistorianConnectionType.Event, readOnly: false);

            args.ReadOnly.ShouldBeFalse();
            args.ConnectionType.ShouldBe(HistorianConnectionType.Event);
            args.ServerName.ShouldBe("h1");
        }

        /// <summary>Verifies that a query connection is opened with ReadOnly set to true.</summary>
        [Fact]
        public void BuildConnectionArgs_query_connection_is_read_only()
        {
            var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
                Config("h1"), HistorianConnectionType.Process, readOnly: true);

            args.ReadOnly.ShouldBeTrue();
            args.ConnectionType.ShouldBe(HistorianConnectionType.Process);
        }

        /// <summary>Verifies that non-integrated security credentials are preserved in connection arguments.</summary>
        [Fact]
        public void BuildConnectionArgs_non_integrated_security_carries_credentials()
        {
            var cfg = Config("h1");
            cfg.IntegratedSecurity = false;
            cfg.UserName = "histuser";
            cfg.Password = "histpass";

            var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
                cfg, HistorianConnectionType.Event, readOnly: false);

            args.IntegratedSecurity.ShouldBeFalse();
            args.UserName.ShouldBe("histuser");
            args.Password.ShouldBe("histpass");
        }

        // ── Rig-gated integration tests ───────────────────────────────────────
        //
        // The entry point (HistorianAccess.AddStreamedValue) is pinned and implemented;
        // these need a live AVEVA Historian and are un-skipped during the PR D.1 smoke.

        /// <summary>Verifies that a single alarm event roundtrip returns an Ack outcome.</summary>
        [Fact(Skip = "rig-required: needs a live AVEVA Historian — un-skip during the PR D.1 rollout smoke")]
        public async Task Live_single_event_roundtrip_returns_Ack()
        {
            var backend = new SdkAlarmHistorianWriteBackend(BuildRigConfig());

            var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-E1") }, CancellationToken.None);

            outcomes.Length.ShouldBe(1);
            outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
        }

        /// <summary>Verifies that cluster failover rotates from a bad primary node to a secondary node.</summary>
        [Fact(Skip = "rig-required: needs a live AVEVA Historian cluster (two nodes) — un-skip during the PR D.1 rollout smoke")]
        public async Task Live_cluster_failover_primary_bad_rotates_to_secondary()
        {
            var cfg = new HistorianConfiguration
            {
                Enabled = true,
                ServerNames = new List<string>
                {
                    "invalid-primary-node-deliberately-unreachable",
                    Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
                },
                Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
                IntegratedSecurity = true,
                FailureCooldownSeconds = 5,
                CommandTimeoutSeconds = 10,
            };
            var backend = new SdkAlarmHistorianWriteBackend(cfg);

            var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-failover-E1") }, CancellationToken.None);

            outcomes.Length.ShouldBe(1);
            outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
        }

        // ── helpers ───────────────────────────────────────────────────────────

        private static HistorianConfiguration Config(string server) => new HistorianConfiguration
        {
            Enabled = true,
            ServerName = server,
            Port = 32568,
            IntegratedSecurity = true,
            CommandTimeoutSeconds = 30,
            FailureCooldownSeconds = 60,
        };

        private static AlarmHistorianEventDto AlarmEvent(string id) => new AlarmHistorianEventDto
        {
            EventId = id,
            SourceName = "TestSource",
            ConditionId = "TestSource.Level.HiHi",
            AlarmType = "AnalogLimitAlarm.HiHi",
            Message = "C.1 test alarm",
            Severity = 500,
            EventTimeUtcTicks = DateTime.UtcNow.Ticks,
            AckComment = null,
        };

        private static HistorianConfiguration BuildRigConfig() => new HistorianConfiguration
        {
            Enabled = true,
            ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
            Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
            IntegratedSecurity = true,
            CommandTimeoutSeconds = 30,
            FailureCooldownSeconds = 60,
        };

        private static int TryParseInt(string envName, int defaultValue)
        {
            var raw = Environment.GetEnvironmentVariable(envName);
            return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
        }

        /// <summary>
        ///     Fake factory whose every connect attempt throws — drives the
        ///     connection-unavailable path without loading the native SDK.
        /// </summary>
        private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory
        {
            /// <summary>Creates and attempts to connect, always throwing a simulated connect failure.</summary>
            /// <param name="config">The historian configuration specifying the target server.</param>
            /// <param name="type">The connection type (Process or Event).</param>
            /// <param name="readOnly">Whether to open a read-only connection.</param>
            public HistorianAccess CreateAndConnect(
                HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
                => throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
        }
    }
}
