use crate::gitlab::GITLAB_ISSUER_URL;
use crate::gitlab::workflows::extract_workflow_filepath;
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};
use jsonwebtoken::errors::{Error, ErrorKind};
use jsonwebtoken::{Algorithm, DecodingKey, Validation};

/// Claims extracted from a GitLab CI OIDC token.
///
/// This struct is used to decode and validate the JWT token generated by
/// GitLab CI. It contains the claims that are relevant for our "Trusted
/// Publishing" implementation.
///
/// See <https://docs.gitlab.com/ci/secrets/id_token_authentication/#token-payload>.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GitLabClaims {
    pub aud: String,
    #[serde(with = "ts_seconds")]
    pub iat: DateTime<Utc>,
    #[serde(with = "ts_seconds")]
    pub exp: DateTime<Utc>,
    pub jti: String,

    pub namespace_id: String,
    pub project_path: String,
    pub ci_config_ref_uri: String,
    pub environment: Option<String>,
    pub job_id: String,
    pub sha: String,
}

impl GitLabClaims {
    /// 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 filepath from the [`ci_config_ref_uri`](Self::ci_config_ref_uri)
    /// field or returns `None` if the filepath cannot be extracted.
    pub fn workflow_filepath(&self) -> Option<&str> {
        extract_workflow_filepath(&self.ci_config_ref_uri)
    }
}

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(&[GITLAB_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!({
            "namespace_id": "72",
            "namespace_path": "my-group",
            "project_id": "20",
            "project_path": "my-group/my-project",
            "user_id": "1",
            "user_login": "sample-user",
            "user_email": "sample-user@example.com",
            "user_identities": [
                {"provider": "github", "extern_uid": "2435223452345"},
                {"provider": "bitbucket", "extern_uid": "john.smith"}
            ],
            "pipeline_id": "574",
            "pipeline_source": "push",
            "job_id": "302",
            "ref": "feature-branch-1",
            "ref_type": "branch",
            "ref_path": "refs/heads/feature-branch-1",
            "ref_protected": "false",
            "groups_direct": ["mygroup/mysubgroup", "myothergroup/myothersubgroup"],
            "environment": "test-environment2",
            "environment_protected": "false",
            "deployment_tier": "testing",
            "environment_action": "start",
            "runner_id": 1,
            "runner_environment": "self-hosted",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "project_visibility": "public",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "ci_config_sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "nbf": now - 5,
            "exp": now + 60 * 60,
            "sub": "project_path:my-group/my-project:ref_type:branch:ref:feature-branch-1",
            "aud": AUDIENCE
        }))?;

        let claims = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?;
        assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#"
        {
          "aud": "crates.io",
          "iat": "[datetime]",
          "exp": "[datetime]",
          "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
          "namespace_id": "72",
          "project_path": "my-group/my-project",
          "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
          "environment": "test-environment2",
          "job_id": "302",
          "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc"
        }
        "#);

        Ok(())
    }

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

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

        let claims = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?;
        assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#"
        {
          "aud": "crates.io",
          "iat": "[datetime]",
          "exp": "[datetime]",
          "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
          "namespace_id": "72",
          "project_path": "my-group/my-project",
          "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
          "environment": "test-environment2",
          "job_id": "302",
          "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc"
        }
        "#);

        Ok(())
    }

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

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "iss": "https://gitlab.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

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

        Ok(())
    }

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

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": "somebody-else"
        }))?;

        let error = GitLabClaims::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!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": [AUDIENCE, "somebody-else"]
        }))?;

        let error = GitLabClaims::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_project_path() -> anyhow::Result<()> {
        let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs();

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

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

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

        let jwt = encode_for_testing(&json!({
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

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

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

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

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

        Ok(())
    }

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

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

        let error = GitLabClaims::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!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://github.com",
            "iat": now,
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

        let error = GitLabClaims::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!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "iat": now,
            "aud": AUDIENCE
        }))?;

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

        Ok(())
    }

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

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
          "exp": now - 3000,
          "iat": now - 6000,
            "aud": AUDIENCE
        }))?;

        let error = GitLabClaims::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!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
            "exp": now + 60 * 60,
            "aud": AUDIENCE
        }))?;

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

        Ok(())
    }

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

        let jwt = encode_for_testing(&json!({
            "namespace_id": "72",
            "project_path": "my-group/my-project",
            "job_id": "302",
            "environment": "test-environment2",
            "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc",
            "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main",
            "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
            "iss": "https://gitlab.com",
          "exp": now + 300,
          "iat": now + 100,
            "aud": AUDIENCE
        }))?;

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

        Ok(())
    }
}
