//! Pinned-pubkey trust store for `RunEvidence` signature verification —
//! closes the Phase 5 TOFU gap.
//!
//! # Why this module exists
//!
//! Phase 5 ([`crate::sign`]) signs `RunEvidence` with an Ed25519 key and
//! embeds the verifying key on the artifact itself. Verification via
//! [`crate::sign::verify_signed_evidence`] is therefore **TOFU**
//! (Trust-On-First-Use): it proves the artifact was signed by *whoever
//! owns the embedded key*, NOT that the embedded key belongs to a
//! producer the operator actually trusts. `sign.rs` records this as an
//! explicit out-of-scope item ("PKI / pubkey-trust management … TOFU").
//!
//! This module supplies the missing half: a durable registry mapping a
//! human-readable **identity name** to a pinned Ed25519 **public key**.
//! Once an operator has pinned `ci-prod -> <pubkey>` out of band, a
//! verifier can demand that an artifact's embedded pubkey match a *known*
//! pinned identity — turning "signed by someone" into "signed by
//! `ci-prod`", and **failing closed** for any key not on the list.
//!
//! # Trust model (honest disclosure)
//!
//! - Pinning is the operator's out-of-band act. The store does not (and
//!   cannot) attest that a pinned key is the "right" one — it records the
//!   operator's decision and enforces it consistently thereafter.
//! - The store is integrity-relevant but not itself a secret: it holds
//!   only public keys. An attacker who can rewrite the store file can
//!   pin their own key; that is the same trust boundary as any local
//!   allow-list and is out of scope here (defend the file with normal FS
//!   permissions / the same posture as `config.json`).
//! - This closes the *embedded-pubkey* TOFU gap (you no longer have to
//!   trust the key the artifact hands you). It does NOT add a transparency
//!   log or third-party timestamp — those remain deferred (see
//!   `sign.rs` "Out of scope").
//!
//! # Wire format
//!
//! A single JSON file, `trust-store.json`, under the platform config
//! root (see [`crate::profile`]). Stable, hand-auditable shape:
//!
//! ```json
//! {
//!   "schema": "tsafe.attest_trust_store.v1",
//!   "pins": [
//!     { "name": "ci-prod", "algo": "ed25519", "pubkey": "<base64url>" }
//!   ]
//! }
//! ```

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::sign::{decode_verifying_key, SignaturePayload, SIG_ALGO_ED25519};

/// Schema identifier embedded in the persisted trust-store file.
pub const TRUST_STORE_SCHEMA: &str = "tsafe.attest_trust_store.v1";

/// Filename of the trust store under the config root.
pub const TRUST_STORE_FILENAME: &str = "trust-store.json";

/// A single pinned identity: a name the operator chose, bound to a
/// base64url-encoded Ed25519 verifying key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustPin {
    /// Operator-chosen identity name (e.g. `ci-prod`, `alice-laptop`).
    pub name: String,
    /// Signature algorithm. Always [`SIG_ALGO_ED25519`] in v1.
    pub algo: String,
    /// Verifying-key bytes, base64url-encoded, no padding (32 bytes
    /// decoded) — the same encoding [`SignaturePayload::pubkey`] uses.
    pub pubkey: String,
}

/// The persisted trust store: a schema tag plus the ordered list of pins.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustStore {
    /// Schema identifier. Always [`TRUST_STORE_SCHEMA`] for files this
    /// version writes; parsing tolerates a missing tag (legacy/empty).
    #[serde(default = "default_schema")]
    pub schema: String,
    /// Pinned identities, in insertion order.
    #[serde(default)]
    pub pins: Vec<TrustPin>,
}

fn default_schema() -> String {
    TRUST_STORE_SCHEMA.to_string()
}

impl Default for TrustStore {
    fn default() -> Self {
        TrustStore {
            schema: default_schema(),
            pins: Vec::new(),
        }
    }
}

/// Errors arising from trust-store operations.
#[derive(Debug, Error)]
pub enum TrustStoreError {
    /// I/O failure reading or writing the store file.
    #[error("trust store I/O at {path}: {source}")]
    Io {
        /// The path that failed.
        path: PathBuf,
        /// Underlying I/O error.
        #[source]
        source: std::io::Error,
    },
    /// The store file existed but did not parse as JSON.
    #[error("trust store at {path} is corrupt: {source}")]
    Parse {
        /// The path that failed to parse.
        path: PathBuf,
        /// Underlying serde error.
        #[source]
        source: serde_json::Error,
    },
    /// Serialising the store before write failed.
    #[error("serialise trust store: {0}")]
    Serialize(#[source] serde_json::Error),
    /// A pin's pubkey field was not a valid base64url Ed25519 key.
    #[error("pin '{name}' has an invalid Ed25519 pubkey: {source}")]
    InvalidPin {
        /// The offending pin name.
        name: String,
        /// Underlying decode error.
        #[source]
        source: crate::sign::VerifyError,
    },
    /// The pin algorithm was not one this version understands.
    #[error("pin '{name}' uses unsupported algorithm '{algo}'")]
    UnsupportedAlgorithm {
        /// The offending pin name.
        name: String,
        /// The unsupported algorithm string.
        algo: String,
    },
    /// Attempted to add a pin whose name already exists.
    #[error("a pin named '{0}' already exists; remove it first or choose another name")]
    DuplicateName(String),
    /// Attempted to remove a pin that does not exist.
    #[error("no pin named '{0}' is present in the trust store")]
    NoSuchPin(String),
}

impl TrustStore {
    /// Default store path: `<config-root>/trust-store.json`.
    ///
    /// Honors `TSAFE_VAULT_DIR` via [`crate::profile::config_path`]'s
    /// sibling logic so tests and sandboxes can redirect it.
    pub fn default_path() -> PathBuf {
        // Co-locate with config.json so the trust store follows the same
        // root-override (TSAFE_VAULT_DIR) and platform-config rules.
        crate::profile::config_path()
            .parent()
            .map(|p| p.join(TRUST_STORE_FILENAME))
            .unwrap_or_else(|| PathBuf::from(TRUST_STORE_FILENAME))
    }

    /// Load the store from `path`, returning an empty store if the file
    /// does not exist (first-run is not an error).
    pub fn load(path: &Path) -> Result<TrustStore, TrustStoreError> {
        match std::fs::read_to_string(path) {
            Ok(contents) => {
                serde_json::from_str(&contents).map_err(|source| TrustStoreError::Parse {
                    path: path.to_path_buf(),
                    source,
                })
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::default()),
            Err(source) => Err(TrustStoreError::Io {
                path: path.to_path_buf(),
                source,
            }),
        }
    }

    /// Load from the default path.
    pub fn load_default() -> Result<TrustStore, TrustStoreError> {
        Self::load(&Self::default_path())
    }

    /// Persist the store to `path` atomically (write-tmp + rename).
    pub fn save(&self, path: &Path) -> Result<(), TrustStoreError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|source| TrustStoreError::Io {
                path: parent.to_path_buf(),
                source,
            })?;
        }
        let json = serde_json::to_string_pretty(self).map_err(TrustStoreError::Serialize)?;
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, json).map_err(|source| TrustStoreError::Io {
            path: tmp.clone(),
            source,
        })?;
        std::fs::rename(&tmp, path).map_err(|source| TrustStoreError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        Ok(())
    }

    /// Add a pin. Validates that `pubkey` decodes to a real Ed25519 key
    /// and `algo` is supported BEFORE persisting, so a corrupt pin can
    /// never enter the store. Rejects a duplicate name (use
    /// [`TrustStore::remove`] first to rotate a key under an existing
    /// name).
    pub fn add(&mut self, name: &str, algo: &str, pubkey: &str) -> Result<(), TrustStoreError> {
        if algo != SIG_ALGO_ED25519 {
            return Err(TrustStoreError::UnsupportedAlgorithm {
                name: name.to_string(),
                algo: algo.to_string(),
            });
        }
        if self.pins.iter().any(|p| p.name == name) {
            return Err(TrustStoreError::DuplicateName(name.to_string()));
        }
        // Fail-closed: never persist a pin we cannot decode.
        decode_verifying_key(pubkey).map_err(|source| TrustStoreError::InvalidPin {
            name: name.to_string(),
            source,
        })?;
        self.pins.push(TrustPin {
            name: name.to_string(),
            algo: algo.to_string(),
            pubkey: pubkey.to_string(),
        });
        Ok(())
    }

    /// Remove the pin named `name`. Errors if no such pin exists.
    pub fn remove(&mut self, name: &str) -> Result<TrustPin, TrustStoreError> {
        match self.pins.iter().position(|p| p.name == name) {
            Some(idx) => Ok(self.pins.remove(idx)),
            None => Err(TrustStoreError::NoSuchPin(name.to_string())),
        }
    }

    /// Look up the pinned identity that owns `pubkey`, if any.
    ///
    /// Comparison is on the **decoded** key bytes, not the string, so two
    /// base64url encodings of the same key (or differing-by-padding
    /// representations) still match.
    pub fn identity_for_pubkey(&self, pubkey_b64url: &str) -> Option<&TrustPin> {
        let target = decode_verifying_key(pubkey_b64url).ok()?;
        self.pins.iter().find(|p| {
            decode_verifying_key(&p.pubkey)
                .map(|k| k.as_bytes() == target.as_bytes())
                .unwrap_or(false)
        })
    }

    /// Resolve which pinned identity (if any) a signature payload's
    /// embedded pubkey corresponds to. This is the fail-closed gate's
    /// core question: "is the signer a key we have pinned?"
    pub fn identity_for_signature(&self, sig: &SignaturePayload) -> Option<&TrustPin> {
        self.identity_for_pubkey(&sig.pubkey)
    }

    /// True when at least one pin is present.
    pub fn is_empty(&self) -> bool {
        self.pins.is_empty()
    }

    /// Number of pinned identities.
    pub fn len(&self) -> usize {
        self.pins.len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sign::{sign_evidence, SignaturePayload};
    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    use base64::Engine as _;
    use ed25519_dalek::SigningKey;
    use rand::rngs::OsRng;

    fn fresh_key() -> SigningKey {
        SigningKey::generate(&mut OsRng)
    }

    fn pubkey_b64url(key: &SigningKey) -> String {
        URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())
    }

    #[test]
    fn add_then_lookup_by_pubkey() {
        let key = fresh_key();
        let pk = pubkey_b64url(&key);
        let mut store = TrustStore::default();
        store.add("ci-prod", SIG_ALGO_ED25519, &pk).expect("add");
        let hit = store.identity_for_pubkey(&pk).expect("pinned identity");
        assert_eq!(hit.name, "ci-prod");
    }

    #[test]
    fn unknown_pubkey_is_not_found_fail_closed() {
        let pinned = fresh_key();
        let stranger = fresh_key();
        let mut store = TrustStore::default();
        store
            .add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&pinned))
            .expect("add");
        // A key we never pinned must resolve to no identity — the
        // fail-closed property the gate relies on.
        assert!(store
            .identity_for_pubkey(&pubkey_b64url(&stranger))
            .is_none());
    }

    #[test]
    fn add_rejects_invalid_pubkey_and_does_not_persist() {
        let mut store = TrustStore::default();
        let err = store
            .add("bad", SIG_ALGO_ED25519, "not-a-valid-key!!!")
            .unwrap_err();
        assert!(matches!(err, TrustStoreError::InvalidPin { .. }));
        assert!(store.is_empty(), "a rejected pin must not enter the store");
    }

    #[test]
    fn add_rejects_unsupported_algorithm() {
        let key = fresh_key();
        let mut store = TrustStore::default();
        let err = store
            .add("p256", "ecdsa-p256", &pubkey_b64url(&key))
            .unwrap_err();
        assert!(matches!(err, TrustStoreError::UnsupportedAlgorithm { .. }));
        assert!(store.is_empty());
    }

    #[test]
    fn add_rejects_duplicate_name() {
        let a = fresh_key();
        let b = fresh_key();
        let mut store = TrustStore::default();
        store
            .add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&a))
            .expect("add a");
        let err = store
            .add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&b))
            .unwrap_err();
        assert!(matches!(err, TrustStoreError::DuplicateName(_)));
    }

    #[test]
    fn remove_existing_and_missing() {
        let key = fresh_key();
        let mut store = TrustStore::default();
        store
            .add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&key))
            .expect("add");
        let removed = store.remove("ci").expect("remove");
        assert_eq!(removed.name, "ci");
        assert!(store.is_empty());
        let err = store.remove("ci").unwrap_err();
        assert!(matches!(err, TrustStoreError::NoSuchPin(_)));
    }

    #[test]
    fn save_then_load_round_trips() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("trust-store.json");
        let key = fresh_key();
        let mut store = TrustStore::default();
        store
            .add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&key))
            .expect("add");
        store.save(&path).expect("save");

        let reloaded = TrustStore::load(&path).expect("load");
        assert_eq!(reloaded, store);
        assert_eq!(reloaded.schema, TRUST_STORE_SCHEMA);
        assert_eq!(reloaded.len(), 1);
    }

    #[test]
    fn load_missing_file_is_empty_not_error() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("does-not-exist.json");
        let store = TrustStore::load(&path).expect("missing file => empty store");
        assert!(store.is_empty());
    }

    #[test]
    fn identity_for_signature_matches_signed_artifact() {
        // End-to-end: sign an artifact, pin the signer, confirm the
        // store resolves the embedded signature pubkey to the identity.
        use crate::run_evidence::{
            blake3_hash, ContractRef, EnforcementResult, EnvironmentEvidence, MachineEvidence,
            ProcessEvidence, RiskDelta, RunEvidence, RUN_EVIDENCE_VERSION, RUN_SCHEMA,
        };
        use chrono::Utc;

        let now = Utc::now();
        let evidence = RunEvidence {
            schema: RUN_SCHEMA.to_string(),
            tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
            started_at: now,
            finished_at: now,
            repo_path: "/tmp/x".to_string(),
            repo_commit: None,
            command: vec!["true".to_string()],
            contract: ContractRef {
                path: "c.json".to_string(),
                hash: blake3_hash("c"),
            },
            environment: EnvironmentEvidence {
                parent_env_count: 0,
                child_env_count: 0,
                removed_env_count: 0,
                safe_baseline_injected: vec![],
                secrets_injected: vec![],
                sensitive_env_denied: vec![],
            },
            process: ProcessEvidence {
                pid: 1,
                exit_code: 0,
                duration_ms: 1,
                cwd: "/tmp".to_string(),
            },
            machine: MachineEvidence {
                hostname_hash: blake3_hash("h"),
                username_hash: blake3_hash("u"),
                os: "linux".to_string(),
                arch: "x86_64".to_string(),
            },
            result: EnforcementResult {
                contract_enforced: true,
                violations: vec![],
                risk_delta: RiskDelta {
                    before_score: 0,
                    after_score: 0,
                },
            },
            signature: None,
        };

        let key = fresh_key();
        let signed = sign_evidence(&evidence, &key).expect("sign");
        let sig: SignaturePayload = signed.signature;

        let mut store = TrustStore::default();
        // Before pinning: signature resolves to no identity (fail-closed).
        assert!(store.identity_for_signature(&sig).is_none());

        store
            .add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&key))
            .expect("pin signer");
        let id = store
            .identity_for_signature(&sig)
            .expect("pinned signer resolves");
        assert_eq!(id.name, "ci-prod");
    }
}
