using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Driver;
using MongoDB.Bson;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Feedser.Testing;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;
using Xunit;

namespace StellaOps.Authority.Tests.OpenIddict;

[Collection("mongo-fixture")]
public sealed class TokenPersistenceIntegrationTests
{
    private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.Persistence");
    private readonly MongoIntegrationFixture fixture;

    public TokenPersistenceIntegrationTests(MongoIntegrationFixture fixture)
        => this.fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));

    [Fact]
    public async Task HandleClientCredentials_PersistsTokenInMongo()
    {
        await ResetCollectionsAsync();

        var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero);
        var clock = new FakeTimeProvider(issuedAt);

        await using var provider = await BuildMongoProviderAsync(clock);

        var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
        var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();

        var clientDocument = TestHelpers.CreateClient(
            secret: "s3cr3t!",
            allowedGrantTypes: "client_credentials",
            allowedScopes: "jobs:trigger jobs:read");

        await clientStore.UpsertAsync(clientDocument, CancellationToken.None);

        var registry = TestHelpers.CreateRegistry(
            withClientProvisioning: true,
            clientDescriptor: TestHelpers.CreateDescriptor(clientDocument));

        var authSink = new TestAuthEventSink();
        var metadataAccessor = new TestRateLimiterMetadataAccessor();
        var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<ValidateClientCredentialsHandler>.Instance);
        var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
        var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);

        var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
        transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15);

        var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
        await validateHandler.HandleAsync(validateContext);
        Assert.False(validateContext.IsRejected);

        var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
        await handleHandler.HandleAsync(handleContext);

        Assert.True(handleContext.IsRequestHandled);
        var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal);
        var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
        Assert.False(string.IsNullOrWhiteSpace(tokenId));

        var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
        {
            Principal = principal,
            AccessTokenPrincipal = principal
        };

        await persistHandler.HandleAsync(signInContext);

        var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None);
        Assert.NotNull(stored);
        Assert.Equal(clientDocument.ClientId, stored!.ClientId);
        Assert.Equal(OpenIddictConstants.TokenTypeHints.AccessToken, stored.Type);
        Assert.Equal("valid", stored.Status);
        Assert.Equal(issuedAt, stored.CreatedAt);
        Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt);
        Assert.Equal(new[] { "jobs:trigger" }, stored.Scope);
    }

    [Fact]
    public async Task ValidateAccessTokenHandler_RejectsRevokedRefreshTokenPersistedInMongo()
    {
        await ResetCollectionsAsync();

        var now = new DateTimeOffset(2025, 10, 10, 14, 0, 0, TimeSpan.Zero);
        var clock = new FakeTimeProvider(now);

        await using var provider = await BuildMongoProviderAsync(clock);

        var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
        var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();

        var clientDocument = TestHelpers.CreateClient(
            secret: null,
            clientType: "public",
            allowedGrantTypes: "password refresh_token",
            allowedScopes: "openid profile jobs:read");

        await clientStore.UpsertAsync(clientDocument, CancellationToken.None);

        var descriptor = TestHelpers.CreateDescriptor(clientDocument);
        var userDescriptor = new AuthorityUserDescriptor("subject-1", "alice", displayName: "Alice", requiresPasswordReset: false);

        var plugin = TestHelpers.CreatePlugin(
            name: clientDocument.Plugin ?? "standard",
            supportsClientProvisioning: true,
            descriptor,
            userDescriptor);

        var registry = new AuthorityIdentityProviderRegistry(
            new[] { plugin },
            NullLogger<AuthorityIdentityProviderRegistry>.Instance);

        const string revokedTokenId = "refresh-token-1";
        var refreshToken = new AuthorityTokenDocument
        {
            TokenId = revokedTokenId,
            Type = OpenIddictConstants.TokenTypeHints.RefreshToken,
            SubjectId = userDescriptor.SubjectId,
            ClientId = clientDocument.ClientId,
            Scope = new List<string> { "openid", "profile" },
            Status = "valid",
            CreatedAt = now.AddMinutes(-5),
            ExpiresAt = now.AddHours(4),
            ReferenceId = "refresh-reference-1"
        };

        await tokenStore.InsertAsync(refreshToken, CancellationToken.None);

        var revokedAt = now.AddMinutes(1);
        await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, "manual", null, null, CancellationToken.None);

        var metadataAccessor = new TestRateLimiterMetadataAccessor();
        var auditSink = new TestAuthEventSink();
        var handler = new ValidateAccessTokenHandler(
            tokenStore,
            clientStore,
            registry,
            metadataAccessor,
            auditSink,
            clock,
            TestActivitySource,
            NullLogger<ValidateAccessTokenHandler>.Instance);

        var transaction = new OpenIddictServerTransaction
        {
            EndpointType = OpenIddictServerEndpointType.Token,
            Options = new OpenIddictServerOptions(),
            Request = new OpenIddictRequest
            {
                GrantType = OpenIddictConstants.GrantTypes.RefreshToken
            }
        };

        var principal = TestHelpers.CreatePrincipal(
            clientDocument.ClientId,
            revokedTokenId,
            plugin.Name,
            userDescriptor.SubjectId);

        var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
        {
            Principal = principal,
            TokenId = revokedTokenId
        };

        await handler.HandleAsync(context);

        Assert.True(context.IsRejected);
        Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);

        var stored = await tokenStore.FindByTokenIdAsync(revokedTokenId, CancellationToken.None);
        Assert.NotNull(stored);
        Assert.Equal("revoked", stored!.Status);
        Assert.Equal(revokedAt, stored.RevokedAt);
        Assert.Equal("manual", stored.RevokedReason);
    }

    [Fact]
    public async Task RecordUsageAsync_FlagsSuspectedReplay_OnNewDeviceFingerprint()
    {
        await ResetCollectionsAsync();

        var issuedAt = new DateTimeOffset(2025, 10, 14, 8, 0, 0, TimeSpan.Zero);
        var clock = new FakeTimeProvider(issuedAt);

        await using var provider = await BuildMongoProviderAsync(clock);

        var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();

        var tokenDocument = new AuthorityTokenDocument
        {
            TokenId = "token-replay",
            Type = OpenIddictConstants.TokenTypeHints.AccessToken,
            ClientId = "client-1",
            Status = "valid",
            CreatedAt = issuedAt,
            Devices = new List<BsonDocument>
            {
                new BsonDocument
                {
                    { "remoteAddress", "10.0.0.1" },
                    { "userAgent", "agent/1.0" },
                    { "firstSeen", BsonDateTime.Create(issuedAt.AddMinutes(-10).UtcDateTime) },
                    { "lastSeen", BsonDateTime.Create(issuedAt.AddMinutes(-5).UtcDateTime) },
                    { "useCount", 2 }
                }
            }
        };

        await tokenStore.InsertAsync(tokenDocument, CancellationToken.None);

        var result = await tokenStore.RecordUsageAsync(
            "token-replay",
            remoteAddress: "10.0.0.2",
            userAgent: "agent/2.0",
            observedAt: clock.GetUtcNow(),
            CancellationToken.None);

        Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, result.Status);

        var stored = await tokenStore.FindByTokenIdAsync("token-replay", CancellationToken.None);
        Assert.NotNull(stored);
        Assert.Equal(2, stored!.Devices?.Count);
        Assert.Contains(stored.Devices!, doc =>
        {
            var remote = doc.TryGetValue("remoteAddress", out var ra) && ra.IsString ? ra.AsString : null;
            var agentValue = doc.TryGetValue("userAgent", out var ua) && ua.IsString ? ua.AsString : null;
            return remote == "10.0.0.2" && agentValue == "agent/2.0";
        });
    }

    private async Task ResetCollectionsAsync()
    {
        var tokens = fixture.Database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
        await tokens.DeleteManyAsync(Builders<AuthorityTokenDocument>.Filter.Empty);

        var clients = fixture.Database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
        await clients.DeleteManyAsync(Builders<AuthorityClientDocument>.Filter.Empty);
    }

    private async Task<ServiceProvider> BuildMongoProviderAsync(FakeTimeProvider clock)
    {
        var services = new ServiceCollection();
        services.AddSingleton<TimeProvider>(clock);
        services.AddLogging();
        services.AddAuthorityMongoStorage(options =>
        {
            options.ConnectionString = fixture.Runner.ConnectionString;
            options.DatabaseName = fixture.Database.DatabaseNamespace.DatabaseName;
            options.CommandTimeout = TimeSpan.FromSeconds(5);
        });

        var provider = services.BuildServiceProvider();

        var initializer = provider.GetRequiredService<AuthorityMongoInitializer>();
        var database = provider.GetRequiredService<IMongoDatabase>();
        await initializer.InitialiseAsync(database, CancellationToken.None);

        return provider;
    }
}
