//! Attestation contract — algol-merged, env-injection-shaped policy.
//!
//! # Provenance
//!
//! Lifted from `algol/src/model.rs` @ `6956cfd347cd8ce492231ba5aaa4952227d72689`
//! (commit `6956cfd`, branch `master`, repo `0ryant/algol`).
//!
//! Re-licensed `AGPL-3.0-or-later` per:
//!
//! - `ecosystem-catalog/docs/adr/draft-algol-into-tsafe-merge.md`
//! - `ecosystem-catalog/portfolio-algol-tsafe-migration-2026-05-21.md`
//! - `ecosystem-catalog/portfolio-algol-tsafe-phase0-audit-2026-05-21.md`
//! - operator decision 2026-05-21
//!
//! # Why `AttestContract` is distinct from `AuthorityContract`
//!
//! `tsafe-core::contracts::AuthorityContract` (vault-policy semantics) and
//! algol's original `AuthorityContract` (env-injection semantics) share a
//! name but have **zero field overlap**. Per ec Phase 0 audit §1.5.1:
//!
//! | tsafe `AuthorityContract` fields | algol's contract fields |
//! |---|---|
//! | name, profile, namespace | repo_path, repo_commit |
//! | access_profile, allow_all_secrets | created_at, created_by, command |
//! | allowed_secrets, required_secrets | policy (default_env, allow_safe_baseline, redaction) |
//! | allowed_targets, trust, network | allowed_env, denied_env, safe_baseline_env, source_scan |
//!
//! The two types represent different abstractions:
//!
//! - `AuthorityContract` (tsafe) is a **named, vault-backed policy selector**
//!   describing which profile to resolve from, which secrets are allowed,
//!   and which trust posture applies.
//! - `AttestContract` (this module) is a **per-run env-injection contract**
//!   tied to a specific repo + commit + command, describing which env
//!   variables to inject from which sources and which to deny.
//!
//! Field-merging the two would be wrong; they need to coexist. This module
//! is the algol-merged side of that boundary.
//!
//! # Phase 4 wire-format changes (ec ADR-0003 + schema rename)
//!
//! - Schema: `algol.contract.v1` -> `tsafe.contract.v1`. Both accepted on
//!   parse during the v1.x compat window.
//! - Redaction algorithm: `sha256` is still accepted but `blake3` is the
//!   new canonical value. Either is valid during compat.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;

/// Schema version for the `AttestContract` artifact (current canonical).
pub const ATTEST_CONTRACT_SCHEMA: &str = "tsafe.contract.v1";

/// Legacy schema name accepted during the v1.x compat window.
pub const LEGACY_ATTEST_CONTRACT_SCHEMA: &str = "algol.contract.v1";

/// Canonical redaction algorithm name (Phase 4 BLAKE3 convergence).
pub const REDACTION_BLAKE3: &str = "blake3";

/// Legacy redaction algorithm name accepted during the v1.x compat window.
pub const REDACTION_SHA256_LEGACY: &str = "sha256";

/// Policy block governing the contract's overall stance.
///
/// `default_env = "deny"` is the only currently supported value
/// (env injection is opt-in per name; everything else is denied).
/// `redaction` accepts either `blake3` (Phase 4 canonical) or `sha256`
/// (legacy, compat window).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttestContractPolicy {
    pub default_env: String,
    pub allow_safe_baseline: bool,
    pub redaction: String,
}

/// A single env variable explicitly allowed by the contract.
///
/// `source` is a URI describing where the value comes from. MVP supports
/// `literal://demo/<name>` and `env://<name>`; future sources include
/// vault references and external secret managers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttestAllowedEnv {
    pub name: String,
    pub source: String,
    pub required: bool,
    pub reason: String,
}

/// A single env variable explicitly denied by the contract.
///
/// Denied entries are stripped from the parent env before the child
/// process starts. The `reason` is recorded so operators can audit why.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttestDeniedEnv {
    pub name: String,
    pub reason: String,
}

/// Per-run env-injection contract for an attested command execution.
///
/// `AttestContract` is the input to the attestation pipeline: a frozen
/// description of which env variables to inject (from which sources) and
/// which to deny, scoped to a single repo + commit + command tuple.
///
/// It is **distinct** from [`crate::contracts::AuthorityContract`] — see
/// module docs for the rationale.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttestContract {
    pub schema: String,
    pub repo_path: String,
    pub repo_commit: Option<String>,
    pub created_at: DateTime<Utc>,
    pub created_by: String,
    pub command: Vec<String>,
    pub policy: AttestContractPolicy,
    pub allowed_env: Vec<AttestAllowedEnv>,
    pub denied_env: Vec<AttestDeniedEnv>,
    pub safe_baseline_env: Vec<String>,
    pub source_scan: Option<String>,
}

impl AttestContract {
    /// Return all validation errors for this artifact.
    pub fn validation_errors(&self) -> Vec<String> {
        let mut errors = Vec::new();

        if !is_supported_contract_schema(&self.schema) {
            errors.push(format!("unsupported schema {}", self.schema));
        }
        if self.repo_path.trim().is_empty() {
            errors.push("repo_path must not be empty".to_string());
        }
        if self.command.is_empty() || self.command.iter().any(|part| part.trim().is_empty()) {
            errors.push("command must contain non-empty argv entries".to_string());
        }
        if self.policy.default_env != "deny" {
            errors.push(format!(
                "unsupported policy.default_env {}; MVP requires deny",
                self.policy.default_env
            ));
        }
        if self.policy.redaction != REDACTION_BLAKE3
            && self.policy.redaction != REDACTION_SHA256_LEGACY
        {
            errors.push(format!(
                "unsupported policy.redaction {}; expected blake3 (or sha256 during compat window)",
                self.policy.redaction
            ));
        }

        validate_allowed_env(&self.allowed_env, &mut errors);
        validate_denied_env(&self.denied_env, &mut errors);
        validate_safe_baseline(&self.safe_baseline_env, &mut errors);

        errors
    }

    /// Convert the validation-error list into a `Result`.
    pub fn ensure_valid(&self) -> Result<(), String> {
        let errors = self.validation_errors();
        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors.join("; "))
        }
    }
}

/// Test whether `schema` is one of the supported contract schema names.
pub fn is_supported_contract_schema(schema: &str) -> bool {
    schema == ATTEST_CONTRACT_SCHEMA || schema == LEGACY_ATTEST_CONTRACT_SCHEMA
}

/// Test whether an `allowed_env[].source` URI is one of the supported
/// MVP schemes.
///
/// MVP supports `literal://demo/*` (test fixtures) and `env://*`
/// (parent-env passthrough). Vault and external-secret-manager URIs are
/// in scope for later phases.
pub fn is_supported_source_uri(source: &str) -> bool {
    source.starts_with("literal://demo/")
        || source
            .strip_prefix("env://")
            .is_some_and(|name| !name.trim().is_empty())
}

fn validate_allowed_env(allowed_env: &[AttestAllowedEnv], errors: &mut Vec<String>) {
    let mut names = BTreeSet::new();
    for item in allowed_env {
        if item.name.trim().is_empty() {
            errors.push("allowed_env name must not be empty".to_string());
        }
        if !names.insert(item.name.clone()) {
            errors.push(format!("duplicate allowed_env {}", item.name));
        }
        if !is_supported_source_uri(&item.source) {
            errors.push(format!(
                "unsupported source for {}: {}; MVP supports literal://demo/* and env://*",
                item.name, item.source
            ));
        }
        if item.reason.trim().is_empty() {
            errors.push(format!(
                "allowed_env {} reason must not be empty",
                item.name
            ));
        }
    }
}

fn validate_denied_env(denied_env: &[AttestDeniedEnv], errors: &mut Vec<String>) {
    let mut names = BTreeSet::new();
    for item in denied_env {
        if item.name.trim().is_empty() {
            errors.push("denied_env name must not be empty".to_string());
        }
        if !names.insert(item.name.clone()) {
            errors.push(format!("duplicate denied_env {}", item.name));
        }
        if item.reason.trim().is_empty() {
            errors.push(format!("denied_env {} reason must not be empty", item.name));
        }
    }
}

fn validate_safe_baseline(safe_baseline_env: &[String], errors: &mut Vec<String>) {
    let mut names = BTreeSet::new();
    for name in safe_baseline_env {
        if name.trim().is_empty() {
            errors.push("safe_baseline_env name must not be empty".to_string());
        }
        if !names.insert(name.clone()) {
            errors.push(format!("duplicate safe_baseline_env {name}"));
        }
        if is_sensitive_baseline_name(name) {
            errors.push(format!(
                "safe_baseline_env {name} is sensitive and cannot be baseline-inherited"
            ));
        }
    }
}

fn is_sensitive_baseline_name(name: &str) -> bool {
    let upper = name.to_ascii_uppercase();
    upper.contains("SECRET")
        || upper.contains("TOKEN")
        || upper.contains("CREDENTIAL")
        || upper.contains("CREDS")
        || upper.contains("PASSWORD")
        || upper.contains("PASSWD")
        || upper.contains("PRIVATE_KEY")
        || upper.ends_with("_KEY")
        || upper.ends_with("_PWD")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn valid_contract() -> AttestContract {
        AttestContract {
            schema: ATTEST_CONTRACT_SCHEMA.to_string(),
            repo_path: ".".to_string(),
            repo_commit: None,
            created_at: Utc::now(),
            created_by: "model-test".to_string(),
            command: vec!["true".to_string()],
            policy: AttestContractPolicy {
                default_env: "deny".to_string(),
                allow_safe_baseline: true,
                redaction: REDACTION_BLAKE3.to_string(),
            },
            allowed_env: vec![AttestAllowedEnv {
                name: "DATABASE_URL".to_string(),
                source: "literal://demo/DATABASE_URL".to_string(),
                required: true,
                reason: "test".to_string(),
            }],
            denied_env: vec![AttestDeniedEnv {
                name: "AWS_SECRET_ACCESS_KEY".to_string(),
                reason: "test".to_string(),
            }],
            safe_baseline_env: vec!["PATH".to_string()],
            source_scan: None,
        }
    }

    #[test]
    fn contract_rejects_empty_and_blank_command_entries() {
        let mut empty = valid_contract();
        empty.command.clear();
        assert!(empty
            .validation_errors()
            .iter()
            .any(|error| error.contains("command must contain")));

        let mut blank = valid_contract();
        blank.command = vec!["true".to_string(), " ".to_string()];
        assert!(blank
            .validation_errors()
            .iter()
            .any(|error| error.contains("command must contain")));
    }

    #[test]
    fn contract_rejects_invalid_denied_env_entries() {
        let mut contract = valid_contract();
        contract.denied_env = vec![
            AttestDeniedEnv {
                name: "".to_string(),
                reason: "test".to_string(),
            },
            AttestDeniedEnv {
                name: "GH_TOKEN".to_string(),
                reason: "".to_string(),
            },
            AttestDeniedEnv {
                name: "GH_TOKEN".to_string(),
                reason: "duplicate".to_string(),
            },
        ];

        let errors = contract.validation_errors().join("; ");

        assert!(errors.contains("denied_env name must not be empty"));
        assert!(errors.contains("denied_env GH_TOKEN reason must not be empty"));
        assert!(errors.contains("duplicate denied_env GH_TOKEN"));
    }

    #[test]
    fn contract_rejects_each_sensitive_baseline_name_pattern() {
        for name in [
            "APP_SECRET",
            "API_TOKEN",
            "DATABASE_PASSWORD",
            "DB_PASSWD",
            "SSH_PRIVATE_KEY",
            "GOOGLE_APPLICATION_CREDENTIALS",
            "GOOGLE_GHA_CREDS_PATH",
            "AWS_ACCESS_KEY",
            "DB_PWD",
        ] {
            let mut contract = valid_contract();
            contract.safe_baseline_env = vec![name.to_string()];

            let errors = contract.validation_errors().join("; ");

            assert!(
                errors.contains("is sensitive and cannot be baseline-inherited"),
                "{name} was not rejected: {errors}"
            );
        }
    }

    #[test]
    fn contract_rejects_unsupported_source_uri() {
        let mut contract = valid_contract();
        contract.allowed_env = vec![AttestAllowedEnv {
            name: "DATABASE_URL".to_string(),
            source: "https://example.com/secrets/db".to_string(),
            required: true,
            reason: "test".to_string(),
        }];
        let errors = contract.validation_errors().join("; ");
        assert!(errors.contains("unsupported source for DATABASE_URL"));
    }

    #[test]
    fn contract_rejects_unsupported_default_env_policy() {
        let mut contract = valid_contract();
        contract.policy.default_env = "allow".to_string();
        let errors = contract.validation_errors().join("; ");
        assert!(errors.contains("unsupported policy.default_env allow"));
    }

    #[test]
    fn contract_accepts_a_well_formed_artifact() {
        let contract = valid_contract();
        assert!(
            contract.ensure_valid().is_ok(),
            "valid_contract() should pass validation: {:?}",
            contract.validation_errors()
        );
    }

    #[test]
    fn contract_accepts_legacy_schema_and_redaction_during_compat() {
        let mut contract = valid_contract();
        contract.schema = LEGACY_ATTEST_CONTRACT_SCHEMA.to_string();
        contract.policy.redaction = REDACTION_SHA256_LEGACY.to_string();
        assert!(
            contract.ensure_valid().is_ok(),
            "legacy schema + sha256 redaction must remain valid: {:?}",
            contract.validation_errors()
        );
    }

    #[test]
    fn is_supported_source_uri_accepts_mvp_schemes_and_rejects_others() {
        assert!(is_supported_source_uri("literal://demo/FOO"));
        assert!(is_supported_source_uri("env://FOO"));
        assert!(!is_supported_source_uri("env://"));
        assert!(!is_supported_source_uri("env:// "));
        assert!(!is_supported_source_uri("https://example.com"));
        assert!(!is_supported_source_uri("vault://path"));
    }
}
