//! FC-20 — lifecycle terminal-state naming reconciliation.
//!
//! See [`docs/cloudevents-lifecycle-naming.md`](../../../../docs/cloudevents-lifecycle-naming.md).
//!
//! These tests pin the wire surface for the `complete-clean` / `complete-forced`
//! distinction documented in `Plans/firecracker-release-readiness.md`:
//!
//! - The CloudEvent `type` is **always**
//!   `dev.cellos.events.cell.lifecycle.v1.destroyed` for both terminal states;
//!   no parallel `complete-clean` / `complete-forced` event types exist on the
//!   wire.
//! - The terminal-state distinction is encoded in `data.terminalState` as the
//!   lowercase string `"clean"` or `"forced"`.
//! - On `forced`, `data.reason` carries the rationale (vsock-bridge failure
//!   mode / SIGKILL trigger).
//! - When the host backend does not run an in-VM bridge, `data.terminalState`
//!   is **omitted** (the field is absent, not `null`) so v1 consumers parse
//!   legacy payloads unchanged.

use cellos_core::events::{
    lifecycle_destroyed_data_v1, LifecycleDestroyOutcome, LifecycleTerminalState,
};
use cellos_core::{AuthorityBundle, CloudEventV1, ExecutionCellSpec, Lifetime};
use serde_json::Value;

const DESTROYED_EVENT_TYPE: &str = "dev.cellos.events.cell.lifecycle.v1.destroyed";

fn fixture_spec(id: &str) -> ExecutionCellSpec {
    ExecutionCellSpec {
        id: id.into(),
        correlation: None,
        ingress: None,
        environment: None,
        placement: None,
        policy: None,
        identity: None,
        run: None,
        authority: AuthorityBundle::default(),
        lifetime: Lifetime { ttl_seconds: 60 },
        export: None,
        telemetry: None,
    }
}

/// Mirror of the supervisor's `cloud_event(...)` envelope helper — kept local
/// so this test asserts on the same JSON shape an EventSink would observe.
fn envelope(ty: &str, data: Value) -> CloudEventV1 {
    CloudEventV1 {
        specversion: "1.0".into(),
        id: "urn:cellos:event:test-fixture".into(),
        source: "cellos-supervisor".into(),
        ty: ty.into(),
        datacontenttype: Some("application/json".into()),
        data: Some(data),
        time: Some("2026-05-07T00:00:00Z".into()),
        traceparent: None,
    }
}

/// Walks the wire JSON and asserts both invariants for `Clean`:
/// envelope `type` is the canonical destroyed event, and `data.terminalState`
/// is the lowercase `"clean"` discriminator.
#[test]
fn clean_terminal_state_wire_shape() {
    let spec = fixture_spec("cell-clean");
    let data = lifecycle_destroyed_data_v1(
        &spec,
        "cell-clean",
        Some("run-clean"),
        LifecycleDestroyOutcome::Succeeded,
        None,
        Some(LifecycleTerminalState::Clean),
        None,
        None,
    )
    .expect("build clean destroyed payload");

    let ev = envelope(DESTROYED_EVENT_TYPE, data);
    let wire: Value = serde_json::to_value(&ev).expect("serialize envelope");

    // Envelope: the canonical destroyed event type is the only lifecycle
    // terminal event on the wire.
    assert_eq!(
        wire["type"], DESTROYED_EVENT_TYPE,
        "CloudEvent type must be the canonical destroyed type for clean exits"
    );
    assert_eq!(wire["specversion"], "1.0");

    // Discriminator: lowercase `"clean"` per `LifecycleTerminalState`'s
    // `#[serde(rename_all = "lowercase")]`.
    let payload = &wire["data"];
    assert!(payload.is_object(), "data must be a JSON object");
    assert_eq!(
        payload["terminalState"], "clean",
        "data.terminalState must be the lowercase 'clean' discriminator"
    );
    assert_eq!(payload["outcome"], "succeeded");
    assert_eq!(payload["cellId"], "cell-clean");
    assert_eq!(payload["runId"], "run-clean");
}

/// Walks the wire JSON and asserts both invariants for `Forced`:
/// envelope `type` is the canonical destroyed event, `data.terminalState`
/// is the lowercase `"forced"` discriminator, and `data.reason` carries the
/// SIGKILL / teardown rationale.
#[test]
fn forced_terminal_state_wire_shape_with_reason() {
    let spec = fixture_spec("cell-forced");
    let data = lifecycle_destroyed_data_v1(
        &spec,
        "cell-forced",
        Some("run-forced"),
        LifecycleDestroyOutcome::Failed,
        Some("ttl_exceeded: SIGKILL after graceful-shutdown timeout"),
        Some(LifecycleTerminalState::Forced),
        None,
        None,
    )
    .expect("build forced destroyed payload");

    let ev = envelope(DESTROYED_EVENT_TYPE, data);
    let wire: Value = serde_json::to_value(&ev).expect("serialize envelope");

    assert_eq!(
        wire["type"], DESTROYED_EVENT_TYPE,
        "CloudEvent type must be the canonical destroyed type for forced exits too"
    );

    let payload = &wire["data"];
    assert!(payload.is_object(), "data must be a JSON object");
    assert_eq!(
        payload["terminalState"], "forced",
        "data.terminalState must be the lowercase 'forced' discriminator"
    );
    assert_eq!(
        payload["reason"], "ttl_exceeded: SIGKILL after graceful-shutdown timeout",
        "forced terminal state must carry a reason"
    );
    assert_eq!(payload["outcome"], "failed");
    assert_eq!(payload["cellId"], "cell-forced");
    assert_eq!(payload["runId"], "run-forced");
}

/// Negative invariant: there are no `complete-clean` / `complete-forced`
/// event types on the wire. Auditors who key on event type alone (without
/// inspecting `data.terminalState`) MUST NOT find these strings anywhere on
/// the destroyed payload — neither in the envelope `type`, nor in the data
/// JSON, nor in the discriminator string.
#[test]
fn no_parallel_complete_event_types_on_wire() {
    let spec = fixture_spec("cell-neg");

    for terminal_state in [
        LifecycleTerminalState::Clean,
        LifecycleTerminalState::Forced,
    ] {
        let data = lifecycle_destroyed_data_v1(
            &spec,
            "cell-neg",
            Some("run-neg"),
            LifecycleDestroyOutcome::Succeeded,
            None,
            Some(terminal_state),
            None,
            None,
        )
        .expect("build destroyed payload");
        let ev = envelope(DESTROYED_EVENT_TYPE, data);
        let serialized = serde_json::to_string(&ev).expect("serialize envelope");

        assert!(
            !serialized.contains("complete-clean"),
            "wire JSON must not contain 'complete-clean' (canonical type is destroyed): {serialized}"
        );
        assert!(
            !serialized.contains("complete-forced"),
            "wire JSON must not contain 'complete-forced' (canonical type is destroyed): {serialized}"
        );
        assert!(
            !serialized.contains("lifecycle.v1.complete"),
            "wire JSON must not contain 'lifecycle.v1.complete' as an event type fragment: {serialized}"
        );
    }
}

/// Backward-compat invariant: when the host backend does not run an in-VM
/// bridge, `terminal_state` is `None` and the field is **omitted** from the
/// JSON object (not serialized as `null`). This is what the
/// `lifecycle_destroyed_data_v1` doc-comment promises to v1 consumers.
#[test]
fn host_spawn_backend_omits_terminal_state() {
    let spec = fixture_spec("cell-host");
    let data = lifecycle_destroyed_data_v1(
        &spec,
        "cell-host",
        None,
        LifecycleDestroyOutcome::Succeeded,
        None,
        None,
        None,
        None,
    )
    .expect("build host-spawn destroyed payload");

    let payload_obj = data.as_object().expect("data must be a JSON object");
    assert!(
        !payload_obj.contains_key("terminalState"),
        "terminalState must be ABSENT (not null) on host-spawn backends: {data}"
    );

    // Wrap to ensure the envelope round-trip preserves the omission.
    let ev = envelope(DESTROYED_EVENT_TYPE, data);
    let wire: Value = serde_json::to_value(&ev).expect("serialize envelope");
    let wire_payload = wire["data"].as_object().expect("data must be an object");
    assert!(
        !wire_payload.contains_key("terminalState"),
        "terminalState must remain absent after envelope serialization"
    );
    assert_eq!(wire["type"], DESTROYED_EVENT_TYPE);
}
