using System.Runtime.CompilerServices;
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;

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

/// <summary>
///     Covers <c>dashboard_tags</c> on the dashboard API Keys page: the constraints
///     column must name it, and the create form must be able to set it.
/// </summary>
/// <remarks>
///     <para>
///         The column mattered first. <c>ApiKeyConstraints.IsEmpty</c> counts
///         <c>DashboardTags</c>, so a key whose only per-key policy is a dashboard tag is
///         <em>not</em> unconstrained — but the page's summary enumerated only the eight
///         positional members, produced an empty string, and rendered it as <c>-</c>. An
///         operator auditing keys saw the same cell for "no policy recorded" and "scoped to
///         team-a", which is the reading a tag grant can least afford.
///     </para>
///     <para>
///         Rendered through the framework's static rendering infrastructure, the idiom
///         <c>AlarmsPageTruncationBannerTests</c> and <c>SessionDetailsPageEventAclTests</c>
///         use — the assertions are about emitted markup, so no component-testing package is
///         warranted.
///     </para>
/// </remarks>
public sealed class ApiKeysPageDashboardTagsTests
{
    // Deliberately free of the words the assertions search for: a key id containing
    // "unconstrained" would be counted as a summary and mask a regression in the cell.
    private const string TagsOnlyKeyId = "key-tagged";
    private const string UnconstrainedKeyId = "key-plain";

    /// <summary>
    ///     A key whose only constraint is a dashboard tag names the tags in its constraints
    ///     cell. Before the fix this cell read <c>-</c>.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ApiKeysPage_WhenKeyOnlyHasDashboardTags_NamesThemInTheConstraintsColumn()
    {
        string html = await RenderAsync(openCreateDialog: false);

        Assert.Contains("dashboard_tags=[team-a, team-b]", html, StringComparison.Ordinal);
    }

    /// <summary>
    ///     The control for the assertion above: a key with genuinely no constraints must still
    ///     read <c>unconstrained</c>, and only that key may. Without this, a summary that
    ///     labelled every key would satisfy the positive case while erasing the distinction the
    ///     column exists to draw.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ApiKeysPage_WhenKeyHasNoConstraints_StillReadsUnconstrained()
    {
        string html = await RenderAsync(openCreateDialog: false);

        Assert.Contains(UnconstrainedKeyId, html, StringComparison.Ordinal);
        Assert.Contains("unconstrained", html, StringComparison.Ordinal);

        // Exactly one row may claim it — the tags-only key is constrained, by IsEmpty's own reckoning.
        Assert.Equal(1, CountOccurrences(html, "unconstrained"));
    }

    /// <summary>
    ///     The create form offers a dashboard-tags field, so a tagged key can be minted from the
    ///     dashboard rather than only from the <c>apikey</c> CLI.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ApiKeysPage_CreateForm_OffersADashboardTagsField()
    {
        string html = await RenderAsync(openCreateDialog: true);

        // The sibling constraint field is the control: it proves the form rendered at all, so a
        // dialog that failed to open could not pass this by rendering nothing.
        Assert.Contains("id=\"readSubtrees\"", html, StringComparison.Ordinal);
        Assert.Contains("id=\"dashboardTags\"", html, StringComparison.Ordinal);
        Assert.Contains("Dashboard tags", html, StringComparison.Ordinal);
    }

    /// <summary>
    ///     The field is wired through to the request, not merely displayed: the CLI's comma
    ///     separation is honoured and each tag is trimmed.
    /// </summary>
    [Fact]
    public void TryBuildCreateRequest_CarriesTheDashboardTagsField()
    {
        ApiKeysPage page = new();
        page.CreateModel.DashboardTags = "team-a, team-b";

        bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);

        Assert.True(built, error);
        Assert.NotNull(request);
        Assert.Equal(["team-a", "team-b"], request.Constraints.DashboardTags);

        // Nothing else may be inferred from a tags-only form: dashboard tags are a visibility
        // grant, and turning one into a data-access constraint would be a silent policy change.
        Assert.False(request.Constraints.IsEmpty);
        Assert.False(request.Constraints.HasReadConstraints);
        Assert.False(request.Constraints.HasWriteConstraints);
    }

    /// <summary>
    ///     Two spellings of one tag persist as one grant, matching how
    ///     <c>ApiKeyAdminCommandLineParser.ParseDashboardTags</c> collapses them and how the
    ///     enforcement site compares them.
    /// </summary>
    /// <remarks>
    ///     Enforcement would survive the duplicate — a session holds its tags in a
    ///     case-insensitive set — but the API Keys page's constraints column would render
    ///     <c>dashboard_tags=[team-a, TEAM-A]</c> and report one grant as two, which is the
    ///     reading an audit surface can least afford.
    /// </remarks>
    [Fact]
    public void TryBuildCreateRequest_DeduplicatesDashboardTagsIgnoringCase()
    {
        ApiKeysPage page = new();
        page.CreateModel.DashboardTags = "team-a, TEAM-A, team-a";

        bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);

        Assert.True(built, error);
        Assert.NotNull(request);

        // First spelling typed wins, so what the operator wrote is what the audit surface shows.
        Assert.Equal(["team-a"], request.Constraints.DashboardTags);
    }

    /// <summary>
    ///     A dedupe that spanned fields would be a bug of its own: the five glob lists are matched
    ///     literally, so near-duplicates there are not necessarily the same rule and must survive
    ///     verbatim.
    /// </summary>
    [Fact]
    public void TryBuildCreateRequest_DoesNotDeduplicateTheOtherConstraintLists()
    {
        ApiKeysPage page = new();
        page.CreateModel.ReadSubtrees = "Area1, AREA1";

        bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);

        Assert.True(built, error);
        Assert.NotNull(request);
        Assert.Equal(["Area1", "AREA1"], request.Constraints.ReadSubtrees);
    }

    /// <summary>
    ///     <c>Reset</c> clears the new field, so the next key minted in the same dialog does not
    ///     inherit the previous key's tag grant.
    /// </summary>
    [Fact]
    public void Reset_ClearsDashboardTagsAlongsideTheOtherConstraintFields()
    {
        ApiKeysPage page = new();
        page.CreateModel.DashboardTags = "team-a";

        // A sibling field pins the assertion to Reset itself: were Reset a no-op, both would
        // survive and the failure would name the method rather than the one line.
        page.CreateModel.ReadSubtrees = "Area1";
        page.CreateModel.KeyId = "leftover";

        page.CreateModel.Reset();

        Assert.Equal(string.Empty, page.CreateModel.DashboardTags);
        Assert.Equal(string.Empty, page.CreateModel.ReadSubtrees);
        Assert.Equal(string.Empty, page.CreateModel.KeyId);
    }

    /// <summary>An empty field leaves the key untagged rather than inventing a tag.</summary>
    [Fact]
    public void TryBuildCreateRequest_WhenDashboardTagsIsBlank_LeavesTheKeyUntagged()
    {
        ApiKeysPage page = new();

        bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);

        Assert.True(built, error);
        Assert.NotNull(request);
        Assert.Empty(request.Constraints.DashboardTags);
        Assert.True(request.Constraints.IsEmpty);
    }

    private static int CountOccurrences(string haystack, string needle)
    {
        int count = 0;
        int index = haystack.IndexOf(needle, StringComparison.Ordinal);
        while (index >= 0)
        {
            count++;
            index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal);
        }

        return count;
    }

    private static async Task<string> RenderAsync(bool openCreateDialog)
    {
        ServiceCollection services = new();
        services.AddLogging();
        services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
        services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
        services.AddSingleton<IDashboardApiKeyManagementService>(new ManagingApiKeyService());
        services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider());

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

        return await renderer.Dispatcher.InvokeAsync(async () =>
        {
            ApiKeysPage page = renderer.CreateComponent<ApiKeysPage>();
            if (openCreateDialog)
            {
                page.OpenCreateDialog();
            }

            HtmlRootComponent output = renderer.BeginRenderingComponent(page, ParameterView.Empty);
            await output.QuiescenceTask;

            return output.ToHtmlString();
        });
    }

    // The create form lives behind a click, and static rendering has no way to dispatch one, so
    // the dialog is opened on the instance before it is handed to the renderer. Reaching a
    // pre-built instance needs Renderer.InstantiateComponent (which is what performs [Inject]
    // property injection); the sealed HtmlRenderer used by the sibling tests exposes no such seam,
    // so this subclasses the same static-rendering infrastructure HtmlRenderer itself wraps.
    //
    // BL0006 warns that RenderTree types are not for use outside the Blazor framework. Suppressed
    // here and only here, exactly as SessionDetailsPageEventAclTests does: this is test-only
    // scaffolding that never ships, and the cost of the warning coming true is a compile break in
    // one test file on an SDK bump. Production code must keep honouring BL0006.
#pragma warning disable BL0006
    private sealed class InstanceMountingHtmlRenderer(IServiceProvider services, ILoggerFactory loggerFactory)
        : StaticHtmlRenderer(services, loggerFactory)
    {
        /// <summary>Instantiates a component with its <c>[Inject]</c> properties resolved.</summary>
        /// <typeparam name="TComponent">Component type to create.</typeparam>
        /// <returns>The component instance, not yet attached to the renderer.</returns>
        public TComponent CreateComponent<TComponent>()
            where TComponent : IComponent =>
            (TComponent)InstantiateComponent(typeof(TComponent));
    }
#pragma warning restore BL0006

    private sealed class StubSnapshotService : IDashboardSnapshotService
    {
        /// <inheritdoc />
        public DashboardSnapshot GetSnapshot() => new(
            GeneratedAt: DateTimeOffset.UnixEpoch,
            GatewayStartedAt: DateTimeOffset.UnixEpoch,
            GatewayUptime: TimeSpan.Zero,
            GatewayStatus: "Healthy",
            GatewayVersion: "test",
            Sessions: [],
            Workers: [],
            Metrics: [],
            Faults: [],
            ApiKeys:
            [
                new DashboardApiKeySummary(
                    KeyId: TagsOnlyKeyId,
                    DisplayName: "Tags only",
                    Scopes: new HashSet<string>(StringComparer.Ordinal),
                    Constraints: ApiKeyConstraints.Empty with { DashboardTags = ["team-a", "team-b"] },
                    CreatedUtc: DateTimeOffset.UnixEpoch,
                    LastUsedUtc: null,
                    RevokedUtc: null),
                new DashboardApiKeySummary(
                    KeyId: UnconstrainedKeyId,
                    DisplayName: "Unconstrained",
                    Scopes: new HashSet<string>(StringComparer.Ordinal),
                    Constraints: ApiKeyConstraints.Empty,
                    CreatedUtc: DateTimeOffset.UnixEpoch,
                    LastUsedUtc: null,
                    RevokedUtc: null),
            ],
            Configuration: null!,
            Galaxy: null!);

        /// <inheritdoc />
        public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
            new IdleSnapshotFeed().WatchAsync(cancellationToken);
    }

    // Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
    // second snapshot mid-render.
    private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
    {
        /// <inheritdoc />
        public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);

            yield break;
        }
    }

    // Grants management so the create dialog is reachable; no test here exercises a mutation.
    private sealed class ManagingApiKeyService : IDashboardApiKeyManagementService
    {
        /// <inheritdoc />
        public bool CanManage(ClaimsPrincipal user) => true;

        /// <inheritdoc />
        public Task<DashboardApiKeyManagementResult> CreateAsync(
            ClaimsPrincipal user,
            DashboardApiKeyManagementRequest request,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));

        /// <inheritdoc />
        public Task<DashboardApiKeyManagementResult> RevokeAsync(
            ClaimsPrincipal user,
            string keyId,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));

        /// <inheritdoc />
        public Task<DashboardApiKeyManagementResult> RotateAsync(
            ClaimsPrincipal user,
            string keyId,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));

        /// <inheritdoc />
        public Task<DashboardApiKeyManagementResult> DeleteAsync(
            ClaimsPrincipal user,
            string keyId,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
    }

    private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
    {
        /// <inheritdoc />
        public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
            Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
                [new Claim(ClaimTypes.Name, "admin-user"), new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
                authenticationType: "test",
                nameType: ClaimTypes.Name,
                roleType: ClaimTypes.Role))));
    }
}
