//! CloudEvents schema roundtrip tests (task C7).
//!
//! For each event family defined in `contracts/events/` this module:
//!   1. Constructs a real event using `CloudEvent::from_audit()`.
//!   2. Serialises it to `serde_json::Value`.
//!   3. Validates the value against the published JSON schema.
//!
//! ## `authority.exec` shape (all six schemas) — resolved 2026-05-16
//!
//! The JSON schemas previously defined `authority.exec` as an object with
//! required fields `command`, `pid`, and `authority_profile` (with
//! `additionalProperties: false`). The runtime implementation in
//! `CloudEvent::from_audit()` / `project_exec_context()` emits the
//! authority-contract domain model: `contract_name`, `target`, `trust_level`,
//! `network`, `allowed_secret_refs`, `required_secret_refs`, etc.
//!
//! The schemas have been updated (task C7-fix) to match the runtime's actual
//! output. All six published schemas now accept the full exec-context shape.
//! The `authority_exec_shape_matches_runtime` test below confirms that an event
//! WITH an exec-context authority block passes schema validation end-to-end.
//!
//! ## `browser` event family
//!
//! There is no operation string in `classify_operation()` that maps to
//! `com.tsafe.browser.phishing_blocked.v1`. The browser event type can only be
//! constructed via `CloudEvent::new()` with a hand-crafted payload — there is no
//! audit-log operation that routes through the browser schema. A `browser_event_gap`
//! test documents this and validates a hand-constructed minimal payload to confirm
//! the schema itself is valid and parseable.
//!
//! ## ADR-024 no-plaintext-secret invariant (C7.2)
//!
//! ADR-022 §5 states the invariant is currently "convention only, not enforced by
//! the Rust type system". The mechanical enforcement in this module works by:
//!   - serialising the full event JSON to a string
//!   - asserting no sensitive string appears anywhere in the serialised form
//!
//! This covers the outbox adapter path. The tracing/OTel path has no span
//! instrumentation in `events.rs` that emits secret values (the module uses
//! `tracing::warn!` only for adapter failures, never for event payloads), so
//! there are no span attributes to assert against. The `no_plaintext_in_spans_gap`
//! test documents this and confirms the `warn!` call sites are adapter-error-only.

use tsafe_core::{
    audit::AuditEntry,
    events::{key_ref, CloudEvent},
};

// ── schema helpers ────────────────────────────────────────────────────────────

/// Load a schema file relative to the workspace root.
///
/// `include_str!` paths are relative to the source file; since this test file
/// lives in `crates/tsafe-core/tests/`, walking up three levels reaches the
/// workspace root where `contracts/` lives.
macro_rules! schema_str {
    ($path:literal) => {
        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../", $path))
    };
}

fn validator_for_schema(schema_text: &str) -> jsonschema::Validator {
    let schema_value: serde_json::Value =
        serde_json::from_str(schema_text).expect("schema file must be valid JSON");
    jsonschema::validator_for(&schema_value).expect("schema must be a valid JSON Schema")
}

fn assert_validates(validator: &jsonschema::Validator, event: &CloudEvent, label: &str) {
    let value = serde_json::to_value(event).expect("CloudEvent must serialise to JSON");
    let errors: Vec<String> = validator
        .iter_errors(&value)
        .map(|e| format!("{e}"))
        .collect();
    assert!(
        errors.is_empty(),
        "{label}: schema validation failed with {} error(s):\n{}",
        errors.len(),
        errors.join("\n")
    );
}

// ── C7.1 — schema roundtrip tests ────────────────────────────────────────────

/// secret: `set` operation → `com.tsafe.vault.secret.set.v1`
#[test]
fn secret_set_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("prod", "set", Some("DB_PASSWORD"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret set");
}

/// secret: `get` operation → `com.tsafe.vault.secret.accessed.v1`
#[test]
fn secret_get_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("dev", "get", Some("API_KEY"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret get");
}

/// secret: `delete` operation → `com.tsafe.vault.secret.deleted.v1`
#[test]
fn secret_delete_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "delete", Some("OLD_TOKEN"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret delete");
}

/// secret: `rotate-due` operation → `com.tsafe.secret.rotation_due.v1`
///
/// This is a key-less secret event (no specific key involved in the rotation
/// check sweep); `key_ref` is expected to be `null`.
#[test]
fn secret_rotation_due_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "rotate-due", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "secret rotation_due");
}

/// session: `unlock` operation → `com.tsafe.session.unlocked.v1`
#[test]
fn session_unlocked_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/session/session-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "unlock", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "session unlocked");
}

/// vault: `init` operation → `com.tsafe.vault.created.v1`
#[test]
fn vault_created_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "init", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault created (init)");
}

/// vault: `rotate` operation → `com.tsafe.vault.rotated.v1`
#[test]
fn vault_rotated_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "rotate", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault rotated");
}

/// vault: `export` operation → `com.tsafe.vault.exported.v1`
///
/// Note: `classify_operation("export")` maps this to the *secret* domain
/// (`SecretLifecycleState::Exported`) with event type `com.tsafe.vault.exported.v1`,
/// but the vault schema's `type` enum includes `com.tsafe.vault.exported.v1`.
/// The secret schema does NOT include this type. This test validates against
/// the vault schema which correctly covers this type.
#[test]
fn vault_exported_event_validates_against_vault_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "export", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault exported");
}

/// vault: `exec` operation without authority context → `com.tsafe.vault.exec.v1`
///
/// The `authority` field is absent when no exec context is attached.
#[test]
fn vault_exec_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("work", "exec", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "vault exec (no authority)");
}

/// sync: `kv-pull` operation → `com.tsafe.sync.pull.completed.v1`
#[test]
fn sync_pull_completed_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/sync/sync-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "kv-pull", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "sync pull completed (kv-pull)");
}

/// sync: `vault-pull` variant — same schema type as `kv-pull`
#[test]
fn sync_vault_pull_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/sync/sync-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("work", "vault-pull", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "sync pull completed (vault-pull)");
}

/// share: `share-once` operation → `com.tsafe.share.published.v1`
#[test]
fn share_published_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/share/share-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "share-once", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "share published");
}

/// share: `receive-once` operation → `com.tsafe.share.consumed.v1`
#[test]
fn share_consumed_event_validates_against_schema() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/share/share-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "receive-once", None);
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "share consumed");
}

// ── C7.1 — browser gap test ───────────────────────────────────────────────────

/// Document the gap: no audit operation maps to `com.tsafe.browser.phishing_blocked.v1`.
///
/// The browser event schema exists for the browser extension to emit events,
/// but the tsafe-core audit log has no operation that produces a browser event
/// type via `CloudEvent::from_audit()`. The browser extension emits events
/// directly; tsafe-core has no server-side audit path for phishing blocks.
///
/// This test validates a hand-constructed minimal payload to confirm the schema
/// file is well-formed and parseable, but acknowledges that a real browser event
/// cannot be constructed via the standard `from_audit()` path.
#[test]
fn browser_event_gap_hand_constructed_payload_validates() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/browser/browser-event.v1.schema.json"
    ));
    // Construct a minimal browser event directly — the only way to exercise
    // this schema from Rust since no audit operation maps to it.
    let data = serde_json::json!({
        "audit_id": "00000000-0000-4000-8000-000000000001",
        "operation": "browser-phishing-blocked",
        "key_ref": null,
        "status": "success",
        "message": null
    });
    let event = CloudEvent::new(
        "tsafe/browser",
        "com.tsafe.browser.phishing_blocked.v1",
        data,
    );
    assert_validates(
        &validator,
        &event,
        "browser phishing_blocked (hand-constructed)",
    );
}

// ── authority.exec roundtrip test ────────────────────────────────────────────

/// Confirm that the `authority.exec` shape emitted by `project_exec_context()`
/// now passes schema validation (task C7-fix).
///
/// Previously the schemas defined a speculative `{command, pid, authority_profile}`
/// shape. The schemas were updated to match the runtime's actual output:
/// `{contract_name, target, trust_level, network, allowed_secret_refs, …}`.
///
/// This test constructs an exec-context event and validates it against the vault
/// schema — the primary schema for exec events — confirming the gap is closed.
#[test]
fn authority_exec_shape_matches_runtime() {
    use tsafe_core::{
        audit::{AuditContext, AuditExecContext},
        contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust},
        rbac::RbacProfile,
    };

    let validator = validator_for_schema(schema_str!(
        "contracts/events/vault/vault-event.v1.schema.json"
    ));

    let contract = AuthorityContract {
        name: "deploy".into(),
        profile: Some("main".into()),
        namespace: Some("infra".into()),
        access_profile: RbacProfile::ReadOnly,
        allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
        allow_all_secrets: false,
        required_secrets: vec!["DB_PASSWORD".into()],
        allowed_targets: vec!["deploy.sh".into()],
        trust: AuthorityTrust::Hardened,
        network: AuthorityNetworkPolicy::Restricted,
    };

    let entry = AuditEntry::success("main", "exec", None).with_context(AuditContext::from_exec(
        AuditExecContext::from_contract(&contract)
            .with_target("/scripts/deploy.sh")
            .with_injected_secrets(["DB_PASSWORD"])
            .with_missing_required_secrets(["API_KEY"])
            .with_dropped_env_names(["OPENAI_API_KEY"])
            .with_target_evaluation(&contract.evaluate_target(Some("/scripts/deploy.sh"))),
    ));

    let event = CloudEvent::from_audit(&entry);

    // The authority.exec block must be present in the emitted event.
    let exec = &event.data["authority"]["exec"];
    assert!(!exec.is_null(), "authority.exec must be present");
    assert_eq!(exec["contract_name"], "deploy");
    assert_eq!(exec["trust_level"], "hardened");
    assert_eq!(exec["network"], "restricted");

    // Schema validation must succeed — this is the core assertion of C7-fix.
    assert_validates(&validator, &event, "vault exec with authority (C7-fix)");
}

// ── C7.2 — no-plaintext-in-spans assertion ────────────────────────────────────

/// ADR-022 §5 / ADR-024: no plaintext secret value may appear in any emitted
/// event payload at any nesting depth.
///
/// This test covers the serialised-JSON surface (the universal representation
/// shared by all adapters: outbox file, webhook, NATS). It complements the
/// existing `no_plaintext_secret_value_in_event_payload` unit test in
/// `events.rs` by exercising the schema-validated path.
///
/// # OTel / tracing instrumentation gap
///
/// `events.rs` does not instrument event payloads via `tracing` spans or
/// attributes. The only `tracing::warn!` call sites in the module are
/// adapter-error paths (webhook POST failed, NATS publish failed) — they log
/// the adapter URL and error string, never the event payload or any secret
/// value. Therefore there are no span attributes to assert against for the
/// no-plaintext invariant; the serialised-JSON check below is the mechanical
/// enforcement point.
///
/// If future work adds `tracing::info_span!` around event construction or
/// adapter dispatch, a `tracing_subscriber::fmt::TestWriter`-based test must
/// be added to assert span fields do not contain secret values.
#[test]
fn no_plaintext_secret_in_serialised_events_all_families() {
    // Use values that would be obvious leaks if they appeared anywhere.
    let sensitive_key = "PROD_STRIPE_SECRET_KEY";
    let sensitive_value = "sk_live_super_secret_plaintext_value_9999";

    // Each event family is exercised with an operation that includes a key.
    let families: &[(&str, &str, Option<&str>)] = &[
        ("secret", "set", Some(sensitive_key)),
        ("secret", "get", Some(sensitive_key)),
        ("secret", "delete", Some(sensitive_key)),
        ("session", "unlock", None),
        ("vault", "init", None),
        ("sync", "kv-pull", None),
        ("share", "share-once", None),
    ];

    for (family, operation, key) in families {
        let entry = AuditEntry::success("prod", operation, *key);
        let event = CloudEvent::from_audit(&entry);
        let serialised = serde_json::to_string(&event)
            .unwrap_or_else(|e| panic!("{family}/{operation}: serialise failed: {e}"));

        assert!(
            !serialised.contains(sensitive_key),
            "{family}/{operation}: plaintext key name '{sensitive_key}' leaked into event JSON"
        );
        assert!(
            !serialised.contains(sensitive_value),
            "{family}/{operation}: plaintext secret value leaked into event JSON"
        );

        // Confirm key_ref is present and opaque (when a key was supplied).
        if let Some(k) = key {
            let v: serde_json::Value = serde_json::from_str(&serialised).unwrap();
            let kr = v["data"]["key_ref"].as_str().unwrap_or_else(|| {
                panic!("{family}/{operation}: key_ref must be a string when key is present")
            });
            assert_eq!(
                kr.len(),
                64,
                "{family}/{operation}: key_ref must be 64-char SHA-256 hex"
            );
            assert_eq!(
                kr,
                key_ref("prod", k),
                "{family}/{operation}: key_ref must match SHA-256(profile:key)"
            );
        }
    }
}

/// Confirm that the `key_ref` opaqueness holds: the plaintext key name must not
/// be reconstructible from any field in the serialised event by a reader who
/// only sees the event (without access to the profile and key name).
#[test]
fn key_ref_is_opaque_in_schema_validated_event() {
    let validator = validator_for_schema(schema_str!(
        "contracts/events/secret/secret-event.v1.schema.json"
    ));
    let entry = AuditEntry::success("main", "set", Some("GITHUB_TOKEN"));
    let event = CloudEvent::from_audit(&entry);
    assert_validates(&validator, &event, "key_ref opaqueness check");

    let serialised = serde_json::to_string(&event).unwrap();
    assert!(
        !serialised.contains("GITHUB_TOKEN"),
        "plaintext key name must not appear anywhere in a schema-validated event"
    );

    let value: serde_json::Value = serde_json::from_str(&serialised).unwrap();
    let kr = value["data"]["key_ref"].as_str().unwrap();
    assert_eq!(kr, key_ref("main", "GITHUB_TOKEN"));
    assert_eq!(kr.len(), 64);
}
