use klauthed_macros::DomainError;
use thiserror::Error;

/// Errors raised while parsing or validating protocol payloads.
///
/// These are *data*-level failures — a token that does not look like an
/// `id_token`, a SCIM PATCH `op` that isn't `add`/`remove`/`replace`, an
/// unsupported value — not transport or cryptographic errors. JWT
/// signing/verification lives in `klauthed-security`.
///
/// The `DomainError` impl (category/code) is generated by the derive: codes are
/// `protocol.<snake_variant>` (or the explicit `code`), most under the
/// `bad_request` category since they reflect malformed client input.
#[derive(Debug, Error, DomainError)]
#[domain(prefix = "protocol", category = "bad_request")]
#[non_exhaustive]
pub enum ProtocolError {
    /// A token (e.g. an ID token) was structurally invalid or missing a
    /// required claim.
    #[domain(code = "invalid_token")]
    #[error("invalid token: {0}")]
    InvalidToken(String),

    /// A value (algorithm, scope, schema URN, …) is well-formed but not
    /// supported by this implementation.
    #[error("unsupported value: {0}")]
    Unsupported(String),

    /// A required field was absent from a payload.
    #[error("missing required field: {0}")]
    MissingField(&'static str),

    /// A field held a malformed or out-of-spec value.
    #[error("invalid value for '{field}': {message}")]
    InvalidValue {
        /// The offending field name.
        field: &'static str,
        /// A human-readable description of why it was rejected.
        message: String,
    },

    /// A payload could not be deserialized into the expected protocol type.
    #[error("malformed payload: {0}")]
    Malformed(String),

    /// An ID token's `exp` claim is at or before the current time (minus
    /// leeway): the token has expired. This is a claim-level check only; the
    /// signature is verified separately in `klauthed-security`.
    #[domain(code = "id_token_expired", category = "unauthorized")]
    #[error("id token expired: exp {exp} is not after now {now} (leeway {leeway}s)")]
    IdTokenExpired {
        /// The token's `exp` claim (Unix seconds).
        exp: i64,
        /// The reference time used for the check (Unix seconds).
        now: i64,
        /// The allowed clock-skew leeway, in seconds.
        leeway: i64,
    },

    /// An ID token's `iat` claim is implausibly far in the future, beyond the
    /// configured leeway — likely a clock-skew or forgery problem.
    #[domain(code = "id_token_not_yet_valid", category = "unauthorized")]
    #[error("id token issued in the future: iat {iat} exceeds now {now} (leeway {leeway}s)")]
    IdTokenNotYetValid {
        /// The token's `iat` claim (Unix seconds).
        iat: i64,
        /// The reference time used for the check (Unix seconds).
        now: i64,
        /// The allowed clock-skew leeway, in seconds.
        leeway: i64,
    },

    /// An ID token's `iss` claim did not match the expected issuer.
    #[domain(code = "issuer_mismatch", category = "unauthorized")]
    #[error("issuer mismatch: expected '{expected}', got '{actual}'")]
    IssuerMismatch {
        /// The issuer the relying party expected.
        expected: String,
        /// The issuer the token actually carried.
        actual: String,
    },

    /// An ID token's `aud` claim did not contain the expected audience
    /// (typically the relying party's `client_id`).
    #[domain(code = "audience_mismatch", category = "unauthorized")]
    #[error("audience mismatch: token audience does not contain '{expected}'")]
    AudienceMismatch {
        /// The audience (client id) that was required but absent.
        expected: String,
    },

    /// An ID token's `nonce` claim did not match the expected value (or was
    /// absent when a nonce was required).
    #[domain(code = "nonce_mismatch", category = "unauthorized")]
    #[error("nonce mismatch: id token nonce did not match the expected value")]
    NonceMismatch,

    /// No key in a JSON Web Key Set carried the requested `kid` (e.g. the `kid`
    /// from a JWS header). This is a data-level lookup failure; signature
    /// verification against the chosen key lives in `klauthed-security`.
    #[domain(code = "key_not_found")]
    #[error("no key with kid '{kid}' in key set")]
    KeyNotFound {
        /// The key ID that was looked up but not present.
        kid: String,
    },

    /// A JWK's key type (`kty`) is well-formed but not supported for the
    /// requested operation (e.g. an `oct` key where a public verification key
    /// was required).
    #[domain(code = "unsupported_key_type")]
    #[error("unsupported key type: {0}")]
    UnsupportedKeyType(String),
}

#[cfg(test)]
mod tests {
    use super::*;
    use klauthed_error::{DomainError, ErrorCategory};

    #[test]
    fn invalid_token_classification() {
        let err = ProtocolError::InvalidToken("no sub".into());
        assert_eq!(err.category(), ErrorCategory::BadRequest);
        assert_eq!(err.code().as_str(), "protocol.invalid_token");
        assert_eq!(err.http_status(), 400);
    }

    #[test]
    fn unsupported_classification() {
        let err = ProtocolError::Unsupported("alg=none".into());
        assert_eq!(err.code().as_str(), "protocol.unsupported");
    }

    #[test]
    fn id_token_validation_errors_are_unauthorized() {
        let expired = ProtocolError::IdTokenExpired { exp: 1, now: 2, leeway: 0 };
        assert_eq!(expired.category(), ErrorCategory::Unauthorized);
        assert_eq!(expired.code().as_str(), "protocol.id_token_expired");
        assert_eq!(expired.http_status(), 401);

        let iss = ProtocolError::IssuerMismatch { expected: "a".into(), actual: "b".into() };
        assert_eq!(iss.code().as_str(), "protocol.issuer_mismatch");
        assert_eq!(iss.category(), ErrorCategory::Unauthorized);

        let aud = ProtocolError::AudienceMismatch { expected: "client".into() };
        assert_eq!(aud.code().as_str(), "protocol.audience_mismatch");

        assert_eq!(ProtocolError::NonceMismatch.code().as_str(), "protocol.nonce_mismatch");
        assert_eq!(
            ProtocolError::IdTokenNotYetValid { iat: 9, now: 2, leeway: 0 }.code().as_str(),
            "protocol.id_token_not_yet_valid"
        );
    }

    #[test]
    fn derived_codes_are_prefixed_and_snake_cased() {
        assert_eq!(
            ProtocolError::MissingField("userName").code().as_str(),
            "protocol.missing_field"
        );
        assert_eq!(
            ProtocolError::Malformed("bad json".into()).code().as_str(),
            "protocol.malformed"
        );
    }
}
