using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;

// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;

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

/// <summary>
/// Tests the gateway dashboard API-key management surface over the shared
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> admin commands and stores (the gateway is the donor). The service
/// is exercised against a real temporary SQLite store so the create/revoke/rotate/delete flow,
/// dashboard audit vocabulary, mxgw token format, duplicate-id rejection and revoke-before-delete
/// rule are all proven end-to-end.
/// </summary>
public sealed class DashboardApiKeyManagementServiceTests : IDisposable
{
    private readonly List<TempDatabaseDirectory> _tempDirectories = [];

    /// <summary>Verifies that unauthorized users cannot create API keys.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);

        DashboardApiKeyManagementResult result = await service.CreateAsync(
            new ClaimsPrincipal(new ClaimsIdentity()),
            CreateRequest(),
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Empty(await ListAsync(services));
    }

    /// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);

        DashboardApiKeyManagementResult result = await service.CreateAsync(
            CreateAuthorizedUser(),
            CreateRequest(),
            CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.NotNull(result.ApiKey);
        Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);

        // The freshly minted token authenticates against the same store and surfaces its scopes.
        ApiKeyVerification verification = await services
            .GetRequiredService<IApiKeyVerifier>()
            .VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None);
        Assert.True(verification.Succeeded);
        Assert.Contains(GatewayScopes.SessionOpen, verification.Identity!.Scopes);

        // Constraints round-trip through the opaque JSON blob.
        ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
        Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);

        IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
        // Phase 3: Actor = operator username ("alice"), Target = managed keyId ("operator01").
        Assert.Contains(audit, entry =>
            entry.EventType == "dashboard-create-key"
            && entry.KeyId == "alice");
    }

    /// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);

        await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
        DashboardApiKeyManagementResult duplicate = await service.CreateAsync(
            CreateAuthorizedUser(),
            CreateRequest(),
            CancellationToken.None);

        Assert.False(duplicate.Succeeded);
        Assert.Contains("already exists", duplicate.Message, StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);
        await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);

        DashboardApiKeyManagementResult result = await service.RevokeAsync(
            CreateAuthorizedUser(),
            "operator01",
            CancellationToken.None);

        Assert.True(result.Succeeded);
        ApiKeyListItem key = Assert.Single(await ListAsync(services));
        Assert.NotNull(key.RevokedUtc);
        IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
        // Phase 3: Actor = operator username; the dashboard-revoke-key event surfaces KeyId = "alice"
        // (the operator) and Details = "revoked".
        Assert.Contains(audit, entry =>
            entry.EventType == "dashboard-revoke-key"
            && entry.KeyId == "alice"
            && entry.Details == "revoked");
    }

    /// <summary>Verifies that authorized users can rotate a key's secret with audit trail.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);
        DashboardApiKeyManagementResult created = await service.CreateAsync(
            CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);

        DashboardApiKeyManagementResult result = await service.RotateAsync(
            CreateAuthorizedUser(),
            "operator01",
            CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.NotNull(result.ApiKey);
        Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
        Assert.NotEqual(created.ApiKey, result.ApiKey);

        // Old token no longer authenticates; new one does.
        IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
        Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded);
        Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);

        IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
        // Phase 3: Actor = operator username ("alice").
        Assert.Contains(audit, entry =>
            entry.EventType == "dashboard-rotate-key"
            && entry.KeyId == "alice"
            && entry.Details == "rotated");
    }

    /// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);
        await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
        await service.RevokeAsync(CreateAuthorizedUser(), "operator01", CancellationToken.None);

        DashboardApiKeyManagementResult result = await service.DeleteAsync(
            CreateAuthorizedUser(),
            "operator01",
            CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.Empty(await ListAsync(services));
        IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
        // Phase 3: Actor = operator username ("alice").
        Assert.Contains(audit, entry =>
            entry.EventType == "dashboard-delete-key"
            && entry.KeyId == "alice"
            && entry.Details == "deleted");
    }

    /// <summary>
    /// When the key is still active (not revoked), the delete is refused but a
    /// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
    /// written — audit completeness for refused deletes.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);
        await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);

        DashboardApiKeyManagementResult result = await service.DeleteAsync(
            CreateAuthorizedUser(),
            "operator01",
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);

        IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
        ApiKeyAuditEntry deleteEntry = Assert.Single(
            audit, entry => entry.EventType == "dashboard-delete-key");
        // Phase 3: Actor = operator username ("alice"), not the managed keyId.
        Assert.Equal("alice", deleteEntry.KeyId);
        Assert.Equal("not-found-or-active", deleteEntry.Details);
    }

    /// <summary>A blank key id fails validation before any store or audit call runs.</summary>
    /// <param name="blankKeyId">A blank or whitespace key identifier.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData("")]
    [InlineData(" ")]
    [InlineData("\t")]
    public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);

        DashboardApiKeyManagementResult result = await service.DeleteAsync(
            CreateAuthorizedUser(),
            blankKeyId,
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Empty(await ListAuditAsync(services));
    }

    /// <summary>
    /// The dashboard create path must reject a request carrying a
    /// non-canonical scope string rather than persisting a key whose scope the authorization
    /// resolver never matches.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateAsync_UnknownScope_DoesNotCreate()
    {
        await using ServiceProvider services = BuildServices();
        DashboardApiKeyManagementService service = CreateService(services);

        DashboardApiKeyManagementRequest request = CreateRequest() with
        {
            Scopes = new HashSet<string>(
                [GatewayScopes.SessionOpen, "invoke", "metadata"],
                StringComparer.Ordinal),
        };

        DashboardApiKeyManagementResult result = await service.CreateAsync(
            CreateAuthorizedUser(),
            request,
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Empty(await ListAsync(services));
    }

    /// <summary>
    /// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
    /// the operator username as Actor and the managed keyId as Target.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
    {
        await using ServiceProvider services = BuildServices();

        // Wire a recording writer so we can inspect the canonical AuditEvent directly (bypassing
        // the CanonicalForwardingApiKeyAuditStore round-trip that ListAuditAsync uses).
        RecordingAuditWriter recordingWriter = new();
        DefaultHttpContext httpContext = new();
        httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;

        DashboardApiKeyManagementService service = new(
            new DashboardApiKeyAuthorization(),
            services.GetRequiredService<ApiKeyAdminCommands>(),
            services.GetRequiredService<IApiKeyAdminStore>(),
            recordingWriter,
            new HttpContextAccessor { HttpContext = httpContext });

        await service.CreateAsync(
            CreateAuthorizedUser(),
            CreateRequest(),
            CancellationToken.None);

        // The dashboard-create-key event emitted directly by the service (not the library's
        // create-key event forwarded via the adapter) must have Actor = operator username and
        // Target = managed keyId.
        ZB.MOM.WW.Audit.AuditEvent dashboardEvent = Assert.Single(
            recordingWriter.Events,
            e => e.Action == "dashboard-create-key");
        Assert.Equal("alice", dashboardEvent.Actor);
        Assert.Equal("operator01", dashboardEvent.Target);
        Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, dashboardEvent.Outcome);
    }

    private DashboardApiKeyManagementService CreateService(ServiceProvider services)
    {
        DefaultHttpContext httpContext = new();
        httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;

        return new DashboardApiKeyManagementService(
            new DashboardApiKeyAuthorization(),
            services.GetRequiredService<ApiKeyAdminCommands>(),
            services.GetRequiredService<IApiKeyAdminStore>(),
            services.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
            new HttpContextAccessor { HttpContext = httpContext });
    }

    private ServiceProvider BuildServices()
    {
        TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-dashboard-apikey-tests");
        _tempDirectories.Add(directory);

        IConfigurationRoot configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(
                new Dictionary<string, string?>
                {
                    ["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(),
                    ["MxGateway:ApiKeyPepper"] = "test-pepper"
                })
            .Build();

        ServiceCollection services = new();
        services.AddSingleton<IConfiguration>(configuration);
        services.AddGatewayConfiguration(configuration);
        services.AddSqliteAuthStore(configuration);

        ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);

        // Production migrates the schema via the migration hosted service at startup; in these
        // DI-only tests no host runs, so apply the (idempotent) migration up front.
        provider.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>()
            .MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();

        return provider;
    }

    private static Task<IReadOnlyList<ApiKeyListItem>> ListAsync(ServiceProvider services)
    {
        return services.GetRequiredService<IApiKeyAdminStore>().ListAsync(CancellationToken.None);
    }

    private static Task<IReadOnlyList<ApiKeyAuditEntry>> ListAuditAsync(ServiceProvider services)
    {
        return services.GetRequiredService<IApiKeyAuditStore>().ListRecentAsync(50, CancellationToken.None);
    }

    private static DashboardApiKeyManagementRequest CreateRequest()
    {
        return new DashboardApiKeyManagementRequest(
            KeyId: "operator01",
            DisplayName: "Operator",
            Scopes: new HashSet<string>([GatewayScopes.SessionOpen], StringComparer.Ordinal),
            Constraints: ApiKeyConstraints.Empty with
            {
                BrowseSubtrees = ["Area1/*"],
            });
    }

    private static ClaimsPrincipal CreateAuthorizedUser()
    {
        // Phase 3: include ZbClaimTypes.Username so ResolveOperatorActor picks up the LDAP
        // login name ("alice") as the audit Actor. The keyId ("operator01") is the Target.
        ClaimsIdentity identity = new(
            [
                new Claim(ClaimTypes.Role, DashboardRoles.Admin),
                new Claim(ZbClaimTypes.Username, "alice"),
            ],
            DashboardAuthenticationDefaults.AuthenticationScheme,
            ClaimTypes.Name,
            ClaimTypes.Role);

        return new ClaimsPrincipal(identity);
    }

    /// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
    public void Dispose()
    {
        foreach (TempDatabaseDirectory directory in _tempDirectories)
        {
            directory.Dispose();
        }

        _tempDirectories.Clear();
    }

    /// <summary>In-memory <see cref="ZB.MOM.WW.Audit.IAuditWriter"/> that records every event.</summary>
    private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
    {
        /// <summary>Gets the recorded canonical audit events.</summary>
        public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = [];

        /// <summary>Records the audit event in memory instead of writing it to a real sink.</summary>
        /// <param name="auditEvent">The audit event to record.</param>
        /// <param name="cancellationToken">A token to observe for cancellation requests.</param>
        /// <returns>A task that represents the asynchronous operation.</returns>
        public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
        {
            Events.Add(auditEvent);
            return Task.CompletedTask;
        }
    }
}
