using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.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.Security.Authentication;

public sealed class ApiKeyAdminCliRunnerTests : IDisposable
{
    private readonly List<TempDatabaseDirectory> _tempDirectories = [];
    /// <summary>Verifies that CreateKeyAsync creates an authenticating key and audits the action.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits()
    {
        await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
        ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
        StringWriter output = new();

        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.CreateKey,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: "operator01",
                DisplayName: "Operator",
                Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" },
                Constraints: ApiKeyConstraints.Empty),
            output,
            CancellationToken.None);

        string apiKey = ReadApiKey(output.ToString());

        IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
        ApiKeyVerification verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);

        Assert.True(verification.Succeeded);
        Assert.NotNull(verification.Identity);
        Assert.Equal("operator01", verification.Identity.KeyId);
        Assert.Contains("session:open", verification.Identity.Scopes);

        IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
            .GetRequiredService<IApiKeyAuditStore>()
            .ListRecentAsync(10, CancellationToken.None);

        Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01");
    }

    /// <summary>
    /// Verifies that a key created with an already-past <c>--expires</c> is rejected by the verifier
    /// — the CLI expiry wiring reaches the store and the library enforces it end-to-end.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateKeyAsync_WithPastExpiry_KeyIsRejectedByVerifier()
    {
        await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
        ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
        StringWriter output = new();

        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.CreateKey,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: "operator01",
                DisplayName: "Operator",
                Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open" },
                Constraints: ApiKeyConstraints.Empty,
                ExpiresUtc: DateTimeOffset.UtcNow - TimeSpan.FromMinutes(1)),
            output,
            CancellationToken.None);

        string apiKey = ReadApiKey(output.ToString());

        IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
        ApiKeyVerification verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);

        Assert.False(verification.Succeeded);
    }

    /// <summary>Verifies that ListKeysAsync does not print the raw secret.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ListKeysAsync_DoesNotPrintRawSecret()
    {
        await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
        ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
        string apiKey = await CreateKeyAsync(runner, "operator01");
        StringWriter listOutput = new();

        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.ListKeys,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: null,
                DisplayName: null,
                Scopes: new HashSet<string>(StringComparer.Ordinal),
                Constraints: ApiKeyConstraints.Empty),
            listOutput,
            CancellationToken.None);

        string listJson = listOutput.ToString();

        Assert.Contains("operator01", listJson, StringComparison.Ordinal);
        Assert.DoesNotContain(apiKey, listJson, StringComparison.Ordinal);
        Assert.DoesNotContain(ApiKeySecret(apiKey), listJson, StringComparison.Ordinal);
        Assert.DoesNotContain("secret_hash", listJson, StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>Verifies that RevokeKeyAsync causes the revoked key to fail verification and is audited.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits()
    {
        await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
        ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
        string apiKey = await CreateKeyAsync(runner, "operator01");

        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.RevokeKey,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: "operator01",
                DisplayName: null,
                Scopes: new HashSet<string>(StringComparer.Ordinal),
                Constraints: ApiKeyConstraints.Empty),
            TextWriter.Null,
            CancellationToken.None);

        ApiKeyVerification verification = await services
            .GetRequiredService<IApiKeyVerifier>()
            .VerifyAsync($"Bearer {apiKey}", CancellationToken.None);

        Assert.False(verification.Succeeded);
        Assert.Equal(ApiKeyFailure.KeyRevoked, verification.Failure);

        IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
            .GetRequiredService<IApiKeyAuditStore>()
            .ListRecentAsync(10, CancellationToken.None);

        Assert.Contains(auditRecords, record => record.EventType == "revoke-key" && record.KeyId == "operator01");
    }

    /// <summary>Verifies that RotateKeyAsync prints the new secret once and invalidates the old secret.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret()
    {
        await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
        ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
        string oldApiKey = await CreateKeyAsync(runner, "operator01");
        StringWriter rotateOutput = new();

        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.RotateKey,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: "operator01",
                DisplayName: null,
                Scopes: new HashSet<string>(StringComparer.Ordinal),
                Constraints: ApiKeyConstraints.Empty),
            rotateOutput,
            CancellationToken.None);

        string rotateJson = rotateOutput.ToString();
        string newApiKey = ReadApiKey(rotateJson);

        Assert.NotEqual(oldApiKey, newApiKey);
        Assert.Equal(1, CountOccurrences(rotateJson, newApiKey));

        IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
        ApiKeyVerification oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
        ApiKeyVerification newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);

        Assert.False(oldVerification.Succeeded);
        Assert.Equal(ApiKeyFailure.SecretMismatch, oldVerification.Failure);
        Assert.True(newVerification.Succeeded);
    }

    /// <summary>Verifies that CreateKeyAsync prints the raw secret exactly once.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce()
    {
        await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
        ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
        StringWriter output = new();

        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.CreateKey,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: "operator01",
                DisplayName: "Operator",
                Scopes: new HashSet<string>(StringComparer.Ordinal),
                Constraints: ApiKeyConstraints.Empty),
            output,
            CancellationToken.None);

        string json = output.ToString();
        string apiKey = ReadApiKey(json);

        Assert.Equal(1, CountOccurrences(json, apiKey));
        Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey)));
    }

    /// <summary>Verifies that API key constraints are persisted correctly.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CreateKeyAsync_WithConstraints_PersistsConstraints()
    {
        await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
        ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
        StringWriter output = new();

        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.CreateKey,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: "operator01",
                DisplayName: "Operator",
                Scopes: new HashSet<string>(StringComparer.Ordinal) { "metadata:read" },
                Constraints: ApiKeyConstraints.Empty with
                {
                    BrowseSubtrees = ["Area1/*"],
                    ReadAlarmOnly = true,
                }),
            output,
            CancellationToken.None);

        string apiKey = ReadApiKey(output.ToString());
        ApiKeyVerification verification = await services
            .GetRequiredService<IApiKeyVerifier>()
            .VerifyAsync($"Bearer {apiKey}", CancellationToken.None);

        Assert.True(verification.Succeeded);
        // The shared verifier returns the opaque constraints JSON; map it to the gateway identity so
        // the strongly-typed effective constraints round-trip can be asserted.
        ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity!);
        Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
        Assert.True(gatewayIdentity.EffectiveConstraints.ReadAlarmOnly);
    }


    private static async Task<string> CreateKeyAsync(ApiKeyAdminCliRunner runner, string keyId)
    {
        StringWriter output = new();
        await runner.RunAsync(
            new ApiKeyAdminCommand(
                Kind: ApiKeyAdminCommandKind.CreateKey,
                Json: true,
                SqlitePath: null,
                Pepper: null,
                KeyId: keyId,
                DisplayName: "Operator",
                Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open" },
                Constraints: ApiKeyConstraints.Empty),
            output,
            CancellationToken.None);

        return ReadApiKey(output.ToString());
    }

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

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

        return services.BuildServiceProvider(validateScopes: true);
    }

    /// <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();
    }

    private string CreateTempDatabasePath()
    {
        TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-auth-cli-tests");
        _tempDirectories.Add(directory);

        return directory.DatabasePath();
    }

    private static string ReadApiKey(string json)
    {
        using JsonDocument document = JsonDocument.Parse(json);

        return document.RootElement.GetProperty("ApiKey").GetString()
            ?? throw new InvalidOperationException("API key was not present in command output.");
    }

    private static string ApiKeySecret(string apiKey)
    {
        string[] parts = apiKey.Split('_', 3);

        return parts[2];
    }

    private static int CountOccurrences(string value, string pattern)
    {
        int count = 0;
        int index = 0;

        while ((index = value.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
        {
            count++;
            index += pattern.Length;
        }

        return count;
    }
}
