//! CTL-001 / MED-CTL-001-A regression — snapshot envelopes for
//! `GET /v1/formations` and `GET /v1/cells`.
//!
//! Wave 1 (`/tmp/e2e-report.md`, bug CTL-001): `cellctl get formations`
//! failed against cellos-server 0.5.1 with:
//!
//!     error: api: decode json: data did not match any variant of untagged enum Wire
//!
//! Root cause: `cellos_server::routes::formations::FormationsSnapshot`
//! emits `{"formations": [...], "cursor": <u64>}` per ADR-0015 §D2, but
//! the previous `cellos_ctl::model::List<T>` only understood the bare
//! array and the generic `{items: [...]}` shapes.
//!
//! Wave 2 (CTL-001 fix): `List<T>` grew a `Keyed` variant aliasing
//! `formations` and `cells` onto a single `items` field. That decoded
//! the live shape — but accepted a mixed `{formations: [...], cells:
//! [...]}` envelope by silently picking one alias and dropping the
//! other.
//!
//! Wave 3 (MED-CTL-001-A — this file): the generic `List<T>` is gone.
//! Each endpoint gets a typed snapshot (`FormationsSnapshot`,
//! `CellsSnapshot`). The deserializer accepts every legacy shape
//! singly, and refuses any envelope where more than one recognised list
//! key is present at once.

use cellos_ctl::model::{CellsSnapshot, FormationsSnapshot};

/// The shape `cellos-server` actually emits today on `GET /v1/formations`.
/// Two formations + a non-zero cursor — what an operator sees the first
/// time they run `cellctl get formations` after `cellctl apply`.
#[test]
fn formations_snapshot_deserializes_correctly() {
    let body = serde_json::json!({
        "formations": [
            {
                "id": "00000000-0000-0000-0000-000000000001",
                "name": "alpha",
                "status": "RUNNING",
            },
            {
                "id": "00000000-0000-0000-0000-000000000002",
                "name": "beta",
                "status": "PENDING",
            },
        ],
        "cursor": 42,
    });

    let snap: FormationsSnapshot =
        serde_json::from_value(body).expect("snapshot envelope must deserialise");

    assert_eq!(
        snap.formations.len(),
        2,
        "both formations must be decoded — nothing silently dropped",
    );
    assert_eq!(snap.formations[0].name, "alpha");
    assert_eq!(
        snap.formations[0].state, "RUNNING",
        "status alias must populate state"
    );
    assert_eq!(snap.formations[1].name, "beta");
    assert_eq!(snap.formations[1].state, "PENDING");
    assert_eq!(
        snap.cursor,
        Some(42),
        "cursor must be preserved for /ws/events?since=<cursor>",
    );
}

/// Edge case: empty stream. Server still returns the envelope, just with
/// `formations: []` and `cursor: 0`. cellctl must render an empty list,
/// not blow up on the envelope shape.
#[test]
fn formations_snapshot_handles_empty_snapshot() {
    let body = serde_json::json!({
        "formations": [],
        "cursor": 0,
    });

    let snap: FormationsSnapshot =
        serde_json::from_value(body).expect("empty snapshot must deserialise");

    assert!(
        snap.formations.is_empty(),
        "empty formations must decode to empty Vec"
    );
    assert_eq!(snap.cursor, Some(0));
}

/// Forward-compat / canonical: `GET /v1/cells` snapshot envelope. The
/// per-endpoint type means cells responses can never accidentally land
/// in a "formations" decode path (which is the failure mode the wave-3
/// fix closes).
#[test]
fn cells_snapshot_deserializes_correctly() {
    let body = serde_json::json!({
        "cells": [
            { "id": "c-1", "name": "coord", "state": "RUNNING" },
            { "id": "c-2", "name": "worker", "state": "PENDING" },
        ],
        "cursor": 7,
    });

    let snap: CellsSnapshot =
        serde_json::from_value(body).expect("cells snapshot envelope must deserialise");

    assert_eq!(snap.cells.len(), 2);
    assert_eq!(snap.cells[0].name, "coord");
    assert_eq!(snap.cells[0].state, "RUNNING");
    assert_eq!(snap.cells[1].name, "worker");
    assert_eq!(snap.cursor, Some(7));
}

/// Back-compat: bare-array responses (early endpoints, integration tests)
/// must still deserialise. `cursor` is `None` in this case. Pinned for
/// both snapshot types.
#[test]
fn snapshots_still_deserialize_bare_arrays() {
    let formations_body = serde_json::json!([
        { "id": "f-1", "name": "alpha", "status": "RUNNING" },
    ]);
    let f_snap: FormationsSnapshot = serde_json::from_value(formations_body)
        .expect("bare formations array must still deserialise");
    assert_eq!(f_snap.formations.len(), 1);
    assert_eq!(f_snap.formations[0].name, "alpha");
    assert!(f_snap.cursor.is_none(), "bare arrays have no cursor");

    let cells_body = serde_json::json!([
        { "id": "c-1", "name": "coord", "state": "RUNNING" },
    ]);
    let c_snap: CellsSnapshot =
        serde_json::from_value(cells_body).expect("bare cells array must still deserialise");
    assert_eq!(c_snap.cells.len(), 1);
    assert!(c_snap.cursor.is_none());
}

/// Back-compat: generic `{items: [...]}` wrapper. No production route
/// emits this today, but the previous deserialiser accepted it and
/// removing that support would be a needless wire break.
#[test]
fn snapshots_still_deserialize_generic_items_wrapper() {
    let body = serde_json::json!({
        "items": [
            { "id": "f-1", "name": "alpha", "status": "RUNNING" },
        ],
    });
    let snap: FormationsSnapshot = serde_json::from_value(body)
        .expect("generic items wrapper must still deserialise into FormationsSnapshot");
    assert_eq!(snap.formations.len(), 1);
    assert_eq!(snap.formations[0].name, "alpha");
    assert!(snap.cursor.is_none());

    let body = serde_json::json!({
        "items": [
            { "id": "c-1", "name": "coord", "state": "RUNNING" },
        ],
    });
    let snap: CellsSnapshot = serde_json::from_value(body)
        .expect("generic items wrapper must still deserialise into CellsSnapshot");
    assert_eq!(snap.cells.len(), 1);
    assert!(snap.cursor.is_none());
}

/// MED-CTL-001-A: the headline regression. An envelope that contains
/// BOTH `formations` and `cells` (or any two recognised list keys) is
/// ambiguous. Under the old `List<T>` deserializer, serde's alias
/// resolution would silently pick one and drop the other; the call
/// site would see "an empty list" and the operator would never know.
///
/// Wave-3 contract: any such envelope MUST be rejected at decode time
/// so contract drift surfaces loudly instead of corrupting the view.
#[test]
fn mixed_envelope_with_both_keys_is_rejected() {
    let body = serde_json::json!({
        "formations": [
            { "id": "f-1", "name": "alpha", "status": "RUNNING" },
        ],
        "cells": [
            { "id": "c-1", "name": "coord", "state": "RUNNING" },
        ],
        "cursor": 99,
    });

    let result: Result<FormationsSnapshot, _> = serde_json::from_value(body.clone());
    assert!(
        result.is_err(),
        "FormationsSnapshot must REJECT an envelope that also carries `cells` — \
         silently dropping the other half is the MED-CTL-001-A bug",
    );

    let result: Result<CellsSnapshot, _> = serde_json::from_value(body);
    assert!(
        result.is_err(),
        "CellsSnapshot must REJECT an envelope that also carries `formations` — \
         the rejection is symmetric across both snapshot types",
    );
}

/// Symmetric to above: `formations` plus the legacy generic `items` is
/// also ambiguous. The fix can't only check one pair — it has to refuse
/// every combination, since any combination would force serde to pick
/// one half and drop the other.
#[test]
fn mixed_envelope_with_canonical_and_items_is_rejected() {
    let body = serde_json::json!({
        "formations": [
            { "id": "f-1", "name": "alpha", "status": "RUNNING" },
        ],
        "items": [
            { "id": "f-2", "name": "beta", "status": "PENDING" },
        ],
    });

    let result: Result<FormationsSnapshot, _> = serde_json::from_value(body);
    assert!(
        result.is_err(),
        "FormationsSnapshot must REJECT canonical+items mix — both are list \
         keys and choosing one silently drops the other",
    );
}
