//! Run evidence — typed-evidence artifact for a single command execution.
//!
//! # 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 (sole legal copyright holder per algol/LICENSE
//!   + git log; co-founder courtesy gate is not a legal blocker)
//!
//! # Scope
//!
//! `RunEvidence` and the supporting sub-structs (`ContractRef`,
//! `InjectedSecretEvidence`, `DeniedSensitiveEnvEvidence`,
//! `EnvironmentEvidence`, `ProcessEvidence`, `MachineEvidence`,
//! `RiskDelta`, `EnforcementResult`).
//!
//! `AttestContract` lives in [`crate::attest_contract`] as a distinct type
//! per ec Phase 0 audit §1.5.1 — `tsafe-core::contracts::AuthorityContract`
//! (vault-policy semantics) and the algol-merged attestation contract
//! (env-injection semantics) have zero field overlap and must not be merged
//! at the field level.
//!
//! # Phase 4 wire-format changes (ec ADR-0003 + schema rename)
//!
//! Hash family converges to BLAKE3 (`blake3:<64 hex>`) for all four
//! fingerprint slots in `RunEvidence`:
//!
//! - `contract.hash`
//! - `environment.secrets_injected[].hash`
//! - `environment.sensitive_env_denied[].hash`
//! - `machine.{hostname_hash, username_hash}`
//!
//! Schema renames:
//!
//! - `algol.run.v1` -> `tsafe.run.v1`
//! - Field rename `algol_version` -> `tsafe_attest_version`
//!
//! Backward-compat deserialization: any of the legacy `algol.run.v1`
//! schema name, the `sha256:` hash prefix on inputs, or the `algol_version`
//! field name is still accepted on parse so existing audit-trail
//! consumers can migrate incrementally. The compat window scope is
//! documented in CHANGELOG: `v1.2.x` deserializes legacy artifacts;
//! `v1.3.x` will emit warnings; `v2.0.0` will remove compat. NEW emission
//! is always `tsafe.run.v1` + BLAKE3.
//!
//! # Platform scope
//!
//! Linux + macOS first per ec Phase 0 audit §3 (Windows scope option (c)).
//! Windows callers opt in via the `windows-experimental` Cargo feature
//! (see crate `Cargo.toml`).

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

use crate::sign::SignaturePayload;

/// Schema version for the `RunEvidence` artifact (current canonical).
///
/// New emissions tag `schema = "tsafe.run.v1"`. The legacy
/// [`LEGACY_RUN_SCHEMA`] (`algol.run.v1`) is still accepted by
/// [`RunEvidence::validation_errors`] during the v1.x compat window.
pub const RUN_SCHEMA: &str = "tsafe.run.v1";

/// Legacy schema name accepted during the v1.x compat window.
///
/// Phase 4 rename: callers reading older `algol.run.v1` artifacts continue
/// to parse cleanly. New emission is `tsafe.run.v1`. Removal scheduled
/// for v2.0.0; see `CHANGELOG.md`.
pub const LEGACY_RUN_SCHEMA: &str = "algol.run.v1";

/// Version string embedded in `RunEvidence.tsafe_attest_version`.
///
/// Phase 4 rename: the wire-shape field name on new emissions is
/// `tsafe_attest_version`. Legacy `algol_version` is accepted on parse via
/// the `serde(alias)` declaration on the field below.
pub const RUN_EVIDENCE_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Reference to a written contract artifact.
///
/// Carries the on-disk path the contract was loaded from and a BLAKE3
/// hash of the contract bytes so consumers can detect divergence. Legacy
/// `sha256:` hashes are accepted on parse during the v1.x compat window;
/// new emissions are always `blake3:`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContractRef {
    pub path: String,
    pub hash: String,
}

/// Evidence that a single secret was injected into the child process env.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InjectedSecretEvidence {
    pub name: String,
    pub source: String,
    pub hash: String,
    pub redacted_value: String,
    pub required: bool,
}

/// Evidence that a sensitive parent-env variable was denied (stripped).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeniedSensitiveEnvEvidence {
    pub name: String,
    pub hash: String,
    pub reason: String,
}

/// Aggregate environment diff between parent and child processes.
///
/// `parent_env_count` and `child_env_count` are total entry counts in each
/// process's env block. `removed_env_count` is the count of entries that
/// existed in the parent but were stripped from the child — this must
/// satisfy the invariant `removed_env_count == parent_env_count -
/// child_env_count` (see [`RunEvidence::validation_errors`]).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnvironmentEvidence {
    pub parent_env_count: usize,
    pub child_env_count: usize,
    pub removed_env_count: usize,
    pub safe_baseline_injected: Vec<String>,
    pub secrets_injected: Vec<InjectedSecretEvidence>,
    pub sensitive_env_denied: Vec<DeniedSensitiveEnvEvidence>,
}

/// Evidence of the child process's lifecycle.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProcessEvidence {
    pub pid: u32,
    pub exit_code: i32,
    pub duration_ms: u128,
    pub cwd: String,
}

/// Evidence of the host machine the run happened on.
///
/// Hostname and username are recorded as BLAKE3 hashes only — the
/// plaintext values must not appear in the artifact. The os/arch
/// strings are platform-neutral identifiers (`linux`, `darwin`,
/// `x86_64`, `aarch64`, etc.).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MachineEvidence {
    pub hostname_hash: String,
    pub username_hash: String,
    pub os: String,
    pub arch: String,
}

/// Before/after risk score for the enforcement window.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RiskDelta {
    pub before_score: u32,
    pub after_score: u32,
}

/// Enforcement outcome — did the contract hold, and what (if anything) failed?
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnforcementResult {
    pub contract_enforced: bool,
    pub violations: Vec<String>,
    pub risk_delta: RiskDelta,
}

/// Typed-evidence artifact for a single attested command execution.
///
/// `RunEvidence` is the post-run truth record: every field is observed
/// after the child process exits. It carries:
///
/// - Identity of the run (schema, version, timestamps, repo, command)
/// - A pointer to the contract the run was enforced against (`contract`)
/// - The parent-vs-child env diff with per-var BLAKE3 hashes
///   (`environment`)
/// - Process lifecycle observations (`process`)
/// - Host fingerprint (hashed only) (`machine`)
/// - Enforcement verdict (`result`)
///
/// Construction is the caller's responsibility — this module owns the
/// type definition and validation only.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunEvidence {
    pub schema: String,
    /// Producing tool version (BLAKE3-converged `tsafe-attest` since v1.2.0).
    ///
    /// Legacy `algol_version` field name is accepted on parse via
    /// `serde(alias)` for the v1.x compat window; new emissions use
    /// `tsafe_attest_version`.
    #[serde(alias = "algol_version", rename = "tsafe_attest_version")]
    pub tsafe_attest_version: String,
    pub started_at: DateTime<Utc>,
    pub finished_at: DateTime<Utc>,
    pub repo_path: String,
    pub repo_commit: Option<String>,
    pub command: Vec<String>,
    pub contract: ContractRef,
    pub environment: EnvironmentEvidence,
    pub process: ProcessEvidence,
    pub machine: MachineEvidence,
    pub result: EnforcementResult,
    /// Optional Ed25519 signature over the canonical form of every
    /// other field on this artifact (Phase 5; see [`crate::sign`]).
    ///
    /// `None` on legacy / unsigned emissions; `Some(..)` when the
    /// producer had a signing key available and the operator did not
    /// opt out via `--no-sign`. Skipped from the wire form when absent
    /// so existing readers parse old artifacts unchanged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signature: Option<SignaturePayload>,
}

impl RunEvidence {
    /// Return all validation errors for this artifact.
    ///
    /// Empty vector indicates the artifact is structurally and
    /// semantically valid. Used by [`Self::ensure_valid`].
    ///
    /// During the v1.x compat window, both `tsafe.run.v1` and
    /// `algol.run.v1` are accepted for the schema field, and any hash
    /// field accepts either `blake3:` (canonical) or `sha256:` (legacy)
    /// prefixes.
    pub fn validation_errors(&self) -> Vec<String> {
        let mut errors = Vec::new();

        if !is_supported_run_schema(&self.schema) {
            errors.push(format!("unsupported schema {}", self.schema));
        }
        if self.tsafe_attest_version.trim().is_empty() {
            errors.push("tsafe_attest_version must not be empty".to_string());
        }
        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.contract.path.trim().is_empty() {
            errors.push("contract.path must not be empty".to_string());
        }
        if !is_supported_hash(&self.contract.hash) {
            errors.push(
                "contract.hash must be a blake3 hash (or sha256 during compat window)".to_string(),
            );
        }
        for item in &self.environment.secrets_injected {
            if !is_supported_hash(&item.hash) {
                errors.push(format!(
                    "secrets_injected {} hash must be a blake3 hash (or sha256 during compat window)",
                    item.name
                ));
            }
        }
        for item in &self.environment.sensitive_env_denied {
            if !is_supported_hash(&item.hash) {
                errors.push(format!(
                    "sensitive_env_denied {} hash must be a blake3 hash (or sha256 during compat window)",
                    item.name
                ));
            }
        }
        if !is_supported_hash(&self.machine.hostname_hash) {
            errors.push(
                "machine.hostname_hash must be a blake3 hash (or sha256 during compat window)"
                    .to_string(),
            );
        }
        if !is_supported_hash(&self.machine.username_hash) {
            errors.push(
                "machine.username_hash must be a blake3 hash (or sha256 during compat window)"
                    .to_string(),
            );
        }
        if self.environment.child_env_count > self.environment.parent_env_count {
            errors.push("child_env_count must not exceed parent_env_count".to_string());
        }
        let expected_removed = self
            .environment
            .parent_env_count
            .saturating_sub(self.environment.child_env_count);
        if self.environment.removed_env_count != expected_removed {
            errors.push(format!(
                "removed_env_count must equal parent_env_count - child_env_count ({expected_removed})"
            ));
        }

        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 `RunEvidence` schema names.
///
/// Accepts both the canonical [`RUN_SCHEMA`] (`tsafe.run.v1`) and the
/// legacy [`LEGACY_RUN_SCHEMA`] (`algol.run.v1`) during the v1.x compat
/// window.
pub fn is_supported_run_schema(schema: &str) -> bool {
    schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
}

/// Test whether a string is a valid BLAKE3 hash with the
/// `blake3:<64-hex-chars>` prefix convention (canonical Phase 4).
pub fn is_blake3_hash(value: &str) -> bool {
    let Some(hex) = value.strip_prefix("blake3:") else {
        return false;
    };
    hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
}

/// Test whether a string is a valid SHA-256 hash with the
/// `sha256:<64-hex-chars>` prefix convention (legacy / compat).
pub fn is_sha256_hash(value: &str) -> bool {
    let Some(hex) = value.strip_prefix("sha256:") else {
        return false;
    };
    hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
}

/// Test whether a hash string is one of the supported families.
///
/// Accepts BLAKE3 (canonical Phase 4) or SHA-256 (legacy) during the
/// v1.x compat window. New emission code should call [`blake3_hash`]
/// directly.
pub fn is_supported_hash(value: &str) -> bool {
    is_blake3_hash(value) || is_sha256_hash(value)
}

/// Build a `blake3:<hex>` hash from raw bytes.
///
/// This is the canonical content-hash helper per ec ADR-0003.
pub fn blake3_hash(value: impl AsRef<[u8]>) -> String {
    let digest = blake3::hash(value.as_ref());
    format!("blake3:{}", digest.to_hex())
}

/// Build a `sha256:<hex>` hash from raw bytes.
///
/// **Deprecated.** Retained for the v1.x compat window so callers
/// reading existing `algol.run.v1` artifacts (which carry `sha256:`
/// hashes) can validate against them. New emissions use
/// [`blake3_hash`].
#[deprecated(
    since = "1.2.0",
    note = "Use `blake3_hash` per ec ADR-0003. SHA-256 retained only for \
            compat-window reads of legacy `algol.run.v1` artifacts."
)]
pub fn sha256_hash(value: impl AsRef<[u8]>) -> String {
    use sha2::{Digest, Sha256};
    let digest = Sha256::digest(value.as_ref());
    format!("sha256:{}", hex_encode(&digest))
}

/// Minimal lowercase hex encoder so this module does not pull in a new
/// `hex` crate dependency just for the hash helper.
fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push(HEX[(byte >> 4) as usize] as char);
        out.push(HEX[(byte & 0x0f) as usize] as char);
    }
    out
}

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

    fn valid_run() -> RunEvidence {
        RunEvidence {
            schema: RUN_SCHEMA.to_string(),
            tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
            started_at: Utc::now(),
            finished_at: Utc::now(),
            repo_path: ".".to_string(),
            repo_commit: None,
            command: vec!["true".to_string()],
            contract: ContractRef {
                path: "tsafe.contract.json".to_string(),
                hash: blake3_hash("contract"),
            },
            environment: EnvironmentEvidence {
                parent_env_count: 3,
                child_env_count: 1,
                removed_env_count: 2,
                safe_baseline_injected: vec!["PATH".to_string()],
                secrets_injected: vec![InjectedSecretEvidence {
                    name: "DATABASE_URL".to_string(),
                    source: "literal://demo/DATABASE_URL".to_string(),
                    hash: blake3_hash("database"),
                    redacted_value: "po***se".to_string(),
                    required: true,
                }],
                sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
                    name: "AWS_SECRET_ACCESS_KEY".to_string(),
                    hash: blake3_hash("secret"),
                    reason: "test".to_string(),
                }],
            },
            process: ProcessEvidence {
                pid: 1,
                exit_code: 0,
                duration_ms: 1,
                cwd: ".".to_string(),
            },
            machine: MachineEvidence {
                hostname_hash: blake3_hash("host"),
                username_hash: blake3_hash("user"),
                os: "linux".to_string(),
                arch: "x86_64".to_string(),
            },
            result: EnforcementResult {
                contract_enforced: true,
                violations: Vec::new(),
                risk_delta: RiskDelta {
                    before_score: 10,
                    after_score: 0,
                },
            },
            signature: None,
        }
    }

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

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

    #[test]
    fn run_rejects_env_count_edges() {
        let mut child_exceeds_parent = valid_run();
        child_exceeds_parent.environment.parent_env_count = 2;
        child_exceeds_parent.environment.child_env_count = 3;
        child_exceeds_parent.environment.removed_env_count = 0;
        let errors = child_exceeds_parent.validation_errors().join("; ");
        assert!(errors.contains("child_env_count must not exceed parent_env_count"));

        let mut equal_counts = valid_run();
        equal_counts.environment.parent_env_count = 2;
        equal_counts.environment.child_env_count = 2;
        equal_counts.environment.removed_env_count = 1;
        let errors = equal_counts.validation_errors().join("; ");
        assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
        assert!(errors.contains("removed_env_count must equal"));

        let mut removed_mismatch = valid_run();
        removed_mismatch.environment.parent_env_count = 5;
        removed_mismatch.environment.child_env_count = 3;
        removed_mismatch.environment.removed_env_count = 1;
        let errors = removed_mismatch.validation_errors().join("; ");
        assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
        assert!(errors.contains("removed_env_count must equal"));
    }

    #[test]
    fn run_rejects_short_or_non_hex_hashes() {
        let mut short_hash = valid_run();
        short_hash.contract.hash = "blake3:abc123".to_string();
        assert!(short_hash
            .validation_errors()
            .iter()
            .any(|error| error.contains("contract.hash must be a blake3 hash")));

        let mut non_hex_hash = valid_run();
        non_hex_hash.machine.hostname_hash = format!("blake3:{}", "g".repeat(64));
        assert!(non_hex_hash
            .validation_errors()
            .iter()
            .any(|error| error.contains("machine.hostname_hash must be a blake3 hash")));
    }

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

    #[test]
    fn blake3_hash_produces_prefixed_64_hex_string() {
        let hash = blake3_hash("any payload");
        assert!(is_blake3_hash(&hash), "rejected own output: {hash}");
        assert_eq!(hash.len(), "blake3:".len() + 64);
        assert!(hash.starts_with("blake3:"));
    }

    #[test]
    fn blake3_hash_is_deterministic_and_distinct() {
        assert_eq!(blake3_hash("same input"), blake3_hash("same input"));
        assert_ne!(blake3_hash("input one"), blake3_hash("input two"));
    }

    #[test]
    fn is_blake3_hash_rejects_wrong_prefix_and_length() {
        assert!(!is_blake3_hash(
            "sha256:0000000000000000000000000000000000000000000000000000000000000000"
        ));
        assert!(!is_blake3_hash("blake3:short"));
        assert!(!is_blake3_hash("blake3:"));
        assert!(!is_blake3_hash(""));
        assert!(!is_blake3_hash(&format!("blake3:{}", "z".repeat(64))));
    }

    #[test]
    fn compat_legacy_schema_and_sha256_hashes_accepted() {
        let mut legacy = valid_run();
        legacy.schema = LEGACY_RUN_SCHEMA.to_string();
        // SHA-256 hash on a legacy artifact must remain accepted during compat.
        #[allow(deprecated)]
        let legacy_hash = sha256_hash("contract");
        legacy.contract.hash = legacy_hash;
        // Replace all hashes with sha256 to model an actual legacy artifact.
        #[allow(deprecated)]
        {
            for item in &mut legacy.environment.secrets_injected {
                item.hash = sha256_hash(&item.name);
            }
            for item in &mut legacy.environment.sensitive_env_denied {
                item.hash = sha256_hash(&item.name);
            }
            legacy.machine.hostname_hash = sha256_hash("host");
            legacy.machine.username_hash = sha256_hash("user");
        }
        assert!(
            legacy.ensure_valid().is_ok(),
            "legacy artifact must remain valid during compat: {:?}",
            legacy.validation_errors()
        );
    }

    #[test]
    fn compat_legacy_algol_version_field_name_deserializes() {
        // Round-trip from a legacy `algol_version`-keyed JSON document.
        let blob = serde_json::json!({
            "schema": LEGACY_RUN_SCHEMA,
            "algol_version": "0.1.0",
            "started_at": "2026-05-21T00:00:00Z",
            "finished_at": "2026-05-21T00:00:01Z",
            "repo_path": ".",
            "repo_commit": null,
            "command": ["true"],
            "contract": {
                "path": "algol.contract.json",
                "hash": format!("sha256:{}", "a".repeat(64)),
            },
            "environment": {
                "parent_env_count": 1,
                "child_env_count": 1,
                "removed_env_count": 0,
                "safe_baseline_injected": ["PATH"],
                "secrets_injected": [],
                "sensitive_env_denied": [],
            },
            "process": {
                "pid": 1,
                "exit_code": 0,
                "duration_ms": 1u64,
                "cwd": ".",
            },
            "machine": {
                "hostname_hash": format!("sha256:{}", "b".repeat(64)),
                "username_hash": format!("sha256:{}", "c".repeat(64)),
                "os": "linux",
                "arch": "x86_64",
            },
            "result": {
                "contract_enforced": true,
                "violations": [],
                "risk_delta": {"before_score": 10, "after_score": 0},
            },
        });
        let parsed: RunEvidence = serde_json::from_value(blob).expect("legacy json parses");
        assert_eq!(parsed.tsafe_attest_version, "0.1.0");
        assert!(parsed.ensure_valid().is_ok());
    }
}
