//! T11-4 — `cell.lifecycle.v1.started` MUST carry `placement` exactly as
//! declared on the spec so the state projector can answer
//! `GET /cells?poolId=X` without re-reading the source spec.
//!
//! These tests pin the on-the-wire contract:
//!
//! - Specs WITH `placement` produce a `placement` object on the event with
//!   the same set of populated fields (camelCase keys).
//! - Specs WITHOUT `placement` produce an event with NO `placement` key —
//!   the field is additive, not always-present, so v1 consumers stay
//!   backwards-compatible.
//! - The set of populated fields is preserved exactly: a spec that sets
//!   only `poolId` produces a `placement` object with only `poolId`,
//!   never an empty `kubernetesNamespace` or `queueName` shadow.
//!
//! This is the contract the projector's `/cells?poolId=X` filter depends
//! on. If this test fails, the projector's placement query is reading a
//! field that no longer flows from spec → lifecycle event → snapshot.

use cellos_core::events::lifecycle_started_data_v1;
use cellos_core::types::{AuthorityBundle, ExecutionCellSpec, Lifetime, PlacementSpec};

fn minimal_spec_with(placement: Option<PlacementSpec>) -> ExecutionCellSpec {
    ExecutionCellSpec {
        id: "t11-lifecycle-cell".into(),
        correlation: None,
        ingress: None,
        environment: None,
        placement,
        policy: None,
        identity: None,
        run: None,
        authority: AuthorityBundle::default(),
        lifetime: Lifetime { ttl_seconds: 60 },
        export: None,
        telemetry: None,
    }
}

#[test]
fn lifecycle_started_carries_full_placement_block() {
    let spec = minimal_spec_with(Some(PlacementSpec {
        pool_id: Some("runner-pool-amd64".into()),
        kubernetes_namespace: Some("cellos-prod".into()),
        queue_name: Some("ci-high".into()),
    }));
    let data = lifecycle_started_data_v1(
        &spec,
        "cell-t11-001",
        Some("run-t11-001"),
        None,
        None,
        None,
        None,
        None,
        None,
        None,
    )
    .expect("event JSON must build");

    let placement = data
        .get("placement")
        .expect("placement key must be present when spec carries placement");
    assert_eq!(placement["poolId"], "runner-pool-amd64");
    assert_eq!(placement["kubernetesNamespace"], "cellos-prod");
    assert_eq!(placement["queueName"], "ci-high");
}

#[test]
fn lifecycle_started_carries_partial_placement_block() {
    // Only poolId set — kubernetesNamespace and queueName MUST NOT appear.
    let spec = minimal_spec_with(Some(PlacementSpec {
        pool_id: Some("runner-pool-amd64".into()),
        kubernetes_namespace: None,
        queue_name: None,
    }));
    let data = lifecycle_started_data_v1(
        &spec,
        "cell-t11-002",
        Some("run-t11-002"),
        None,
        None,
        None,
        None,
        None,
        None,
        None,
    )
    .expect("event JSON must build");

    let placement = data.get("placement").expect("placement must be present");
    assert_eq!(placement["poolId"], "runner-pool-amd64");
    assert!(
        placement.get("kubernetesNamespace").is_none(),
        "absent field must not be emitted; got {placement:?}"
    );
    assert!(
        placement.get("queueName").is_none(),
        "absent field must not be emitted; got {placement:?}"
    );
}

#[test]
fn lifecycle_started_omits_placement_when_spec_has_none() {
    let spec = minimal_spec_with(None);
    let data = lifecycle_started_data_v1(
        &spec,
        "cell-t11-003",
        Some("run-t11-003"),
        None,
        None,
        None,
        None,
        None,
        None,
        None,
    )
    .expect("event JSON must build");

    assert!(
        data.get("placement").is_none(),
        "v1 backward-compat: no placement key when spec has none; got {data:?}"
    );
}

/// Round-trip cross-check: the projector's `/cells?poolId=X` filter reads
/// `placement.poolId` from a `CellStateSnapshot`. The snapshot is built from
/// `cell.lifecycle.v1.started` data, so the contract is: whatever the spec
/// declares for `poolId`, the event surfaces, and the snapshot can re-parse
/// it without information loss.
#[test]
fn lifecycle_started_placement_round_trips_through_state_projection() {
    use cellos_core::state_projection::CellStateSnapshot;

    let spec = minimal_spec_with(Some(PlacementSpec {
        pool_id: Some("runner-pool-amd64".into()),
        kubernetes_namespace: None,
        queue_name: Some("ci-high".into()),
    }));
    let data = lifecycle_started_data_v1(
        &spec,
        "cell-t11-004",
        Some("run-t11-004"),
        None,
        None,
        None,
        None,
        None,
        None,
        None,
    )
    .expect("event JSON must build");

    // The projector seeds a snapshot from this `data` object on the
    // started event. We assert the structural shape the projector reads.
    let placement_value = data
        .get("placement")
        .expect("started event must carry placement when spec did");

    let placement: PlacementSpec =
        serde_json::from_value(placement_value.clone()).expect("placement must deserialize");
    assert_eq!(placement.pool_id.as_deref(), Some("runner-pool-amd64"));
    assert!(placement.kubernetes_namespace.is_none());
    assert_eq!(placement.queue_name.as_deref(), Some("ci-high"));

    // And the same shape lands on a CellStateSnapshot built from the event
    // — sanity-check that the projector's snapshot struct exposes the
    // placement field that the `/cells?poolId=X` filter reads. Use
    // serde default round-trip so this test stays robust against future
    // additions of optional fields on the snapshot struct.
    let snapshot: CellStateSnapshot = serde_json::from_value(serde_json::json!({
        "specId": spec.id,
        "cellId": "cell-t11-004",
        "runId": "run-t11-004",
        "placement": placement_value,
        "lifecycleStage": "started",
        "identityStage": "unknown",
        "exportStage": "none",
        "currentState": "started",
        "exports": [],
        "processedEvents": 1
    }))
    .expect("snapshot must deserialize from JSON shape");
    assert_eq!(
        snapshot
            .placement
            .as_ref()
            .and_then(|p| p.pool_id.as_deref()),
        Some("runner-pool-amd64"),
        "projector snapshot must expose poolId so /cells?poolId=X works",
    );
}
