using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;

namespace StellaOps.Authority.Plugin.Standard.Tests;

public class StandardPluginRegistrarTests
{
    [Fact]
    public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
    {
        using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
        var client = new MongoClient(runner.ConnectionString);
        var database = client.GetDatabase("registrar-tests");

        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["passwordPolicy:minimumLength"] = "8",
                ["passwordPolicy:requireDigit"] = "false",
                ["passwordPolicy:requireSymbol"] = "false",
                ["lockout:enabled"] = "false",
                ["passwordHashing:memorySizeInKib"] = "8192",
                ["passwordHashing:iterations"] = "2",
                ["passwordHashing:parallelism"] = "1",
                ["bootstrapUser:username"] = "bootstrap",
                ["bootstrapUser:password"] = "Bootstrap1!",
                ["bootstrapUser:requirePasswordReset"] = "true"
            })
            .Build();

        var manifest = new AuthorityPluginManifest(
            "standard",
            "standard",
            true,
            typeof(StandardPluginRegistrar).Assembly.GetName().Name,
            typeof(StandardPluginRegistrar).Assembly.Location,
            new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
            new Dictionary<string, string?>(),
            "standard.yaml");

        var pluginContext = new AuthorityPluginContext(manifest, configuration);
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddSingleton<IMongoDatabase>(database);
        services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());

        var registrar = new StandardPluginRegistrar();
        registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));

        var provider = services.BuildServiceProvider();
        var hostedServices = provider.GetServices<IHostedService>();
        foreach (var hosted in hostedServices)
        {
            if (hosted is StandardPluginBootstrapper bootstrapper)
            {
                await bootstrapper.StartAsync(CancellationToken.None);
            }
        }

        var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
        Assert.Equal("standard", plugin.Type);
        Assert.True(plugin.Capabilities.SupportsPassword);
        Assert.False(plugin.Capabilities.SupportsMfa);
        Assert.True(plugin.Capabilities.SupportsClientProvisioning);

        var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None);
        Assert.True(verification.Succeeded);
        Assert.True(verification.User?.RequiresPasswordReset);
    }

    [Fact]
    public void Register_LogsWarning_WhenPasswordPolicyWeaker()
    {
        using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
        var client = new MongoClient(runner.ConnectionString);
        var database = client.GetDatabase("registrar-password-policy");

        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["passwordPolicy:minimumLength"] = "6",
                ["passwordPolicy:requireUppercase"] = "false",
                ["passwordPolicy:requireLowercase"] = "false",
                ["passwordPolicy:requireDigit"] = "false",
                ["passwordPolicy:requireSymbol"] = "false"
            })
            .Build();

        var manifest = new AuthorityPluginManifest(
            "standard",
            "standard",
            true,
            typeof(StandardPluginRegistrar).Assembly.GetName().Name,
            typeof(StandardPluginRegistrar).Assembly.Location,
            new[] { AuthorityPluginCapabilities.Password },
            new Dictionary<string, string?>(),
            "standard.yaml");

        var pluginContext = new AuthorityPluginContext(manifest, configuration);
        var services = new ServiceCollection();
        var loggerProvider = new CapturingLoggerProvider();
        services.AddLogging(builder => builder.AddProvider(loggerProvider));
        services.AddSingleton<IMongoDatabase>(database);
        services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());

        var registrar = new StandardPluginRegistrar();
        registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));

        using var provider = services.BuildServiceProvider();
        _ = provider.GetRequiredService<StandardUserCredentialStore>();

        Assert.Contains(loggerProvider.Entries, entry =>
            entry.Level == LogLevel.Warning &&
            entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
            entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Register_ForcesPasswordCapability_WhenManifestMissing()
    {
        using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
        var client = new MongoClient(runner.ConnectionString);
        var database = client.GetDatabase("registrar-capabilities");

        var configuration = new ConfigurationBuilder().Build();
        var manifest = new AuthorityPluginManifest(
            "standard",
            "standard",
            true,
            typeof(StandardPluginRegistrar).Assembly.GetName().Name,
            typeof(StandardPluginRegistrar).Assembly.Location,
            Array.Empty<string>(),
            new Dictionary<string, string?>(),
            "standard.yaml");

        var pluginContext = new AuthorityPluginContext(manifest, configuration);
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddSingleton<IMongoDatabase>(database);
        services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
        services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
        services.AddSingleton(TimeProvider.System);

        var registrar = new StandardPluginRegistrar();
        registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));

        using var provider = services.BuildServiceProvider();
        var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();

        Assert.True(plugin.Capabilities.SupportsPassword);
    }

    [Fact]
    public void Register_Throws_WhenBootstrapConfigurationIncomplete()
    {
        using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
        var client = new MongoClient(runner.ConnectionString);
        var database = client.GetDatabase("registrar-bootstrap-validation");

        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["bootstrapUser:username"] = "bootstrap"
            })
            .Build();

        var manifest = new AuthorityPluginManifest(
            "standard",
            "standard",
            true,
            typeof(StandardPluginRegistrar).Assembly.GetName().Name,
            typeof(StandardPluginRegistrar).Assembly.Location,
            new[] { AuthorityPluginCapabilities.Password },
            new Dictionary<string, string?>(),
            "standard.yaml");

        var pluginContext = new AuthorityPluginContext(manifest, configuration);
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddSingleton<IMongoDatabase>(database);
        services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());

        var registrar = new StandardPluginRegistrar();
        registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));

        using var provider = services.BuildServiceProvider();
        Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IIdentityProviderPlugin>());
    }

    [Fact]
    public void Register_NormalizesTokenSigningKeyDirectory()
    {
        using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
        var client = new MongoClient(runner.ConnectionString);
        var database = client.GetDatabase("registrar-token-signing");

        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["tokenSigning:keyDirectory"] = "../keys"
            })
            .Build();

        var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(configDir);

        try
        {
            var configPath = Path.Combine(configDir, "standard.yaml");
            var manifest = new AuthorityPluginManifest(
                "standard",
                "standard",
                true,
                typeof(StandardPluginRegistrar).Assembly.GetName().Name,
                typeof(StandardPluginRegistrar).Assembly.Location,
                new[] { AuthorityPluginCapabilities.Password },
                new Dictionary<string, string?>(),
                configPath);

            var pluginContext = new AuthorityPluginContext(manifest, configuration);
            var services = new ServiceCollection();
            services.AddLogging();
            services.AddSingleton<IMongoDatabase>(database);
            services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());

            var registrar = new StandardPluginRegistrar();
            registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));

            using var provider = services.BuildServiceProvider();
            var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
            var options = optionsMonitor.Get("standard");

            var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
            Assert.Equal(expected, options.TokenSigning.KeyDirectory);
        }
        finally
        {
            if (Directory.Exists(configDir))
            {
                Directory.Delete(configDir, recursive: true);
            }
        }
    }
}

internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message);

internal sealed class CapturingLoggerProvider : ILoggerProvider
{
    public List<CapturedLogEntry> Entries { get; } = new();

    public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);

    public void Dispose()
    {
    }

    private sealed class CapturingLogger : ILogger
    {
        private readonly string category;
        private readonly List<CapturedLogEntry> entries;

        public CapturingLogger(string category, List<CapturedLogEntry> entries)
        {
            this.category = category;
            this.entries = entries;
        }

        public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
        {
            entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
        }

        private sealed class NullScope : IDisposable
        {
            public static readonly NullScope Instance = new();

            public void Dispose()
            {
            }
        }
    }
}

internal sealed class StubRevocationStore : IAuthorityRevocationStore
{
    public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
        => ValueTask.CompletedTask;

    public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
        => ValueTask.FromResult(false);

    public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
        => ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}

internal sealed class InMemoryClientStore : IAuthorityClientStore
{
    private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);

    public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
    {
        clients.TryGetValue(clientId, out var document);
        return ValueTask.FromResult(document);
    }

    public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
    {
        clients[document.ClientId] = document;
        return ValueTask.CompletedTask;
    }

    public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
        => ValueTask.FromResult(clients.Remove(clientId));
}
