using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
///     Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
///     in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
///     live MXAccess runtime → real Galaxy objects + attributes.
/// </summary>
/// <remarks>
///     <para>
///         <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
///         <c>Assert.Skip</c> when missing):
///     </para>
///     <list type="bullet">
///         <item>AVEVA System Platform installed + Platform deployed.</item>
///         <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
///         <item>MXAccess COM server registered.</item>
///         <item>ZB database exists with at least one deployed gobject.</item>
///         <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
///         <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
///               service's registry Environment values (test host typically needs to be elevated
///               to read the latter).</item>
///         <item>Test process runs as the account listed in the service's pipe ACL
///               (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
///     </list>
///     <para>
///         Tests here are deliberately read-only. Writes against live Galaxy attributes are a
///         separate concern — they need a test-only UDA or an agreed scratch tag so they can't
///         accidentally mutate a process-critical value. Adding a write test is a follow-up
///         PR that reuses this fixture.
///     </para>
/// </remarks>
[Trait("Category", "LiveGalaxy")]
[Collection(LiveStackCollection.Name)]
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
{
    [Fact]
    public void Fixture_initialized_successfully()
    {
        fixture.SkipIfUnavailable();
        // If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
        // This is the cheapest possible assertion that the IPC handshake worked end-to-end;
        // every other test in this class depends on it.
        fixture.Driver.ShouldNotBeNull();
        fixture.Config.ShouldNotBeNull();
        fixture.PrerequisiteReport.ShouldNotBeNull();
        fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
    }

    [Fact]
    public void Driver_reports_Healthy_after_IPC_handshake()
    {
        fixture.SkipIfUnavailable();
        var health = fixture.Driver!.GetHealth();
        health.State.ShouldBe(DriverState.Healthy,
            $"Expected Healthy after successful IPC connect; Reason={health.LastError}");
    }

    [Fact]
    public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
    {
        fixture.SkipIfUnavailable();
        var builder = new CapturingAddressSpaceBuilder();
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        await fixture.Driver!.DiscoverAsync(builder, cts.Token);

        builder.Variables.Count.ShouldBeGreaterThan(0,
            "Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
            "Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");

        // Every discovered attribute must carry a non-empty FullName so the OPC UA server can
        // route reads/writes back. Regression guard — PR 19 normalized this across drivers.
        builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
    }

    [Fact]
    public void GetHostStatuses_reports_at_least_one_platform()
    {
        fixture.SkipIfUnavailable();
        var statuses = fixture.Driver!.GetHostStatuses();
        statuses.Count.ShouldBeGreaterThan(0,
            "Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
            "Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");

        // Host names are driver-opaque to the Core but non-empty by contract.
        statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
    }

    [Fact]
    public async Task Can_read_a_discovered_variable_from_live_galaxy()
    {
        fixture.SkipIfUnavailable();
        var builder = new CapturingAddressSpaceBuilder();
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        await fixture.Driver!.DiscoverAsync(builder, cts.Token);
        builder.Variables.Count.ShouldBeGreaterThan(0);

        // Pick the first discovered variable. Read-only smoke — we don't assert on Value,
        // only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
        // returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
        // Uncertain quality until the Engine's first scan publishes them, which is fine here.
        var full = builder.Variables[0].AttributeInfo.FullName;
        var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);

        snapshots.Count.ShouldBe(1);
        var snap = snapshots[0];
        snap.StatusCode.ShouldNotBe(0x80020000u,
            $"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
            $"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
    }

    [Fact]
    public async Task Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001()
    {
        // PR 40 — finishes LMX #5. Targets DelmiaReceiver_001.TestAttribute, the writable
        // Boolean attribute on the TestMachine_001 hierarchy that the dev Galaxy was deployed
        // with for exactly this kind of integration testing. We invert the current value and
        // assert the new value comes back, then restore the original so the test is effectively
        // idempotent (Galaxy holds the value across runs since it's a deployed UDA).
        fixture.SkipIfUnavailable();
        const string fullRef = "DelmiaReceiver_001.TestAttribute";
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

        // Read current value first — gives the cleanup path the right baseline. Galaxy may
        // return Uncertain quality until the Engine has scanned the attribute at least once;
        // we don't read into a strongly-typed bool until Status is Good.
        var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
        before.StatusCode.ShouldNotBe(0x80020000u, $"baseline read failed for {fullRef}: {before.Value}");
        var originalBool = Convert.ToBoolean(before.Value ?? false);
        var inverted = !originalBool;

        try
        {
            // Write the inverted value via IWritable.
            var writeResults = await fixture.Driver!.WriteAsync(
                [new(fullRef, inverted)], cts.Token);
            writeResults.Count.ShouldBe(1);
            writeResults[0].StatusCode.ShouldBe(0u,
                $"WriteAsync returned status 0x{writeResults[0].StatusCode:X8} for {fullRef} — " +
                $"check the Host service log at %ProgramData%\\OtOpcUa\\Galaxy\\.");

            // The Engine's scan + acknowledgement is async — read in a short loop with a 5s
            // budget. Galaxy's attribute roundtrip on a dev box is typically sub-second but
            // we give headroom for first-scan after a service restart.
            DataValueSnapshot after = default!;
            var deadline = DateTime.UtcNow.AddSeconds(5);
            while (DateTime.UtcNow < deadline)
            {
                after = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
                if (after.StatusCode == 0u && Convert.ToBoolean(after.Value ?? false) == inverted) break;
                await Task.Delay(200, cts.Token);
            }
            after.StatusCode.ShouldBe(0u, "post-write read failed");
            Convert.ToBoolean(after.Value ?? false).ShouldBe(inverted,
                $"Wrote {inverted} but Galaxy returned {after.Value} after the scan window.");
        }
        finally
        {
            // Restore — best-effort. If this throws the test still reports its primary result;
            // we just leave a flipped TestAttribute on the dev box (benign, name says it all).
            try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); }
            catch { /* swallow */ }
        }
    }

    [Fact]
    public async Task Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write()
    {
        // Subscribe + write is the canonical "is the data path actually live" test for
        // an OPC UA driver. We subscribe to the same Boolean attribute, expect an initial-
        // value callback within a couple of seconds (per ISubscribable's contract — the
        // driver MAY fire OnDataChange immediately with the current value), then write a
        // distinct value and expect a second callback carrying the new value.
        fixture.SkipIfUnavailable();
        const string fullRef = "DelmiaReceiver_001.TestAttribute";
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

        // Capture every OnDataChange notification for this fullRef onto a thread-safe queue
        // we can poll from the test thread. Galaxy's MXAccess advisory fires on its own
        // thread; we don't want to block it.
        var notifications = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
        void Handler(object? sender, DataChangeEventArgs e)
        {
            if (string.Equals(e.FullReference, fullRef, StringComparison.OrdinalIgnoreCase))
                notifications.Enqueue(e.Snapshot);
        }
        fixture.Driver!.OnDataChange += Handler;

        // Read current value so we know which value to write to force a transition.
        var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
        var originalBool = Convert.ToBoolean(before.Value ?? false);
        var toWrite = !originalBool;

        ISubscriptionHandle? handle = null;
        try
        {
            handle = await fixture.Driver!.SubscribeAsync(
                [fullRef], TimeSpan.FromMilliseconds(250), cts.Token);

            // Wait for initial-value notification — typical < 1s on a hot Galaxy, give 5s.
            await WaitForAsync(() => notifications.Count >= 1, TimeSpan.FromSeconds(5), cts.Token);
            notifications.Count.ShouldBeGreaterThanOrEqualTo(1,
                $"No initial-value OnDataChange for {fullRef} within 5s. " +
                $"Either MXAccess subscription failed silently or the Engine hasn't scanned yet.");

            // Drain the initial-value queue before writing so we count post-write deltas only.
            var initialCount = notifications.Count;

            // Write the toggled value. Engine scan + advisory fires the second callback.
            var w = await fixture.Driver!.WriteAsync([new(fullRef, toWrite)], cts.Token);
            w[0].StatusCode.ShouldBe(0u);

            await WaitForAsync(() => notifications.Count > initialCount, TimeSpan.FromSeconds(8), cts.Token);
            notifications.Count.ShouldBeGreaterThan(initialCount,
                $"OnDataChange did not fire after writing {toWrite} to {fullRef} within 8s.");

            // Find the post-write notification carrying the toggled value (initial value may
            // appear multiple times before the write commits — search the tail).
            var postWrite = notifications.ToArray().Reverse()
                .FirstOrDefault(n => n.StatusCode == 0u && Convert.ToBoolean(n.Value ?? false) == toWrite);
            postWrite.ShouldNotBe(default,
                $"No OnDataChange carrying the toggled value {toWrite} appeared in the queue: " +
                string.Join(",", notifications.Select(n => $"{n.Value}@{n.StatusCode:X8}")));
        }
        finally
        {
            fixture.Driver!.OnDataChange -= Handler;
            if (handle is not null)
            {
                try { await fixture.Driver!.UnsubscribeAsync(handle, cts.Token); } catch { /* swallow */ }
            }
            // Restore baseline.
            try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } catch { /* swallow */ }
        }
    }

    private static async Task WaitForAsync(Func<bool> predicate, TimeSpan budget, CancellationToken ct)
    {
        var deadline = DateTime.UtcNow + budget;
        while (DateTime.UtcNow < deadline)
        {
            if (predicate()) return;
            await Task.Delay(100, ct);
        }
    }

    /// <summary>
    ///     Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
    ///     Variable() call into a flat list so tests can inspect what discovery produced
    ///     without running the full OPC UA node-manager stack.
    /// </summary>
    private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
    {
        public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];

        public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
        {
            Variables.Add((browseName, attributeInfo));
            return new NoopHandle(attributeInfo.FullName);
        }
        public void AddProperty(string browseName, DriverDataType dataType, object? value) { }

        private sealed class NoopHandle(string fullReference) : IVariableHandle
        {
            public string FullReference { get; } = fullReference;
            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
            private sealed class NoopSink : IAlarmConditionSink
            {
                public void OnTransition(AlarmEventArgs args) { }
            }
        }
    }
}
