using System.Text.Json.Nodes;
using DodoSSH.Api.Setup;
using DodoSSH.Crypto;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace DodoSSH.Api.Features.Identity;

/// <summary>The outcome of checking an identity-provider key binding.</summary>
/// <param name="Verified">Whether the token bound the supplied keys to the caller.</param>
/// <param name="Failure">Why it did not, when it did not.</param>
/// <param name="Evidence">
/// The binding evidence to persist, as JSON. Includes the token verbatim so a client can repeat
/// the verification against the provider's own JWKS rather than trusting this server's word.
/// </param>
internal sealed record BindingVerification(bool Verified, string? Failure, string? Evidence)
{
    internal static BindingVerification Failed(string failure) => new(false, failure, null);
}

/// <summary>Verifies that an identity provider signed an assertion over a set of public keys.</summary>
internal interface IIdentityBindingVerifier
{
    /// <summary>
    /// Verifies a binding ID token.
    /// </summary>
    /// <param name="idToken">The ID token from the binding authorization.</param>
    /// <param name="expectedIssuer">Issuer named by the key statement.</param>
    /// <param name="expectedSubject">Subject the access token authenticated as.</param>
    /// <param name="expectedNonce">
    /// The statement's binding value, from <see cref="KeyStatementCodec.ComputeNonce"/>.
    /// </param>
    /// <param name="cancellationToken">Cancellation token.</param>
    Task<BindingVerification> VerifyAsync(
        string idToken,
        string expectedIssuer,
        string expectedSubject,
        string expectedNonce,
        CancellationToken cancellationToken);
}

/// <summary>
/// Identity-provider binding verification.
/// </summary>
/// <remarks>
/// <para>
/// This is the primary public-key trust anchor, and everything else in the design is downstream of
/// it. At enrollment the client hashes its key statement and runs a fresh OIDC authorization with
/// that hash as the <c>nonce</c>, so the resulting ID token is an identity-provider signature over
/// exactly those keys. This server cannot mint such a signature, so it cannot invent a key for a
/// user who never enrolled — which is the attack that would otherwise let an operator read every
/// vault by publishing its own key as yours. See ADR 0001.
/// </para>
/// <para>
/// Be clear about the residual risk: this makes the identity provider a key-distribution trust
/// root, and in a self-hosted deployment the person running Keycloak is usually the person running
/// DodoSSH. It raises the bar from one compromised service to one compromised service plus a
/// detectable artefact — the key log — not to zero.
/// </para>
/// <para>
/// Verifying here does <em>not</em> make this the boundary. Clients must repeat the check against
/// the provider's JWKS fetched directly, which is why the token is retained verbatim. A check only
/// the server performs is a claim, not evidence.
/// </para>
/// </remarks>
internal sealed class IdentityBindingVerifier(
    IOptionsMonitor<JwtBearerOptions> jwtBearerOptions,
    IOptions<OidcOptions> oidcOptions,
    TimeProvider clock)
    : IIdentityBindingVerifier
{
    /// <summary>Stateless and documented as thread-safe, so one instance serves every request.</summary>
    private static readonly JsonWebTokenHandler TokenHandler = new();

    /// <summary>Version of the stored evidence document.</summary>
    private const int EvidenceVersion = 1;

    /// <inheritdoc />
    public async Task<BindingVerification> VerifyAsync(
        string idToken,
        string expectedIssuer,
        string expectedSubject,
        string expectedNonce,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(idToken))
        {
            return BindingVerification.Failed("No identity provider token was supplied.");
        }

        var parameters = await BuildValidationParametersAsync(cancellationToken).ConfigureAwait(false);

        var result = await TokenHandler.ValidateTokenAsync(idToken, parameters).ConfigureAwait(false);
        if (!result.IsValid || result.SecurityToken is not JsonWebToken token)
        {
            // Deliberately not echoing the library's reason. It is the caller's own token, so
            // there is no leak, but the messages are long, version-dependent, and end up in
            // client-side string matching if we make them part of the contract.
            return BindingVerification.Failed(
                "The identity provider token failed signature, issuer, audience or lifetime validation.");
        }

        if (!string.Equals(token.Issuer, expectedIssuer, StringComparison.Ordinal))
        {
            // The statement names the issuer that vouches for it. If that disagrees with the token,
            // the statement is describing a different identity than the one being proved.
            return BindingVerification.Failed("The token issuer does not match the key statement.");
        }

        if (!string.Equals(token.Subject, expectedSubject, StringComparison.Ordinal))
        {
            // Without this, any user of this provider could bind keys to another user's account.
            return BindingVerification.Failed("The token subject is not the authenticated caller.");
        }

        if (!token.TryGetPayloadValue<string>("nonce", out var nonce) || nonce is null)
        {
            return BindingVerification.Failed("The token carries no nonce, so it binds no keys.");
        }

        if (!string.Equals(nonce, expectedNonce, StringComparison.Ordinal))
        {
            return BindingVerification.Failed("The token nonce is not this key statement's hash.");
        }

        return new BindingVerification(true, null, BuildEvidence(idToken, token, nonce));
    }

    /// <remarks>
    /// The signing keys come from the same <see cref="IConfigurationManager{T}"/> the bearer
    /// handler uses, so there is one metadata cache, one refresh schedule and one backchannel
    /// rather than a second set that could drift after a provider key rotation.
    /// </remarks>
    private async Task<TokenValidationParameters> BuildValidationParametersAsync(
        CancellationToken cancellationToken)
    {
        var bearer = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme);

        var configurationManager = bearer.ConfigurationManager
            ?? throw new InvalidOperationException(
                "The bearer scheme has no configuration manager, so provider metadata is unavailable.");

        var configuration = await configurationManager
            .GetConfigurationAsync(cancellationToken)
            .ConfigureAwait(false);

        return new TokenValidationParameters
        {
            ValidateIssuer = true,

            // From discovery rather than from the configured authority, which may differ by a
            // trailing slash or be an alias. The document is authoritative about its own issuer.
            ValidIssuer = configuration.Issuer,

            ValidateAudience = true,

            // An ID token is audienced to the client that requested it, never to this API.
            // Validating it against the API audience would reject every genuine binding.
            ValidAudience = oidcOptions.Value.ClientId,

            ValidateLifetime = true,
            RequireExpirationTime = true,
            ClockSkew = TimeSpan.FromSeconds(30),

            ValidateIssuerSigningKey = true,
            RequireSignedTokens = true,
            IssuerSigningKeys = configuration.SigningKeys,

            // Algorithms are deliberately not restricted: providers legitimately use RS256, PS256,
            // ES256 and now EdDSA, and a fixed list breaks deployments for no gain. The classic
            // algorithm-confusion attack is already closed, because the discovery document only
            // publishes asymmetric keys and a symmetric algorithm cannot consume one.
        };
    }

    /// <summary>Builds the evidence document stored alongside the key.</summary>
    /// <remarks>
    /// The token is stored verbatim so that clients — and later the public-key directory — can
    /// verify the binding themselves. It has already expired by the time any third party reads it,
    /// and its audience is the desktop client rather than any API, so it is not a usable credential
    /// elsewhere. Storing only our summary would mean asking clients to trust the server on the one
    /// question the whole design exists to avoid trusting it about.
    /// </remarks>
    private string BuildEvidence(string idToken, JsonWebToken token, string nonce)
    {
        var evidence = new JsonObject
        {
            ["v"] = EvidenceVersion,
            ["idToken"] = idToken,
            ["issuer"] = token.Issuer,
            ["subject"] = token.Subject,
            ["audiences"] = new JsonArray([.. token.Audiences.Select(a => JsonValue.Create(a))]),
            ["nonce"] = nonce,
            ["issuedAtUtc"] = token.IssuedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
            ["expiresAtUtc"] = token.ValidTo.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
            ["verifiedAtUtc"] = clock.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
        };

        if (token.TryGetPayloadValue<long>("auth_time", out var authTime))
        {
            evidence["authTimeUtc"] = DateTimeOffset
                .FromUnixTimeSeconds(authTime)
                .ToString("O", System.Globalization.CultureInfo.InvariantCulture);
        }

        return evidence.ToJsonString();
    }
}
