//! E4-02 — Authority derivation signature rejection regression test.
//!
//! Guards the real ED25519 signature check shipped in E2-01
//! (`verify_authority_derivation`, `crates/cellos-core/src/spec_validation.rs`).
//!
//! Three cases:
//!   1. valid signature             → `Ok(())`
//!   2. forged signature (tampered) → `Err(CellosError::InvalidSpec(...))`
//!                                    with "authority derivation signature invalid"
//!   3. unsigned token (empty bytes)→ `Err(CellosError::InvalidSpec(...))`
//!                                    with the missing-signature ("must be 64 bytes")
//!                                    diagnostic
//!
//! `cellos-core::CellosError` collapses every spec-level rejection — including
//! signature-domain ones — into the `InvalidSpec` variant; the message is the
//! load-bearing discriminator the supervisor surfaces in audit events. Each
//! assertion below pins the exact substring so a future refactor that loses the
//! signature check would re-trip this test.
//!
//! Keys are derived from a fixed seed (no `OsRng`) so the test is deterministic
//! and dev-deps stay minimal.

use std::collections::HashMap;

use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use ed25519_dalek::{Signer as _, SigningKey};

use cellos_core::{
    spec_validation::authority_derivation_signing_payload, verify_authority_derivation,
    AuthorityBundle, AuthorityCapability, AuthorityDerivationToken, AuthorityNarrowing,
    AuthoritySignature, CellosError, EgressRule, ExecutionCellSpec, Lifetime, RoleId,
};

// ── fixtures ────────────────────────────────────────────────────────────────

const ROLE_ID: &str = "role-e4-02";

/// Deterministic ED25519 key — avoids `rand_core` / `OsRng` in dev-deps.
fn fixture_signing_key() -> SigningKey {
    SigningKey::from_bytes(&[0xA5; 32])
}

fn verifying_key_b64(signing_key: &SigningKey) -> String {
    STANDARD.encode(signing_key.verifying_key().to_bytes())
}

fn fixture_egress() -> Vec<EgressRule> {
    vec![EgressRule {
        host: "api.example.com".into(),
        port: 443,
        protocol: Some("https".into()),
        dns_egress_justification: None,
    }]
}

fn fixture_secret_refs() -> Vec<String> {
    vec!["api-key".into()]
}

fn fixture_capability() -> AuthorityCapability {
    AuthorityCapability {
        egress_rules: fixture_egress(),
        secret_refs: fixture_secret_refs(),
    }
}

/// Spec whose declared authority is a superset of the leaf the token will claim,
/// so the structural check passes and signature verification is the discriminator.
fn fixture_spec() -> ExecutionCellSpec {
    ExecutionCellSpec {
        id: "deriv-sig-regression".into(),
        correlation: None,
        ingress: None,
        environment: None,
        placement: None,
        policy: None,
        identity: None,
        run: None,
        authority: AuthorityBundle {
            filesystem: None,
            network: None,
            egress_rules: Some(fixture_egress()),
            secret_refs: Some(fixture_secret_refs()),
            authority_derivation: None,
            dns_authority: None,
            cdn_authority: None,
        },
        lifetime: Lifetime { ttl_seconds: 60 },
        export: None,
        telemetry: None,
    }
}

/// Build a token with `grantor_signature.bytes` initialized to the placeholder
/// value (`AA==` — a single zero byte). Callers either replace the bytes with a
/// real signature or leave the placeholder in place to exercise the unsigned path.
fn placeholder_token() -> AuthorityDerivationToken {
    AuthorityDerivationToken {
        role_root: RoleId(ROLE_ID.into()),
        parent_run_id: None,
        derivation_steps: Vec::<AuthorityNarrowing>::new(),
        leaf_capability: fixture_capability(),
        grantor_signature: AuthoritySignature {
            algorithm: "ed25519".into(),
            // Single zero byte — base64 decodes to 1 byte, NOT 64. This is the
            // "unsigned" / missing-signature shape the verifier rejects at the
            // length check.
            bytes: "AA==".into(),
        },
    }
}

/// Sign `token` in-place: write a real ED25519 signature over the canonical payload.
fn sign(token: &mut AuthorityDerivationToken, signing_key: &SigningKey) {
    let payload = authority_derivation_signing_payload(token).expect("encode signing payload");
    let signature = signing_key.sign(&payload);
    token.grantor_signature.bytes = STANDARD.encode(signature.to_bytes());
}

fn role_keys_with(signing_key: &SigningKey) -> HashMap<String, String> {
    let mut keys = HashMap::new();
    keys.insert(ROLE_ID.into(), verifying_key_b64(signing_key));
    keys
}

// ── cases ───────────────────────────────────────────────────────────────────

/// Case 1 — Valid signature is accepted.
#[test]
fn valid_signature_is_accepted() {
    let signing_key = fixture_signing_key();
    let mut token = placeholder_token();
    sign(&mut token, &signing_key);

    let spec = fixture_spec();
    let keys = role_keys_with(&signing_key);

    verify_authority_derivation(&spec, &token, &keys)
        .expect("a token signed with the declared role key must verify");
}

/// Case 2 — Forged signature (signed by a key that does NOT match the role's
/// verifying key) is rejected with the signature-invalid diagnostic.
#[test]
fn forged_signature_is_rejected() {
    let real_key = fixture_signing_key();
    // Attacker key — DIFFERENT from `real_key`. Token verifies syntactically
    // (correct length, valid base64) but the signature does not match the
    // role's registered verifying key, so `verify_strict` returns Err.
    let attacker_key = SigningKey::from_bytes(&[0x5A; 32]);
    let mut token = placeholder_token();
    sign(&mut token, &attacker_key);

    let spec = fixture_spec();
    // Role is registered to the REAL key, but token was signed by the attacker.
    let keys = role_keys_with(&real_key);

    let err = verify_authority_derivation(&spec, &token, &keys)
        .expect_err("forged signature must be rejected");
    let CellosError::InvalidSpec(msg) = err else {
        panic!("expected CellosError::InvalidSpec, got {err:?}");
    };
    assert!(
        msg.contains("authority derivation signature invalid"),
        "forged-signature rejection must surface the signature-invalid diagnostic; got: {msg}"
    );
}

/// Case 2b — Tampered signature (real key, then last byte flipped) is also
/// rejected with the signature-invalid diagnostic. Distinct from 2a in that the
/// signature WAS produced by the role's key, but mutated in transit. Pinned as
/// a separate case so a future regression that drops `verify_strict` (e.g.
/// silently accepting any 64-byte blob from the registered key) is caught.
#[test]
fn tampered_signature_is_rejected() {
    let signing_key = fixture_signing_key();
    let mut token = placeholder_token();
    sign(&mut token, &signing_key);

    // Flip the last byte of the signature — still 64 bytes, still valid base64,
    // but the signature no longer verifies against the canonical payload.
    let mut sig_bytes = STANDARD
        .decode(token.grantor_signature.bytes.as_bytes())
        .expect("decode the fresh signature we just produced");
    let last = sig_bytes.len() - 1;
    sig_bytes[last] ^= 0x01;
    token.grantor_signature.bytes = STANDARD.encode(&sig_bytes);

    let spec = fixture_spec();
    let keys = role_keys_with(&signing_key);

    let err = verify_authority_derivation(&spec, &token, &keys)
        .expect_err("tampered signature must be rejected");
    let CellosError::InvalidSpec(msg) = err else {
        panic!("expected CellosError::InvalidSpec, got {err:?}");
    };
    assert!(
        msg.contains("authority derivation signature invalid"),
        "tampered-signature rejection must surface the signature-invalid diagnostic; got: {msg}"
    );
}

/// Case 3 — Unsigned token (placeholder `AA==` bytes — base64 decodes to a
/// single zero byte, NOT 64) is rejected at the signature-length check.
///
/// This is the "missing signature" failure mode that E2-01 must reject. The
/// verifier's length-domain error is the missing-signature analog of the
/// signature-invalid error; pinning the substring here ensures a future change
/// that, e.g., zero-pads to 64 bytes (and would then fail `verify_strict` with
/// a different message) is still caught as a rejection — the test would simply
/// need to be updated to the new diagnostic intentionally.
#[test]
fn unsigned_token_is_rejected() {
    let signing_key = fixture_signing_key();
    // NOTE: deliberately do NOT call `sign()` — leave the `AA==` placeholder.
    let token = placeholder_token();

    let spec = fixture_spec();
    let keys = role_keys_with(&signing_key);

    let err = verify_authority_derivation(&spec, &token, &keys)
        .expect_err("unsigned token must be rejected");
    let CellosError::InvalidSpec(msg) = err else {
        panic!("expected CellosError::InvalidSpec, got {err:?}");
    };
    assert!(
        msg.contains("must be 64 bytes"),
        "unsigned-token rejection must surface the signature-length diagnostic \
         (missing-signature analog); got: {msg}"
    );
    assert!(
        msg.contains("authority derivation grantor signature"),
        "diagnostic must name the offending field; got: {msg}"
    );
}
