//! Permanent Lifeloop conformance fixture suite — issue #10.
//!
//! This is the post-extraction gate that proves Lifeloop adapters and
//! clients continue to satisfy the public contract independent of the
//! pre-extraction CCD reference corpus. Fixtures live as JSON under
//! `tests/conformance/` so they can be read by Lifeloop AND by an
//! external client implementation in another language without taking
//! a Rust-only dependency.
//!
//! # Layout
//!
//! Every JSON file under `tests/conformance/<category>/<name>.json`
//! is a self-describing fixture envelope:
//!
//! ```json
//! {
//!   "kind": "<contract type>",
//!   "expect": "valid" | "invalid",
//!   "expect_error":   "<optional substring of the validation error>",
//!   "description":    "<one-line human note>",
//!   "data":           { ... actual contract object ... }
//! }
//! ```
//!
//! The harness is data-driven: it discovers every `*.json` under
//! `tests/conformance/` at test time, deserializes by `kind`, and
//! asserts the `expect` outcome. Add a fixture by adding a file; no
//! Rust changes required for normal coverage growth.
//!
//! # Determinism
//!
//! No clock, no network, no random IDs. Stable strings throughout.
//! `at_epoch_s`, `observed_at_epoch_s`, etc. are pinned literals.

use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

use lifeloop::router::{
    LifeloopFailureMapper, TransportError, classes_for_negotiation_outcome,
    failure_class_for_transport, retry_class_for,
};
use lifeloop::telemetry::PressureObservation;
use lifeloop::{
    AdapterManifest, CallbackRequest, CallbackResponse, FailureClass, FrameContext,
    LifecycleEventKind, LifecycleReceipt, NegotiationOutcome, PayloadEnvelope, PlacementClass,
    RetryClass,
};
use serde::Deserialize;
use serde_json::Value;

// ---------------------------------------------------------------------------
// Fixture envelope
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
struct Fixture {
    kind: String,
    expect: Expect,
    #[serde(default)]
    expect_error: Option<String>,
    #[serde(default)]
    description: Option<String>,
    data: Value,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Expect {
    Valid,
    Invalid,
}

// ---------------------------------------------------------------------------
// Discovery
// ---------------------------------------------------------------------------

fn conformance_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("conformance")
}

/// Recursively gather every `*.json` file under `root`, sorted for
/// reproducible test order.
fn collect_fixtures(root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let entries = match fs::read_dir(&dir) {
            Ok(entries) => entries,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.extension().and_then(|s| s.to_str()) == Some("json") {
                out.push(path);
            }
        }
    }
    out.sort();
    out
}

fn load(path: &Path) -> Fixture {
    let bytes = fs::read(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
    serde_json::from_slice::<Fixture>(&bytes)
        .unwrap_or_else(|e| panic!("parse fixture envelope {path:?}: {e}"))
}

// ---------------------------------------------------------------------------
// Per-kind dispatch
// ---------------------------------------------------------------------------

/// Run a fixture: deserialize `data` as the typed contract object,
/// invoke `validate()`, and assert `expect` matches the outcome.
///
/// Returns the textual outcome category for use in coverage assertions.
fn run_one(path: &Path, fixture: &Fixture) -> &'static str {
    let kind = fixture.kind.as_str();
    let result: Result<(), String> = match kind {
        "callback_request" => parse_and_validate::<CallbackRequest>(&fixture.data),
        "callback_response" => parse_and_validate::<CallbackResponse>(&fixture.data),
        "lifecycle_receipt" => parse_and_validate::<LifecycleReceipt>(&fixture.data),
        "payload_envelope" => parse_and_validate::<PayloadEnvelope>(&fixture.data),
        "frame_context" => parse_and_validate::<FrameContext>(&fixture.data),
        "adapter_manifest" => parse_and_validate::<AdapterManifest>(&fixture.data),
        "pressure_observation" => parse_only::<PressureObservation>(&fixture.data),
        "client_class" => run_client_class(&fixture.data),
        "capability_negotiation" => run_capability_negotiation(&fixture.data),
        "failure_mapping" => run_failure_mapping(&fixture.data),
        other => panic!("{path:?}: unknown fixture kind `{other}`"),
    };

    match (result, fixture.expect) {
        (Ok(()), Expect::Valid) => "valid_ok",
        (Err(msg), Expect::Invalid) => {
            if let Some(needle) = &fixture.expect_error {
                assert!(
                    msg.contains(needle),
                    "{path:?}: expected error containing `{needle}`, got `{msg}`"
                );
            }
            "invalid_ok"
        }
        (Ok(()), Expect::Invalid) => {
            panic!("{path:?}: fixture expected to be invalid but parsed and validated cleanly")
        }
        (Err(msg), Expect::Valid) => {
            panic!("{path:?}: fixture expected to be valid but failed: {msg}")
        }
    }
}

/// Generic deserialize-then-validate path for types with a `validate()`
/// method.
trait ContractObject: serde::de::DeserializeOwned {
    fn run_validate(&self) -> Result<(), String>;
}

impl ContractObject for CallbackRequest {
    fn run_validate(&self) -> Result<(), String> {
        self.validate().map_err(|e| e.to_string())
    }
}
impl ContractObject for CallbackResponse {
    fn run_validate(&self) -> Result<(), String> {
        self.validate().map_err(|e| e.to_string())
    }
}
impl ContractObject for LifecycleReceipt {
    fn run_validate(&self) -> Result<(), String> {
        self.validate().map_err(|e| e.to_string())
    }
}
impl ContractObject for PayloadEnvelope {
    fn run_validate(&self) -> Result<(), String> {
        self.validate().map_err(|e| e.to_string())
    }
}
impl ContractObject for FrameContext {
    fn run_validate(&self) -> Result<(), String> {
        self.validate().map_err(|e| e.to_string())
    }
}
impl ContractObject for AdapterManifest {
    fn run_validate(&self) -> Result<(), String> {
        self.validate().map_err(|e| e.to_string())
    }
}

fn parse_and_validate<T: ContractObject>(data: &Value) -> Result<(), String> {
    let typed: T = serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
    typed.run_validate()
}

/// Types with no `validate()` (e.g. PressureObservation) — parse-only
/// roundtrip + reserialize confirms wire shape stability.
fn parse_only<T: serde::de::DeserializeOwned + serde::Serialize>(
    data: &Value,
) -> Result<(), String> {
    let typed: T = serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
    let _back = serde_json::to_value(&typed).map_err(|e| format!("reserialize: {e}"))?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Custom fixture kinds
// ---------------------------------------------------------------------------

/// Client-class fixtures describe how a particular client shape uses
/// the contract. They carry a stable `client_id`, the `payload_kind`
/// strings that client emits, and a worked example payload. The
/// harness verifies the example payload is a valid `PayloadEnvelope`.
#[derive(Debug, Deserialize)]
struct ClientClassFixture {
    client_id: String,
    description: String,
    payload_kinds: Vec<String>,
    example_payload: Value,
}

fn run_client_class(data: &Value) -> Result<(), String> {
    let cls: ClientClassFixture =
        serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
    if cls.client_id.is_empty() {
        return Err("client_id must not be empty".into());
    }
    if cls.description.is_empty() {
        return Err("description must not be empty".into());
    }
    if cls.payload_kinds.is_empty() {
        return Err("payload_kinds must list at least one kind".into());
    }
    let payload: PayloadEnvelope = serde_json::from_value(cls.example_payload.clone())
        .map_err(|e| format!("example_payload deserialize: {e}"))?;
    payload.validate().map_err(|e| e.to_string())?;
    if payload.client_id != cls.client_id {
        return Err(format!(
            "example_payload.client_id `{}` must match fixture client_id `{}`",
            payload.client_id, cls.client_id
        ));
    }
    if !cls.payload_kinds.contains(&payload.payload_kind) {
        return Err(format!(
            "example_payload.payload_kind `{}` not in declared payload_kinds {:?}",
            payload.payload_kind, cls.payload_kinds
        ));
    }
    Ok(())
}

/// Capability-negotiation fixture: pins the (outcome, explicit) →
/// (failure_class, retry_class) projection from
/// `classes_for_negotiation_outcome`.
#[derive(Debug, Deserialize)]
struct CapabilityNegotiationFixture {
    outcome: NegotiationOutcome,
    #[serde(default)]
    explicit_failure_class: Option<FailureClass>,
    expected: Option<FailureRetryPair>,
}

#[derive(Debug, Deserialize)]
struct FailureRetryPair {
    failure_class: FailureClass,
    retry_class: RetryClass,
}

fn run_capability_negotiation(data: &Value) -> Result<(), String> {
    let fx: CapabilityNegotiationFixture =
        serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
    let actual = classes_for_negotiation_outcome(fx.outcome, fx.explicit_failure_class);
    match (&actual, &fx.expected) {
        (None, None) => Ok(()),
        (Some((af, ar)), Some(exp)) => {
            if *af == exp.failure_class && *ar == exp.retry_class {
                Ok(())
            } else {
                Err(format!(
                    "negotiation projection mismatch: got ({af:?}, {ar:?}), expected ({:?}, {:?})",
                    exp.failure_class, exp.retry_class
                ))
            }
        }
        (None, Some(exp)) => Err(format!(
            "expected ({:?}, {:?}) but classes_for_negotiation_outcome returned None",
            exp.failure_class, exp.retry_class
        )),
        (Some(pair), None) => Err(format!(
            "expected None but classes_for_negotiation_outcome returned {pair:?}"
        )),
    }
}

/// Failure-mapping fixture: pins the FailureClass → RetryClass default
/// projection, and optionally a TransportError example.
#[derive(Debug, Deserialize)]
struct FailureMappingFixture {
    failure_class: FailureClass,
    expected_retry_class: RetryClass,
    #[serde(default)]
    transport_example: Option<TransportExample>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
enum TransportExample {
    Io(String),
    Timeout,
    Internal(String),
}

fn run_failure_mapping(data: &Value) -> Result<(), String> {
    let fx: FailureMappingFixture =
        serde_json::from_value(data.clone()).map_err(|e| format!("deserialize: {e}"))?;
    let actual_retry = retry_class_for(fx.failure_class);
    if actual_retry != fx.expected_retry_class {
        return Err(format!(
            "default retry for {:?}: got {actual_retry:?}, expected {:?}",
            fx.failure_class, fx.expected_retry_class
        ));
    }
    // Cross-check via FailureClass::default_retry — the spec table.
    if fx.failure_class.default_retry() != fx.expected_retry_class {
        return Err(format!(
            "FailureClass::{:?}::default_retry diverges from expected_retry_class",
            fx.failure_class
        ));
    }
    // Transport example, when present, must funnel through the mapper
    // and produce the declared (failure_class, retry_class) pair.
    if let Some(example) = fx.transport_example {
        let te = match example {
            TransportExample::Io(d) => TransportError::Io(d),
            TransportExample::Timeout => TransportError::Timeout,
            TransportExample::Internal(d) => TransportError::Internal(d),
        };
        let mapper = LifeloopFailureMapper::new();
        let (fc, rc) = mapper.map_transport_error(&te);
        let direct_fc = failure_class_for_transport(&te);
        if direct_fc != fc {
            return Err(format!("free-fn vs mapper drift: {direct_fc:?} vs {fc:?}"));
        }
        if fc != fx.failure_class {
            return Err(format!(
                "transport_example maps to {fc:?} but fixture declares {:?}",
                fx.failure_class
            ));
        }
        if rc != fx.expected_retry_class {
            return Err(format!(
                "transport_example retry {rc:?} != expected {:?}",
                fx.expected_retry_class
            ));
        }
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Top-level test: walks every fixture file
// ---------------------------------------------------------------------------

#[test]
fn conformance_suite_walks_every_fixture() {
    let root = conformance_root();
    let files = collect_fixtures(&root);
    assert!(
        !files.is_empty(),
        "no conformance fixtures found under {root:?}"
    );

    let mut counts = std::collections::BTreeMap::<String, usize>::new();
    for path in &files {
        let fixture = load(path);
        let outcome = run_one(path, &fixture);
        let bucket = format!("{}.{}", fixture.kind, outcome);
        *counts.entry(bucket).or_default() += 1;
    }

    // Surface the per-bucket counts in test output so a regression that
    // silently drops fixtures (e.g. accidental .gitignore) is visible.
    eprintln!("conformance fixture counts: {counts:#?}");
    eprintln!("conformance fixture total: {}", files.len());
}

// ---------------------------------------------------------------------------
// Coverage gates: every public enum variant must be represented
// ---------------------------------------------------------------------------

fn all_event_fixtures() -> Vec<Fixture> {
    collect_fixtures(&conformance_root().join("events"))
        .iter()
        .map(|p| load(p))
        .collect()
}

fn all_placement_fixtures() -> Vec<Fixture> {
    collect_fixtures(&conformance_root().join("placements"))
        .iter()
        .map(|p| load(p))
        .collect()
}

fn all_receipt_fixtures() -> Vec<Fixture> {
    collect_fixtures(&conformance_root().join("receipts"))
        .iter()
        .map(|p| load(p))
        .collect()
}

fn all_failure_fixtures() -> Vec<Fixture> {
    collect_fixtures(&conformance_root().join("failures"))
        .iter()
        .map(|p| load(p))
        .collect()
}

fn all_manifest_fixtures() -> Vec<Fixture> {
    collect_fixtures(&conformance_root().join("manifests"))
        .iter()
        .map(|p| load(p))
        .collect()
}

fn all_client_fixtures() -> Vec<Fixture> {
    collect_fixtures(&conformance_root().join("clients"))
        .iter()
        .map(|p| load(p))
        .collect()
}

fn all_renewal_fixtures() -> Vec<Fixture> {
    collect_fixtures(&conformance_root().join("renewal"))
        .iter()
        .map(|p| load(p))
        .collect()
}

#[test]
fn every_lifecycle_event_kind_has_an_event_fixture() {
    let mut covered = BTreeSet::new();
    for fx in all_event_fixtures() {
        assert_eq!(
            fx.kind, "callback_request",
            "events/ fixtures must be callback_request kind"
        );
        // Read the event field off the data blob.
        let event = fx
            .data
            .get("event")
            .and_then(|v| v.as_str())
            .unwrap_or_else(|| panic!("event fixture missing data.event: {:?}", fx.description));
        covered.insert(event.to_string());
    }
    let expected: BTreeSet<String> = LifecycleEventKind::ALL
        .iter()
        .map(|kind| {
            serde_json::to_value(kind)
                .unwrap()
                .as_str()
                .unwrap()
                .to_string()
        })
        .collect();
    let missing: Vec<_> = expected.difference(&covered).collect();
    assert!(
        missing.is_empty(),
        "events/ fixtures missing coverage for lifecycle event kinds: {missing:?}"
    );
}

#[test]
fn every_placement_class_has_a_placement_fixture() {
    let mut covered = BTreeSet::new();
    for fx in all_placement_fixtures() {
        assert_eq!(
            fx.kind, "payload_envelope",
            "placements/ fixtures must be payload_envelope kind"
        );
        // The first acceptable placement is what the fixture demonstrates.
        let placement = fx
            .data
            .get("acceptable_placements")
            .and_then(|v| v.as_array())
            .and_then(|arr| arr.first())
            .and_then(|p| p.get("placement"))
            .and_then(|v| v.as_str())
            .unwrap_or_else(|| {
                panic!(
                    "placement fixture missing acceptable_placements[0].placement: {:?}",
                    fx.description
                )
            });
        covered.insert(placement.to_string());
    }
    let expected: BTreeSet<String> = PlacementClass::ALL
        .iter()
        .map(|c| {
            serde_json::to_value(c)
                .unwrap()
                .as_str()
                .unwrap()
                .to_string()
        })
        .collect();
    let missing: Vec<_> = expected.difference(&covered).collect();
    assert!(
        missing.is_empty(),
        "placements/ fixtures missing coverage for placement classes: {missing:?}"
    );
}

#[test]
fn receipt_fixtures_cover_every_receipt_status_and_special_case() {
    // The contract requires applied/delivered, degraded, skipped, blocked,
    // failed, idempotency, conflict, and receipt-gap shapes. The blocked
    // surface in this contract is `Failed` + `OperatorRequired`; the
    // conformance fixture corpus carries it as `blocked.json`.
    let mut required: BTreeSet<&'static str> = [
        "delivered",
        "degraded",
        "skipped",
        "blocked",
        "failed",
        "idempotency",
        "conflict",
        "receipt_gap",
    ]
    .into_iter()
    .collect();
    for fx in all_receipt_fixtures() {
        if let Some(desc) = &fx.description {
            for tag in &required.clone() {
                if desc.contains(tag) {
                    required.remove(tag);
                }
            }
        }
    }
    assert!(
        required.is_empty(),
        "receipts/ corpus missing fixtures for: {required:?}"
    );
}

#[test]
fn failure_mapping_fixtures_cover_every_failure_class() {
    let mut covered = BTreeSet::new();
    for fx in all_failure_fixtures() {
        if fx.kind != "failure_mapping" {
            continue;
        }
        let class = fx
            .data
            .get("failure_class")
            .and_then(|v| v.as_str())
            .unwrap_or_else(|| panic!("failure_mapping fixture missing failure_class"));
        covered.insert(class.to_string());
    }
    let expected: BTreeSet<String> = FailureClass::ALL
        .iter()
        .map(|c| {
            serde_json::to_value(c)
                .unwrap()
                .as_str()
                .unwrap()
                .to_string()
        })
        .collect();
    let missing: Vec<_> = expected.difference(&covered).collect();
    assert!(
        missing.is_empty(),
        "failures/ corpus missing failure_mapping fixtures for: {missing:?}"
    );
}

#[test]
fn renewal_fixtures_cover_reset_prepare_and_continuation_delivery() {
    let mut payload_kinds = BTreeSet::new();
    for fx in all_renewal_fixtures() {
        assert_eq!(
            fx.kind, "lifecycle_receipt",
            "renewal/ fixtures must be lifecycle_receipt kind"
        );
        let receipts = fx
            .data
            .get("payload_receipts")
            .and_then(|v| v.as_array())
            .unwrap_or_else(|| panic!("renewal fixture missing payload_receipts"));
        for receipt in receipts {
            if let Some(kind) = receipt.get("payload_kind").and_then(|v| v.as_str()) {
                payload_kinds.insert(kind.to_string());
            }
        }
    }

    for required in [
        "lifeloop.renewal.reset_prepare",
        "lifeloop.renewal.continuation",
    ] {
        assert!(
            payload_kinds.contains(required),
            "renewal/ corpus missing payload kind `{required}`; saw {payload_kinds:?}"
        );
    }
}

#[test]
fn renewal_failure_mapping_fixtures_cover_issue_3_cases() {
    let mut descriptions = BTreeSet::new();
    for fx in all_failure_fixtures() {
        if let Some(desc) = fx.description {
            descriptions.insert(desc);
        }
    }
    for required in [
        "renewal reset unsupported",
        "renewal continuation delivery lost",
        "renewal capability went stale",
        "renewal path is manual-only",
    ] {
        assert!(
            descriptions.iter().any(|desc| desc.contains(required)),
            "failures/ corpus missing renewal case `{required}`"
        );
    }
}

#[test]
fn manifest_fixtures_cover_codex_and_claude_before_v1_freeze() {
    let mut ids = BTreeSet::new();
    for fx in all_manifest_fixtures() {
        if fx.kind != "adapter_manifest" {
            continue;
        }
        let id = fx
            .data
            .get("adapter_id")
            .and_then(|v| v.as_str())
            .unwrap_or_else(|| panic!("manifest fixture missing adapter_id"));
        ids.insert(id.to_string());
    }
    assert!(
        ids.contains("codex"),
        "manifests/ corpus missing codex (required for v1 freeze gate)"
    );
    assert!(
        ids.contains("claude"),
        "manifests/ corpus missing claude (required for v1 freeze gate)"
    );
}

#[test]
fn at_least_two_distinct_client_class_shapes_exist() {
    let mut clients = BTreeSet::new();
    for fx in all_client_fixtures() {
        assert_eq!(
            fx.kind, "client_class",
            "clients/ fixtures must be client_class"
        );
        let id = fx
            .data
            .get("client_id")
            .and_then(|v| v.as_str())
            .unwrap_or_else(|| panic!("client_class fixture missing client_id"));
        clients.insert(id.to_string());
    }
    assert!(
        clients.len() >= 2,
        "v1 freeze gate requires at least two client-class shapes; found: {clients:?}"
    );
}

// ---------------------------------------------------------------------------
// Spec-derived gate: the failure-class default retry table mirrored
// across every FailureClass variant via LifeloopFailureMapper. This
// runs even if a `failures/` fixture is dropped, so the table cannot
// silently drift away from the spec.
// ---------------------------------------------------------------------------

#[test]
fn failure_class_default_retry_table_is_complete_via_mapper() {
    // Each FailureClass variant must produce the same RetryClass
    // through both `FailureClass::default_retry()` and the
    // free-function `retry_class_for`, which the LifeloopFailureMapper
    // delegates to. This keeps the runtime mapper and default retry table
    // tied together even if a fixture is removed.
    for fc in FailureClass::ALL {
        let direct = fc.default_retry();
        let via_helper = retry_class_for(*fc);
        assert_eq!(
            direct, via_helper,
            "FailureClass::{fc:?}: default_retry diverges from retry_class_for"
        );
    }
}
