using System.Text.Json;

namespace DodoSSH.Client.Auth;

/// <summary>Raised when an OIDC exchange fails in a way the caller must handle.</summary>
public sealed class OidcException(string message, string? errorCode = null) : Exception(message)
{
    /// <summary>The provider's OAuth error code, when it supplied one.</summary>
    public string? ErrorCode { get; } = errorCode;
}

/// <summary>The parts of an OIDC discovery document this client uses.</summary>
/// <param name="Issuer">The provider's own idea of its issuer identifier.</param>
/// <param name="AuthorizationEndpoint">Where the browser is sent.</param>
/// <param name="TokenEndpoint">Where codes and refresh tokens are redeemed.</param>
/// <param name="JwksUri">
/// Where signing keys are published. Not used to validate tokens received directly from the token
/// endpoint, but required in M3 to verify other users' key bindings — fetched from here, never
/// proxied through the DodoSSH server, which is the entire point of the binding. See ADR 0001.
/// </param>
/// <param name="CodeChallengeMethodsSupported">Advertised PKCE methods.</param>
public sealed record OidcProviderMetadata(
    Uri Issuer,
    Uri AuthorizationEndpoint,
    Uri TokenEndpoint,
    Uri? JwksUri,
    IReadOnlyList<string> CodeChallengeMethodsSupported);

/// <summary>Fetches and validates an OIDC discovery document.</summary>
public sealed class OidcDiscoveryClient(HttpClient http)
{
    private const string DiscoveryPath = ".well-known/openid-configuration";

    /// <summary>
    /// Reads the discovery document for an authority.
    /// </summary>
    /// <remarks>
    /// The document's <c>issuer</c> is checked against the authority the URL was built from, per
    /// RFC 8414 §3.3. Skipping that check is what enables a mix-up attack: an attacker-controlled
    /// authority can serve metadata pointing at a legitimate provider's endpoints, and the client
    /// then hands its code to the attacker's token endpoint while believing it is talking to the
    /// real one.
    /// </remarks>
    public async Task<OidcProviderMetadata> GetAsync(
        Uri authority,
        bool requireHttps,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(authority);

        if (requireHttps && !string.Equals(authority.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal))
        {
            throw new OidcException(
                $"The authority '{authority}' is not HTTPS. Plaintext metadata is only ever "
                + "acceptable for local development.");
        }

        // A trailing slash matters: without it Uri would replace the last path segment, so an
        // authority with a realm path would resolve to the wrong document.
        var basePath = authority.AbsoluteUri.EndsWith('/') ? authority : new Uri(authority.AbsoluteUri + "/");
        var discoveryUri = new Uri(basePath, DiscoveryPath);

        using var response = await http.GetAsync(discoveryUri, cancellationToken).ConfigureAwait(false);

        if (!response.IsSuccessStatusCode)
        {
            throw new OidcException(
                $"Discovery at '{discoveryUri}' returned {(int)response.StatusCode}.");
        }

        var content = await response.Content
            .ReadAsStreamAsync(cancellationToken)
            .ConfigureAwait(false);

        await using var contentScope = content.ConfigureAwait(false);

        using var document = await JsonDocument
            .ParseAsync(content, cancellationToken: cancellationToken)
            .ConfigureAwait(false);

        var metadata = Read(document.RootElement, discoveryUri);

        if (!IssuerMatches(metadata.Issuer, authority))
        {
            throw new OidcException(
                $"The discovery document at '{discoveryUri}' declares issuer '{metadata.Issuer}', "
                + $"which is not the authority '{authority}' it was fetched from.");
        }

        return metadata;
    }

    private static OidcProviderMetadata Read(JsonElement root, Uri discoveryUri) =>
        new(
            Issuer: RequireUri(root, "issuer", discoveryUri),
            AuthorizationEndpoint: RequireUri(root, "authorization_endpoint", discoveryUri),
            TokenEndpoint: RequireUri(root, "token_endpoint", discoveryUri),
            JwksUri: OptionalUri(root, "jwks_uri"),
            CodeChallengeMethodsSupported: ReadStrings(root, "code_challenge_methods_supported"));

    /// <remarks>
    /// Compared after normalising a single trailing slash, because providers are inconsistent about
    /// it and a mismatch there is cosmetic rather than an attack.
    /// </remarks>
    private static bool IssuerMatches(Uri issuer, Uri authority) =>
        string.Equals(
            issuer.AbsoluteUri.TrimEnd('/'),
            authority.AbsoluteUri.TrimEnd('/'),
            StringComparison.OrdinalIgnoreCase);

    private static Uri RequireUri(JsonElement root, string name, Uri discoveryUri)
    {
        var value = OptionalUri(root, name)
            ?? throw new OidcException(
                $"The discovery document at '{discoveryUri}' has no usable '{name}'.");

        return value;
    }

    private static Uri? OptionalUri(JsonElement root, string name) =>
        root.TryGetProperty(name, out var property)
            && property.ValueKind == JsonValueKind.String
            && Uri.TryCreate(property.GetString(), UriKind.Absolute, out var uri)
            ? uri
            : null;

    private static List<string> ReadStrings(JsonElement root, string name)
    {
        if (!root.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        var values = new List<string>();
        foreach (var element in property.EnumerateArray())
        {
            if (element.ValueKind == JsonValueKind.String && element.GetString() is { } value)
            {
                values.Add(value);
            }
        }

        return values;
    }
}
