using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using DodoSSH.Infrastructure;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;

namespace DodoSSH.SystemTests;

/// <summary>
/// The whole stack the end-to-end suite talks to, brought up per run and thrown away after.
/// </summary>
/// <remarks>
/// <para>
/// PostgreSQL, Keycloak and an OpenSSH server in containers; the API as a child process out of its own
/// build output. Nothing is stubbed and nothing has to be started by hand, which is what allows this suite
/// to run in CI rather than only on the machine of whoever remembered the commands.
/// </para>
/// <para>
/// It deliberately consumes the artefacts that ship — the realm file from <c>deploy/keycloak</c>, the
/// committed EF migrations, the API's own <c>appsettings</c> — rather than an arrangement written to match
/// them. That distinction is the whole value of the suite: a fixture that restates the configuration agrees
/// with itself by construction, and both bugs this suite has found were in the committed configuration,
/// invisible to every other test.
/// </para>
/// <para>
/// One thing cannot come from a committed file: the identity provider's port is assigned when its container
/// starts, so <c>Oidc:Authority</c> is overridden to match. Everything the authority points at — the realm,
/// the public client, the loopback redirect URI, the audience mapper — is still the real file, and the URL
/// is the one value a self-hosted deployment is guaranteed to change anyway.
/// </para>
/// <para>
/// A fresh Keycloak per run also removes a footgun the development stack has: <c>--import-realm</c> skips a
/// realm that already exists, so editing the realm file and restarting the container serves the old
/// configuration and looks exactly like the edit being wrong.
/// </para>
/// </remarks>
public sealed class DevStack : IAsyncLifetime
{
    /// <summary>The realm the committed file defines.</summary>
    internal const string Realm = "dodossh";

    /// <summary>The account the OpenSSH container is built around.</summary>
    internal const string SshUsername = "dodo";

    /// <summary>Its password. A container that lives for one test run, on a random loopback port.</summary>
    internal const string SshPassword = "correct-horse-battery-staple";

    /// <remarks>
    /// Pinned to the same version as <c>deploy/docker-compose.dev.yml</c>. Two places, deliberately: the
    /// suite cannot read the compose file without becoming a compose parser, and a version skew between
    /// them shows up here as a failure rather than as a difference nobody notices.
    /// </remarks>
    private const string KeycloakImage = "quay.io/keycloak/keycloak:26.4";

    private const string PostgresImage = "postgres:18-alpine";
    private const string SshImage = "linuxserver/openssh-server:latest";

    private const ushort KeycloakPort = 8080;
    private const ushort SshPort = 2222;

    /// <remarks>
    /// Generous, and covering image pulls on a cold machine. It exists so that a daemon that never answers
    /// fails with a sentence rather than hanging until the test host is killed.
    /// </remarks>
    private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(8);

    private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder(PostgresImage)
        .WithDatabase("dodossh")
        .WithUsername("dodossh")
        .WithPassword("dodossh")

        // Matching the compose stack: deterministic collation, as the server's InvariantGlobalization
        // implies. Ordering differences between a C locale and a language-aware one are exactly the kind
        // of thing that behaves in development and not in production.
        .WithEnvironment("POSTGRES_INITDB_ARGS", "--encoding=UTF8 --locale=C")
        .Build();

    private readonly IContainer keycloak = new ContainerBuilder(KeycloakImage)
        // start-dev, as the development stack does: no HTTPS enforcement and throwaway H2 state. The realm
        // is imported from the committed file, which is the artefact under test here.
        .WithCommand("start-dev", "--import-realm")
        .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
        .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin")
        .WithPortBinding(KeycloakPort, assignRandomHostPort: true)
        .WithResourceMapping(
            new FileInfo(Path.Combine(AppContext.BaseDirectory, "realm-dodossh.json")),
            "/opt/keycloak/data/import/")

        // Serving the realm's discovery document is the only readiness that means anything: the port opens
        // well before the import finishes, and an unimported realm answers 404 to every request the client
        // is about to make.
        .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => request
            .ForPort(KeycloakPort)
            .ForPath($"/realms/{Realm}/.well-known/openid-configuration")))
        .Build();

    private readonly IContainer sshd = new ContainerBuilder(SshImage)
        .WithEnvironment("PUID", "1000")
        .WithEnvironment("PGID", "1000")
        .WithEnvironment("USER_NAME", SshUsername)
        .WithEnvironment("USER_PASSWORD", SshPassword)
        .WithEnvironment("PASSWORD_ACCESS", "true")
        .WithEnvironment("SUDO_ACCESS", "false")
        .WithPortBinding(SshPort, assignRandomHostPort: true)

        // A published port is not readiness: the entrypoint generates host keys and rewrites sshd_config
        // first. This is the check that actually proves sshd is accepting.
        .WithWaitStrategy(Wait.ForUnixContainer()
            .UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
        .Build();

    private ApiProcess? api;

    /// <summary>Where the API is listening, which is the only URL the client is told.</summary>
    internal Uri ApiBaseUrl => Api.BaseUrl;

    /// <summary>The identity provider's base URL, port included.</summary>
    internal Uri KeycloakBaseUrl => new(string.Create(
        CultureInfo.InvariantCulture,
        $"http://{keycloak.Hostname}:{keycloak.GetMappedPublicPort(KeycloakPort)}"));

    /// <summary>The issuer the API is configured to trust.</summary>
    internal Uri Authority => new(KeycloakBaseUrl, $"/realms/{Realm}");

    /// <summary>Where the OpenSSH container answers.</summary>
    internal string SshHostname => sshd.Hostname;

    /// <summary>Its mapped port.</summary>
    internal int SshHostPort => sshd.GetMappedPublicPort(SshPort);

    /// <summary>The API's console output, for explaining a failure.</summary>
    internal string ApiLog => Api.Log;

    private ApiProcess Api => api
        ?? throw new InvalidOperationException("The stack has not been started.");

    /// <inheritdoc />
    public async ValueTask InitializeAsync()
    {
        using var deadline = new CancellationTokenSource(StartupTimeout);

        // Concurrently: none of the three depends on another, and Keycloak alone takes longer than the
        // other two together.
        await Task.WhenAll(
            postgres.StartAsync(deadline.Token),
            keycloak.StartAsync(deadline.Token),
            sshd.StartAsync(deadline.Token));

        await MigrateAsync(deadline.Token);

        api = await ApiProcess.StartAsync(postgres.GetConnectionString(), Authority, deadline.Token);
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        // The API first: it holds connections to PostgreSQL, and tearing the database out from under it
        // produces a page of exceptions that look like a failure rather than a shutdown.
        if (api is not null)
        {
            await api.DisposeAsync();
        }

        await Task.WhenAll(
            postgres.DisposeAsync().AsTask(),
            keycloak.DisposeAsync().AsTask(),
            sshd.DisposeAsync().AsTask());
    }

    /// <summary>
    /// The account the committed realm file declares, which is the one the README tells a user to sign in
    /// as.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This suite used to create its own account through Keycloak's admin API instead, and that was a hole
    /// rather than a detail: Keycloak grants <c>default-roles-&lt;realm&gt;</c> automatically to a user
    /// created through the admin API, and grants nothing at all to a user declared in a realm import
    /// without an explicit <c>realmRoles</c>. So the suite exercised a provisioning path no real user
    /// takes, and passed while the realm's own <c>alice</c> could not complete a sign-in — the token
    /// endpoint answered <em>"Offline tokens not allowed for the user or client"</em>, because
    /// <c>offline_access</c> lives inside that composite and the desktop client asks for it.
    /// </para>
    /// <para>
    /// Using the realm's account closes that, at the cost of one constraint: enrollment happens once per
    /// account and cannot be undone from the client, so this only works because the Keycloak and PostgreSQL
    /// containers are both per-run and this assembly holds one test. A second test needing a second
    /// identity must declare it in the realm file — with its roles — rather than mint one at runtime, or it
    /// reopens exactly this hole.
    /// </para>
    /// </remarks>
    internal static DevStackUser RealmUser { get; } = new("alice", "alice");

    /// <remarks>
    /// Through the same design-time factory <c>dotnet ef database update</c> uses, so the migrations and
    /// the history table land exactly where the API expects them. If they did not, the API's readiness
    /// check would refuse to pass and <see cref="ApiProcess"/> would say so — the schema is verified by the
    /// server's own opinion of it rather than by this fixture's.
    /// </remarks>
    private async Task MigrateAsync(CancellationToken cancellationToken)
    {
        var context = DodoDbContextFactory.Create(postgres.GetConnectionString());
        await using var scope = context.ConfigureAwait(false);

        await context.Database.MigrateAsync(cancellationToken);
    }

}

/// <summary>Credentials for one identity in the development realm.</summary>
internal sealed record DevStackUser(string Username, string Password);
