using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;

namespace DodoSSH.Api.Tests;

/// <summary>
/// A stand-in identity provider: OIDC discovery, JWKS, and token minting.
/// </summary>
/// <remarks>
/// Deliberately not a <c>TestAuthHandler</c> that short-circuits authentication. Tokens are signed
/// with a real RSA key and validated by the application's own JwtBearer pipeline, so issuer,
/// audience, signature, lifetime and claim handling are all genuinely exercised. Bypassing that
/// would hide precisely the claim-mapping mistakes that cause real authorization holes.
/// </remarks>
public sealed class StubIdentityProvider : IDisposable
{
    private const string KeyId = "dodossh-test-key";

    private readonly WireMockServer server;
    private readonly RsaSecurityKey signingKey;

    public StubIdentityProvider()
    {
        var rsa = RSA.Create(2048);
        signingKey = new RsaSecurityKey(rsa) { KeyId = KeyId };

        // Loopback explicitly: WireMock's default listens on every interface, which makes Windows Firewall
        // prompt the first time each test executable runs — per binary path, so a new worktree or
        // configuration asks again. Port 0 still picks a free port and reports it on server.Url, which is
        // what Authority below is built from, so the issuer the tokens claim follows the binding.
        server = WireMockServer.Start(new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
        Authority = server.Url!.TrimEnd('/');

        StubDiscovery();
        StubJwks();
    }

    /// <summary>Issuer URL, matching what the tokens claim.</summary>
    public string Authority { get; }

    /// <summary>Audience the API is configured to expect on access tokens.</summary>
    public static string Audience => "dodossh-api";

    /// <summary>
    /// The public client identifier, and therefore the audience of an ID token.
    /// </summary>
    /// <remarks>
    /// Distinct from <see cref="Audience"/> on purpose. An ID token is audienced to the client that
    /// requested it, never to the API — validating a binding token against the API audience would
    /// reject every genuine enrollment, and a test that used one audience for both would not notice.
    /// </remarks>
    public static string ClientId => "dodossh-desktop";

    /// <summary>
    /// Mints a signed access token.
    /// </summary>
    /// <param name="subject">The <c>sub</c> claim — the stable user identifier.</param>
    /// <param name="email">Optional email claim.</param>
    /// <param name="name">Optional display name claim.</param>
    /// <param name="audience">Override the audience, to test rejection.</param>
    /// <param name="issuer">Override the issuer, to test rejection.</param>
    /// <param name="expires">Override expiry, to test rejection.</param>
    /// <remarks>
    /// <c>email_verified</c> travels beside an address because a real provider sends it, and for no
    /// other reason: the server reads it nowhere. It did once — to decide whether a pending team
    /// invitation addressed to that address could be claimed — and there are no invitations. Keeping it
    /// in the token keeps this stub honest about the shape of a real one.
    /// </remarks>
    public string MintToken(
        string subject,
        string? email = null,
        string? name = null,
        string? audience = null,
        string? issuer = null,
        DateTime? expires = null)
    {
        var now = TimeProvider.System.GetUtcNow().UtcDateTime;

        var claims = new List<System.Security.Claims.Claim>
        {
            new("sub", subject),
        };

        if (email is not null)
        {
            claims.Add(new System.Security.Claims.Claim("email", email));

            // Boolean, so the handler writes a JSON boolean rather than a quoted string, which is what
            // a real provider sends.
            claims.Add(new System.Security.Claims.Claim(
                "email_verified",
                "true",
                System.Security.Claims.ClaimValueTypes.Boolean));
        }

        if (name is not null)
        {
            claims.Add(new System.Security.Claims.Claim("name", name));
        }

        var expiry = expires ?? now.AddMinutes(15);

        // The earlier of now-1min and exp-1min. A fixed now-1min would sit after the expiry of a
        // deliberately-expired test token (rejected at construction), while exp-1min alone would
        // put nbf in the future for a normal token and make every valid token unauthorized.
        var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1);

        var token = new JwtSecurityToken(
            issuer: issuer ?? Authority,
            audience: audience ?? Audience,
            claims: claims,
            notBefore: notBefore,
            expires: expiry,
            signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    /// <summary>
    /// Mints an ID token carrying a key-binding nonce.
    /// </summary>
    /// <remarks>
    /// This is the artefact the whole public-key trust model rests on: an identity-provider
    /// signature over the hash of a key statement. Signed with the same real RSA key the JWKS
    /// endpoint publishes, so the server's verification genuinely runs.
    /// </remarks>
    /// <param name="subject">The <c>sub</c> claim.</param>
    /// <param name="nonce">The key statement binding, from <c>KeyStatementCodec.ComputeNonce</c>.</param>
    /// <param name="email">Optional email claim.</param>
    /// <param name="audience">Override the audience, to test rejection. Defaults to the client id.</param>
    /// <param name="issuer">Override the issuer, to test rejection.</param>
    /// <param name="expires">Override expiry, to test rejection.</param>
    /// <param name="omitNonce">Omit the nonce entirely, to test rejection.</param>
    public string MintIdToken(
        string subject,
        string nonce,
        string? email = null,
        string? audience = null,
        string? issuer = null,
        DateTime? expires = null,
        bool omitNonce = false)
    {
        var now = TimeProvider.System.GetUtcNow().UtcDateTime;

        // Integer64, so the handler writes a JSON number rather than a string. A provider that
        // emitted a string here would be unusual, and the server reads auth_time as a number.
        var claims = new List<System.Security.Claims.Claim>
        {
            new("sub", subject),
            new(
                "auth_time",
                new DateTimeOffset(now, TimeSpan.Zero).ToUnixTimeSeconds()
                    .ToString(CultureInfo.InvariantCulture),
                System.Security.Claims.ClaimValueTypes.Integer64),
        };

        if (!omitNonce)
        {
            claims.Add(new System.Security.Claims.Claim("nonce", nonce));
        }

        if (email is not null)
        {
            claims.Add(new System.Security.Claims.Claim("email", email));
        }

        var expiry = expires ?? now.AddMinutes(5);
        var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1);

        var token = new JwtSecurityToken(
            issuer: issuer ?? Authority,
            audience: audience ?? ClientId,
            claims: claims,
            notBefore: notBefore,
            expires: expiry,
            signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    /// <summary>Mints an ID token signed by a key the provider does not publish.</summary>
    public string MintIdTokenWithForeignKey(string subject, string nonce)
    {
        var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId };
        var now = TimeProvider.System.GetUtcNow().UtcDateTime;

        var token = new JwtSecurityToken(
            issuer: Authority,
            audience: ClientId,
            claims:
            [
                new System.Security.Claims.Claim("sub", subject),
                new System.Security.Claims.Claim("nonce", nonce),
            ],
            notBefore: now.AddMinutes(-1),
            expires: now.AddMinutes(5),
            signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    /// <summary>Mints a token signed by a different key, which must be rejected.</summary>
    public string MintTokenWithForeignKey(string subject)
    {
        var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId };
        var now = TimeProvider.System.GetUtcNow().UtcDateTime;

        var token = new JwtSecurityToken(
            issuer: Authority,
            audience: Audience,
            claims: [new System.Security.Claims.Claim("sub", subject)],
            notBefore: now.AddMinutes(-1),
            expires: now.AddMinutes(15),
            signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    private void StubDiscovery()
    {
        var document = new Dictionary<string, object>(StringComparer.Ordinal)
        {
            ["issuer"] = Authority,
            ["jwks_uri"] = $"{Authority}/.well-known/jwks.json",
            ["authorization_endpoint"] = $"{Authority}/connect/authorize",
            ["token_endpoint"] = $"{Authority}/connect/token",
            ["response_types_supported"] = new[] { "code" },
            ["subject_types_supported"] = new[] { "public" },
            ["id_token_signing_alg_values_supported"] = new[] { "RS256" },
            ["code_challenge_methods_supported"] = new[] { "S256" },
        };

        server
            .Given(Request.Create().WithPath("/.well-known/openid-configuration").UsingGet())
            .RespondWith(Response.Create()
                .WithStatusCode(200)
                .WithHeader("Content-Type", "application/json")
                .WithBody(JsonSerializer.Serialize(document)));
    }

    private void StubJwks()
    {
        var parameters = signingKey.Rsa.ExportParameters(includePrivateParameters: false);

        var jwks = new
        {
            keys = new[]
            {
                new
                {
                    kty = "RSA",
                    use = "sig",
                    kid = KeyId,
                    alg = "RS256",
                    n = Base64UrlEncoder.Encode(parameters.Modulus),
                    e = Base64UrlEncoder.Encode(parameters.Exponent),
                },
            },
        };

        server
            .Given(Request.Create().WithPath("/.well-known/jwks.json").UsingGet())
            .RespondWith(Response.Create()
                .WithStatusCode(200)
                .WithHeader("Content-Type", "application/json")
                .WithBody(JsonSerializer.Serialize(jwks)));
    }

    /// <inheritdoc />
    public void Dispose()
    {
        server.Stop();
        server.Dispose();
        signingKey.Rsa.Dispose();
    }
}
