using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer;

namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;

/// <summary>
///     Renders <see cref="AlarmsPage"/> and asserts the truncated-snapshot
///     caveat banner appears exactly when the alarm query reports a capped
///     provider fetch.
/// </summary>
/// <remarks>
///     <para>
///         The absence assertion is the load-bearing one: a banner that renders
///         unconditionally would satisfy the positive case while telling every
///         operator, on every normal day, that the alarm list might be missing
///         alarms. A caveat that is always on is a caveat nobody reads.
///     </para>
///     <para>
///         Static rendering via the framework's <see cref="HtmlRenderer"/>, as in
///         <c>SecretsNavRenderTests</c> — the assertion is about markup the server
///         emits, so no component-testing dependency is warranted. The page's
///         poll loop runs its first pass inline during <c>OnInitialized</c>
///         (the stub query completes synchronously), so the rendered markup
///         already reflects the query result.
///     </para>
/// </remarks>
public sealed class AlarmsPageTruncationBannerTests
{
    private const string BannerMarker = "Alarm snapshot may be incomplete";

    private static readonly TimeSpan RenderWaitTimeout = TimeSpan.FromSeconds(10);

    /// <summary>A capped provider fetch puts the completeness caveat on the page.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AlarmsPage_WhenSnapshotTruncated_RendersTheCaveatBanner()
    {
        string html = await RenderAsync(snapshotTruncated: true);

        Assert.Contains(BannerMarker, html, StringComparison.Ordinal);
    }

    /// <summary>
    ///     The proof the banner is gated. A complete fetch must render no caveat
    ///     at all, while the page itself still renders — the alarm-table heading
    ///     is the control that keeps this from passing over a blank page.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AlarmsPage_WhenSnapshotComplete_OmitsTheCaveatBanner()
    {
        string html = await RenderAsync(snapshotTruncated: false);

        Assert.DoesNotContain(BannerMarker, html, StringComparison.Ordinal);
        Assert.Contains("Active Alarms", html, StringComparison.Ordinal);
    }

    /// <summary>
    ///     The push path. A <c>snapshot_status</c> frame arriving on the page's
    ///     in-process alarm-feed subscription raises the caveat on its own, with
    ///     no poll tick behind it — the poll is a 3-second reconcile baseline, and
    ///     an operator should not stare at an un-caveated alarm list for up to
    ///     three seconds after the gateway has already decided the set is capped.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AlarmsPage_WhenFeedPushesTruncated_RaisesTheBannerWithoutAPollTick()
    {
        ScriptedAlarmFeed feed = new();
        await using ServiceProvider provider = BuildPushServices(feed);
        await using HtmlRenderer renderer = new(
            provider,
            provider.GetRequiredService<ILoggerFactory>());

        HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
            () => renderer.RenderComponentAsync<AlarmsPage>());

        // The one poll answer this page will ever get said "complete", so everything
        // the banner does from here is the feed's doing.
        Assert.DoesNotContain(BannerMarker, await HtmlAsync(renderer, page), StringComparison.Ordinal);

        await feed.PushAsync(SnapshotStatusFrame(truncated: true));

        await WaitForHtmlAsync(
            renderer,
            page,
            html => html.Contains(BannerMarker, StringComparison.Ordinal),
            "banner to appear after a truncated snapshot_status frame");
    }

    /// <summary>
    ///     The clearing edge, page-side. Absence authority comes back when the
    ///     gateway says so; a banner that only ever went up would caveat the alarm
    ///     list for the life of the circuit.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AlarmsPage_WhenFeedPushesComplete_ClearsTheBannerWithoutAPollTick()
    {
        ScriptedAlarmFeed feed = new();
        await using ServiceProvider provider = BuildPushServices(feed);
        await using HtmlRenderer renderer = new(
            provider,
            provider.GetRequiredService<ILoggerFactory>());

        HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
            () => renderer.RenderComponentAsync<AlarmsPage>());

        await feed.PushAsync(SnapshotStatusFrame(truncated: true));
        await WaitForHtmlAsync(
            renderer,
            page,
            html => html.Contains(BannerMarker, StringComparison.Ordinal),
            "banner to appear before the clearing frame is pushed");

        await feed.PushAsync(SnapshotStatusFrame(truncated: false));

        await WaitForHtmlAsync(
            renderer,
            page,
            html => !html.Contains(BannerMarker, StringComparison.Ordinal),
            "banner to clear after a complete snapshot_status frame");
    }

    /// <summary>
    ///     Attach while the verdict is already truncated. The page carries no
    ///     priming logic of its own — it relies on <c>StreamAsync</c> opening every
    ///     subscription with a <c>snapshot_status</c> baseline — so the caveat has
    ///     to come up off the open sequence alone, with no edge pushed afterwards.
    ///     A page that only handled the edge would show an un-caveated alarm list
    ///     to every operator who opened it after the truncation began.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AlarmsPage_AttachingToAnAlreadyTruncatedFeed_RaisesTheBannerFromThePriming()
    {
        // The monitor's open sequence: provider status, then the unconditional
        // completeness baseline. Nothing is pushed after this.
        ScriptedAlarmFeed feed = new()
        {
            Priming =
            [
                new AlarmFeedMessage { ProviderStatus = new AlarmProviderStatus() },
                SnapshotStatusFrame(truncated: true),
            ],
        };

        await using ServiceProvider provider = BuildPushServices(feed);
        await using HtmlRenderer renderer = new(
            provider,
            provider.GetRequiredService<ILoggerFactory>());

        HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
            () => renderer.RenderComponentAsync<AlarmsPage>());

        await WaitForHtmlAsync(
            renderer,
            page,
            html => html.Contains(BannerMarker, StringComparison.Ordinal),
            "banner to appear from the feed's open-time snapshot_status baseline");
    }

    private static AlarmFeedMessage SnapshotStatusFrame(bool truncated)
    {
        return new AlarmFeedMessage
        {
            SnapshotStatus = new AlarmSnapshotStatus { Truncated = truncated },
        };
    }

    private static ServiceProvider BuildPushServices(ScriptedAlarmFeed feed)
    {
        ServiceCollection services = new();
        services.AddLogging();
        services.AddSingleton<IDashboardLiveDataService, SinglePollLiveDataService>();
        services.AddSingleton<IGatewayAlarmService>(feed);
        services.AddSingleton<IOptions<GatewayOptions>>(
            Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));
        return services.BuildServiceProvider();
    }

    private static Task<string> HtmlAsync(HtmlRenderer renderer, HtmlRootComponent page)
    {
        // Serialization has to happen on the renderer's dispatcher, and it reads the
        // component's current render tree — so it reflects renders the page's feed
        // loop queued after the initial quiescent render.
        return renderer.Dispatcher.InvokeAsync(page.ToHtmlString);
    }

    private static async Task WaitForHtmlAsync(
        HtmlRenderer renderer,
        HtmlRootComponent page,
        Func<string, bool> predicate,
        string expectation)
    {
        Stopwatch elapsed = Stopwatch.StartNew();
        while (elapsed.Elapsed < RenderWaitTimeout)
        {
            if (predicate(await HtmlAsync(renderer, page)))
            {
                return;
            }

            await Task.Delay(TimeSpan.FromMilliseconds(20));
        }

        Assert.Fail($"Timed out after {RenderWaitTimeout.TotalSeconds:N0}s waiting for the {expectation}.");
    }

    private static async Task<string> RenderAsync(bool snapshotTruncated)
    {
        ServiceCollection services = new();
        services.AddLogging();
        services.AddSingleton<IDashboardLiveDataService>(
            new StubLiveDataService(snapshotTruncated));
        services.AddSingleton<IGatewayAlarmService>(
            new FakeGatewayAlarmService { SnapshotTruncated = snapshotTruncated });
        services.AddSingleton<IOptions<GatewayOptions>>(
            Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));

        await using ServiceProvider provider = services.BuildServiceProvider();
        await using HtmlRenderer renderer = new(
            provider,
            provider.GetRequiredService<ILoggerFactory>());

        return await renderer.Dispatcher.InvokeAsync(async () =>
        {
            HtmlRootComponent output = await renderer.RenderComponentAsync<AlarmsPage>();
            return output.ToHtmlString();
        });
    }

    // Answers the page's 3-second poll synchronously, so the first pass completes
    // inline inside OnInitialized and the rendered markup reflects it.
    private sealed class StubLiveDataService(bool snapshotTruncated) : IDashboardLiveDataService
    {
        /// <inheritdoc />
        public Task<DashboardLiveReadResult> ReadAsync(
            IReadOnlyCollection<string> tagAddresses,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardLiveReadResult.Empty);

        /// <inheritdoc />
        public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken) =>
            Task.FromResult(new DashboardAlarmQueryResult(
                Alarms: [],
                Error: null,
                WorkerProcessId: null,
                SnapshotTruncated: snapshotTruncated));
    }

    // Answers exactly one poll — the inline first pass — and parks every later tick
    // until the page's disposal cancels it. The parking is what makes the push tests
    // measure the push: a second tick would re-assert the poll's own verdict, and
    // could either mask a banner the feed raised or raise one the feed did not.
    private sealed class SinglePollLiveDataService : IDashboardLiveDataService
    {
        private int _polls;

        /// <inheritdoc />
        public Task<DashboardLiveReadResult> ReadAsync(
            IReadOnlyCollection<string> tagAddresses,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardLiveReadResult.Empty);

        /// <inheritdoc />
        public async Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
        {
            if (Interlocked.Increment(ref _polls) > 1)
            {
                await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
            }

            return new DashboardAlarmQueryResult(
                Alarms: [],
                Error: null,
                WorkerProcessId: null,
                SnapshotTruncated: false);
        }
    }

    // A hand-driven stand-in for the alarm feed: the test writes the frames the real
    // monitor would push. Unbounded and never completed, so a frame written before the
    // page's loop attaches is still delivered, and the loop never has to resubscribe.
    private sealed class ScriptedAlarmFeed : IGatewayAlarmService
    {
        private readonly Channel<AlarmFeedMessage> _frames =
            Channel.CreateUnbounded<AlarmFeedMessage>(new UnboundedChannelOptions
            {
                SingleReader = false,
                SingleWriter = false,
            });

        /// <inheritdoc />
        public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;

        /// <inheritdoc />
        public string? LastError => null;

        /// <inheritdoc />
        public int? WorkerProcessId => null;

        /// <inheritdoc />
        public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms => [];

        /// <inheritdoc />
        public bool SnapshotTruncated { get; set; }

        /// <summary>
        ///     Frames replayed at the head of every subscription, standing in for the
        ///     monitor's open sequence (provider status, then the unconditional
        ///     completeness baseline). Empty means the subscriber sees only what the
        ///     test pushes.
        /// </summary>
        public IReadOnlyList<AlarmFeedMessage> Priming { get; init; } = [];

        /// <summary>Pushes one frame onto the feed the page is subscribed to.</summary>
        /// <param name="message">The feed frame to deliver.</param>
        /// <returns>A task that represents the asynchronous operation.</returns>
        public ValueTask PushAsync(AlarmFeedMessage message) => _frames.Writer.WriteAsync(message);

        /// <inheritdoc />
        public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
            string? alarmFilterPrefix,
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            foreach (AlarmFeedMessage primed in Priming)
            {
                cancellationToken.ThrowIfCancellationRequested();
                yield return primed;
            }

            await foreach (AlarmFeedMessage message in _frames.Reader
                .ReadAllAsync(cancellationToken)
                .ConfigureAwait(false))
            {
                yield return message;
            }
        }

        /// <inheritdoc />
        public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
            AcknowledgeAlarmRequest request,
            CancellationToken cancellationToken)
        {
            return Task.FromResult(new AcknowledgeAlarmReply
            {
                CorrelationId = request.ClientCorrelationId,
                ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
                DiagnosticMessage = string.Empty,
            });
        }
    }
}
