//! A2-02 / ADR-0007 — per-caller secretRef allowlist gate.
//!
//! Pins the cellos-core contract for
//! [`validate_secret_refs_against_allowlist`]: the gate is opt-in (absent
//! `secretRefAllowlist` admits everything), and when present it rejects
//! callers that aren't mapped or that request a secretRef the allowlist
//! doesn't grant.
//!
//! D11 boundary: `cellos-core` does not read process env vars. The caller
//! identity is supplied as a parameter; the supervisor side
//! (`crates/cellos-supervisor/src/composition.rs::resolve_caller_identity`)
//! is responsible for reading `CELLOS_CALLER_IDENTITY` and threading the
//! resolved value in. See ADR-0007 (`docs/adr/0007-rbac-secret-ref-admission.md`).

use std::collections::HashMap;

use cellos_core::{
    policy::validate_secret_refs_against_allowlist,
    types::{AuthorityBundle, Lifetime, RunSpec},
    CellosError, ExecutionCellSpec, PolicyRules, SecretDeliveryMode,
};

/// Build a minimal spec with the supplied secret refs (or `None` if absent).
fn spec_with_secret_refs(refs: Option<Vec<String>>) -> ExecutionCellSpec {
    ExecutionCellSpec {
        id: "test-cell".into(),
        correlation: None,
        ingress: None,
        environment: None,
        placement: None,
        policy: None,
        identity: None,
        run: Some(RunSpec {
            argv: vec!["/usr/bin/true".into()],
            working_directory: None,
            timeout_ms: None,
            limits: None,
            secret_delivery: SecretDeliveryMode::Env,
        }),
        authority: AuthorityBundle {
            filesystem: None,
            network: None,
            egress_rules: None,
            secret_refs: refs,
            authority_derivation: None,
            dns_authority: None,
            cdn_authority: None,
        },
        lifetime: Lifetime { ttl_seconds: 300 },
        export: None,
        telemetry: None,
    }
}

fn rules_with_allowlist(map: HashMap<String, Vec<String>>) -> PolicyRules {
    PolicyRules {
        secret_ref_allowlist: Some(map),
        ..Default::default()
    }
}

// ── Positive case: caller is in the allowlist with the requested ref ─────────

#[test]
fn admits_caller_with_granted_secret_ref() {
    let mut map = HashMap::new();
    map.insert(
        "ci-prod".to_string(),
        vec!["db-pass".to_string(), "api-key".to_string()],
    );
    let rules = rules_with_allowlist(map);
    let spec = spec_with_secret_refs(Some(vec!["db-pass".into()]));

    assert!(validate_secret_refs_against_allowlist(&spec, &rules, "ci-prod").is_ok());
}

#[test]
fn admits_caller_with_all_granted_refs() {
    let mut map = HashMap::new();
    map.insert(
        "ci-prod".to_string(),
        vec!["db-pass".to_string(), "api-key".to_string()],
    );
    let rules = rules_with_allowlist(map);
    let spec = spec_with_secret_refs(Some(vec!["db-pass".into(), "api-key".into()]));

    assert!(validate_secret_refs_against_allowlist(&spec, &rules, "ci-prod").is_ok());
}

#[test]
fn admits_caller_with_no_secret_refs_when_mapped() {
    // Caller is mapped but the spec asks for nothing — vacuously fine.
    let mut map = HashMap::new();
    map.insert("ci-prod".to_string(), vec!["db-pass".to_string()]);
    let rules = rules_with_allowlist(map);
    let spec = spec_with_secret_refs(None);

    assert!(validate_secret_refs_against_allowlist(&spec, &rules, "ci-prod").is_ok());
}

// ── Negative case: caller missing from allowlist ─────────────────────────────

#[test]
fn rejects_unmapped_caller_with_caller_unmapped_reason() {
    let mut map = HashMap::new();
    map.insert("ci-prod".to_string(), vec!["db-pass".to_string()]);
    let rules = rules_with_allowlist(map);
    let spec = spec_with_secret_refs(Some(vec!["db-pass".into()]));

    let err = validate_secret_refs_against_allowlist(&spec, &rules, "rogue-caller")
        .expect_err("unmapped caller must be rejected");
    match err {
        CellosError::InvalidSpec(msg) => {
            assert!(
                msg.contains("caller_unmapped"),
                "expected caller_unmapped reason, got: {msg}"
            );
            assert!(
                msg.contains("rogue-caller"),
                "expected caller identity in message, got: {msg}"
            );
        }
        other => panic!("expected InvalidSpec, got {other:?}"),
    }
}

// ── Negative case: caller mapped but ref not granted ─────────────────────────

#[test]
fn rejects_caller_requesting_ungranted_ref_with_secret_ref_denied_reason() {
    let mut map = HashMap::new();
    map.insert("ci-prod".to_string(), vec!["db-pass".to_string()]);
    let rules = rules_with_allowlist(map);
    let spec = spec_with_secret_refs(Some(vec!["db-pass".into(), "root-token".into()]));

    let err = validate_secret_refs_against_allowlist(&spec, &rules, "ci-prod")
        .expect_err("ungranted ref must be rejected");
    match err {
        CellosError::InvalidSpec(msg) => {
            assert!(
                msg.contains("secret_ref_denied"),
                "expected secret_ref_denied reason, got: {msg}"
            );
            assert!(
                msg.contains("root-token"),
                "expected denied ref in message, got: {msg}"
            );
        }
        other => panic!("expected InvalidSpec, got {other:?}"),
    }
}

#[test]
fn rejects_caller_with_empty_allowlist_entry_when_refs_requested() {
    // Caller is mapped to an empty list — every requested ref is denied.
    let mut map = HashMap::new();
    map.insert("ci-prod".to_string(), vec![]);
    let rules = rules_with_allowlist(map);
    let spec = spec_with_secret_refs(Some(vec!["db-pass".into()]));

    let err = validate_secret_refs_against_allowlist(&spec, &rules, "ci-prod")
        .expect_err("ungranted ref must be rejected even with empty allowlist entry");
    match err {
        CellosError::InvalidSpec(msg) => assert!(msg.contains("secret_ref_denied")),
        other => panic!("expected InvalidSpec, got {other:?}"),
    }
}

// ── Opt-in posture: absent allowlist skips the gate ─────────────────────────

#[test]
fn absent_allowlist_admits_unconditionally() {
    let rules = PolicyRules::default(); // secret_ref_allowlist: None
    let spec = spec_with_secret_refs(Some(vec!["db-pass".into(), "anything".into()]));

    // Even an unmapped caller and arbitrary refs admit when the allowlist
    // isn't authored. This is the audit-only / opt-in posture from ADR-0007.
    assert!(validate_secret_refs_against_allowlist(&spec, &rules, "default").is_ok());
    assert!(validate_secret_refs_against_allowlist(&spec, &rules, "anything").is_ok());
}

// ── Multi-caller curation: distinct callers get distinct allowlists ─────────

#[test]
fn distinct_callers_have_distinct_allowlists() {
    // ADR-0007 non-goal: roles are flat strings — no inheritance. Two
    // callers with overlapping but distinct allowlists are two independent
    // entries.
    let mut map = HashMap::new();
    map.insert(
        "ci-prod".to_string(),
        vec!["db-pass".to_string(), "api-key".to_string()],
    );
    map.insert("ci-staging".to_string(), vec!["api-key".to_string()]);
    let rules = rules_with_allowlist(map);

    // ci-prod can reach db-pass.
    let spec = spec_with_secret_refs(Some(vec!["db-pass".into()]));
    assert!(validate_secret_refs_against_allowlist(&spec, &rules, "ci-prod").is_ok());

    // ci-staging cannot.
    let err = validate_secret_refs_against_allowlist(&spec, &rules, "ci-staging")
        .expect_err("ci-staging is not granted db-pass");
    if let CellosError::InvalidSpec(msg) = err {
        assert!(msg.contains("secret_ref_denied"));
        assert!(msg.contains("db-pass"));
    } else {
        panic!("expected InvalidSpec");
    }
}
