use crate::github::GITHUB_ISSUER_URL;
use crate::github::workflows::extract_workflow_filename;
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};
use jsonwebtoken::errors::{Error, ErrorKind};
use jsonwebtoken::{Algorithm, DecodingKey, Validation};

/// Claims extracted from a GitHub Actions OIDC token.
///
/// This struct is used to decode and validate the JWT token generated by
/// GitHub Actions. It contains the claims that are relevant for our "Trusted
/// Publishing" implementation.
///
/// See <https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect#understanding-the-oidc-token>.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GitHubClaims {
    pub aud: String,
    #[serde(with = "ts_seconds")]
    pub iat: DateTime<Utc>,
    #[serde(with = "ts_seconds")]
    pub exp: DateTime<Utc>,
    pub jti: String,

    pub repository_owner_id: String,
    pub repository: String,
    pub workflow_ref: String,
    pub environment: Option<String>,
    pub event_name: String,
    pub run_id: String,
    pub sha: String,
}

impl GitHubClaims {
    /// Decodes and validates a JWT token, returning the relevant claims if valid.
    pub fn decode(token: &str, audience: &str, key: &DecodingKey) -> Result<Self, Error> {
        let validation = validation(audience);

        let claims: Self = jsonwebtoken::decode(token, key, &validation)?.claims;

        let leeway = chrono::TimeDelta::seconds(validation.leeway as i64);
        if claims.iat > Utc::now() + leeway {
            return Err(ErrorKind::ImmatureSignature.into());
        }

        Ok(claims)
    }

    /// Extracts the workflow filename from the [`workflow_ref`](Self::workflow_ref)
    /// field or returns `None` if the filename cannot be extracted.
    pub fn workflow_filename(&self) -> Option<&str> {
        extract_workflow_filename(&self.workflow_ref)
    }
}

fn validation(audience: &str) -> Validation {
    let mut validation = Validation::new(Algorithm::RS256);
    validation.leeway = crate::JWT_LEEWAY.num_seconds() as u64;
    validation.required_spec_claims.insert("iss".into());
    validation.required_spec_claims.insert("exp".into());
    validation.required_spec_claims.insert("aud".into());
    validation.validate_exp = true;
    validation.validate_aud = true;
    validation.validate_nbf = true;
    validation.set_issuer(&[GITHUB_ISSUER_URL]);
    validation.set_audience(&[audience]);
    validation
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_keys::{DECODING_KEY, encode_for_testing};
    use insta::{assert_compact_debug_snapshot, assert_json_snapshot};
    use serde_json::json;
    use std::time::SystemTime;

    const AUDIENCE: &str = "crates.io";

    #[test]
    fn test_decode() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "sub": "repo:octo-org/octo-repo:environment:prod",
          "environment": "prod",
          "aud": AUDIENCE,
          "ref": "refs/heads/main",
          "sha": "example-sha",
          "repository": "octo-org/octo-repo",
          "repository_owner": "octo-org",
          "actor_id": "12",
          "repository_visibility": "private",
          "repository_id": "74",
          "repository_owner_id": "65",
          "run_id": "example-run-id",
          "run_number": "10",
          "run_attempt": "2",
          "runner_environment": "github-hosted",
          "actor": "octocat",
          "workflow": "example-workflow",
          "head_ref": "",
          "base_ref": "",
          "event_name": "workflow_dispatch",
          "ref_type": "branch",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "nbf": now,
          "exp": now + 30,
          "iat": now,
        }))?;

        let claims = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?;
        assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#"
        {
          "aud": "crates.io",
          "iat": "[datetime]",
          "exp": "[datetime]",
          "jti": "example-id",
          "repository_owner_id": "65",
          "repository": "octo-org/octo-repo",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "environment": "prod",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha"
        }
        "#);

        Ok(())
    }

    #[test]
    fn test_decode_minimal() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let claims = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?;
        assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#"
        {
          "aud": "crates.io",
          "iat": "[datetime]",
          "exp": "[datetime]",
          "jti": "example-id",
          "repository_owner_id": "65",
          "repository": "octo-org/octo-repo",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "environment": null,
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha"
        }
        "#);

        Ok(())
    }

    #[test]
    fn test_decode_missing_jti() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `jti`", line: 1, column: 330)))"#);

        Ok(())
    }

    #[test]
    fn test_decode_wrong_audience() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": "somebody-else",
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @"Error(InvalidAudience)");

        Ok(())
    }

    #[test]
    fn test_decode_multi_audience() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": [AUDIENCE, "somebody-else"],
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("invalid type: sequence, expected a string", line: 1, column: 7)))"#);

        Ok(())
    }
    #[test]
    fn test_decode_missing_repo() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `repository`", line: 1, column: 315)))"#);

        Ok(())
    }
    #[test]
    fn test_decode_missing_owner_id() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `repository_owner_id`", line: 1, column: 322)))"#);

        Ok(())
    }
    #[test]
    fn test_decode_missing_workflow() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `workflow_ref`", line: 1, column: 264)))"#);

        Ok(())
    }

    #[test]
    fn test_decode_missing_issuer() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(MissingRequiredClaim("iss"))"#);

        Ok(())
    }

    #[test]
    fn test_decode_wrong_issuer() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://gitlab.com",
          "exp": now + 30,
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @"Error(InvalidIssuer)");

        Ok(())
    }

    #[test]
    fn test_decode_missing_exp() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "iat": now,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `exp`", line: 1, column: 332)))"#);

        Ok(())
    }

    #[test]
    fn test_decode_expired() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now - 3000,
          "iat": now - 6000,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @"Error(ExpiredSignature)");

        Ok(())
    }

    #[test]
    fn test_decode_missing_iat() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 30,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `iat`", line: 1, column: 332)))"#);

        Ok(())
    }

    #[test]
    fn test_decode_future_iat() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
          "jti": "example-id",
          "aud": AUDIENCE,
          "repository": "octo-org/octo-repo",
          "repository_owner_id": "65",
          "event_name": "workflow_dispatch",
          "run_id": "example-run-id",
          "sha": "example-sha",
          "workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main",
          "iss": "https://token.actions.githubusercontent.com",
          "exp": now + 300,
          "iat": now + 100,
        }))?;

        let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err();
        assert_compact_debug_snapshot!(error, @"Error(ImmatureSignature)");

        Ok(())
    }
}
