//! E4-06 — Contract-schema regression test for homeostasis events.
//!
//! Pins the post-E2-06 wire shape of
//! `dev.cellos.events.cell.authority.v1.homeostasis` so a future regression
//! cannot silently re-introduce the hardcoded `0` for
//! `exercisedEgressConnections`. The two assertions encode the contract:
//!
//!   1. `exercised_egress_connections = None` → JSON `null` AND
//!      `exercisedEgressReason: "telemetry_pending_L2-04"` is present (so
//!      consumers can distinguish "telemetry pending" from a real zero).
//!   2. `exercised_egress_connections = Some(N)` → JSON integer AND
//!      `exercisedEgressReason` is OMITTED entirely.
//!
//! The on-disk schema at
//! `contracts/schemas/cell-authority-homeostasis-v1.schema.json` is then
//! spot-checked: `exercisedEgressConnections` MUST permit the `null` type
//! (regression fence against re-tightening to `"type": "integer"` only),
//! and `exercisedEgressReason` MUST be a declared property with `type: string`.
//!
//! NB: the `jsonschema` crate is not a workspace dep, so we validate by
//! inspecting the schema document directly with `serde_json` rather than
//! pulling in a new dependency just for this test (per slot guidance).

use cellos_core::{
    homeostasis_signal_data_v1, AuthorityBundle, ExecutionCellSpec, HomeostasisSignal, Lifetime,
};
use serde_json::Value;

/// Schema text, embedded so the test fails loudly if the file is renamed or
/// removed. `include_str!` panics at compile time if the path is wrong.
const HOMEOSTASIS_SCHEMA_JSON: &str =
    include_str!("../../../contracts/schemas/cell-authority-homeostasis-v1.schema.json");

fn minimal_spec() -> ExecutionCellSpec {
    ExecutionCellSpec {
        id: "homeostasis-regression".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,
    }
}

fn signal_with(
    exercised: Option<u32>,
    reason: Option<&str>,
    declared: u32,
    efficiency: f64,
) -> HomeostasisSignal {
    HomeostasisSignal {
        spec_hash: format!("sha256:{}", "0".repeat(64)),
        declared_egress_rules: declared,
        exercised_egress_connections: exercised,
        exercised_egress_reason: reason.map(str::to_owned),
        declared_mount_paths: 0,
        accessed_mount_paths: 0,
        declared_secret_count: 0,
        authority_efficiency: efficiency,
        recommended_removals: Vec::new(),
    }
}

#[test]
fn exercised_egress_none_serializes_null_with_reason() {
    let spec = minimal_spec();
    let signal = signal_with(
        None,
        Some("telemetry_pending_L2-04"),
        // declared=2, exercised=None → efficiency conservative 1.0 (see types.rs)
        2,
        1.0,
    );

    let data = homeostasis_signal_data_v1(&spec, "cell-001", Some("run-1"), &signal)
        .expect("constructor must succeed");
    let obj = data.as_object().expect("homeostasis data is a JSON object");

    // exercisedEgressConnections is present AND its value is JSON null.
    let exercised = obj
        .get("exercisedEgressConnections")
        .expect("exercisedEgressConnections must be present even when telemetry is pending");
    assert!(
        exercised.is_null(),
        "expected `null` (telemetry pending), got {exercised:?} — \
         regression: someone reintroduced a hardcoded 0"
    );

    // Sibling diagnostic must accompany the null.
    let reason = obj
        .get("exercisedEgressReason")
        .expect("exercisedEgressReason must accompany a null exercisedEgressConnections");
    assert_eq!(
        reason.as_str(),
        Some("telemetry_pending_L2-04"),
        "exercisedEgressReason payload mismatch"
    );

    // Sanity: the rest of the contract still serializes as expected.
    assert_eq!(obj.get("cellId").and_then(Value::as_str), Some("cell-001"));
    assert_eq!(obj.get("runId").and_then(Value::as_str), Some("run-1"));
    assert_eq!(
        obj.get("declaredEgressRules").and_then(Value::as_u64),
        Some(2)
    );
}

#[test]
fn exercised_egress_some_serializes_integer_and_omits_reason() {
    let spec = minimal_spec();
    let signal = signal_with(Some(7), None, 10, 0.7);

    let data = homeostasis_signal_data_v1(&spec, "cell-002", None, &signal)
        .expect("constructor must succeed");
    let obj = data.as_object().expect("homeostasis data is a JSON object");

    // exercisedEgressConnections is a real number (no telemetry-pending null).
    let exercised = obj
        .get("exercisedEgressConnections")
        .expect("exercisedEgressConnections must be present");
    assert_eq!(
        exercised.as_u64(),
        Some(7),
        "expected JSON integer 7, got {exercised:?}"
    );

    // The diagnostic field MUST be entirely absent — its presence alongside a
    // real integer count would mislead consumers.
    assert!(
        !obj.contains_key("exercisedEgressReason"),
        "exercisedEgressReason must be omitted when the count is a real integer; \
         got {obj:?}"
    );

    // runId omitted (was None) — sanity check unrelated optional handling still works.
    assert!(!obj.contains_key("runId"));
}

#[test]
fn schema_permits_null_exercised_and_declares_reason() {
    // Spot-check the on-disk schema to fence against someone re-tightening
    // exercisedEgressConnections back to integer-only or dropping the
    // exercisedEgressReason property entirely. We do not pull in the
    // `jsonschema` crate just for this — the structural assertions below are
    // sufficient to catch a regression of the E2-06 shape.
    let schema: Value = serde_json::from_str(HOMEOSTASIS_SCHEMA_JSON)
        .expect("homeostasis schema must be valid JSON");
    let props = schema
        .get("properties")
        .and_then(Value::as_object)
        .expect("schema must have a `properties` object");

    // exercisedEgressConnections — MUST allow null.
    let exercised = props
        .get("exercisedEgressConnections")
        .expect("schema must define exercisedEgressConnections");
    let ty = exercised
        .get("type")
        .expect("exercisedEgressConnections must declare a `type`");
    let permits_null = match ty {
        Value::Array(items) => items.iter().any(|v| v.as_str() == Some("null")),
        Value::String(s) => s == "null",
        _ => false,
    };
    assert!(
        permits_null,
        "schema regression: exercisedEgressConnections.type does not permit `null` \
         (E2-06 made this nullable while telemetry is unwired); got {ty:?}"
    );
    let permits_integer = match ty {
        Value::Array(items) => items.iter().any(|v| v.as_str() == Some("integer")),
        Value::String(s) => s == "integer",
        _ => false,
    };
    assert!(
        permits_integer,
        "exercisedEgressConnections must still permit `integer` (real telemetry path); \
         got {ty:?}"
    );

    // exercisedEgressReason — MUST be a declared property of type string.
    let reason = props
        .get("exercisedEgressReason")
        .expect("schema must define exercisedEgressReason (E2-06 sibling diagnostic)");
    assert_eq!(
        reason.get("type").and_then(Value::as_str),
        Some("string"),
        "exercisedEgressReason must be a string; got {:?}",
        reason.get("type")
    );

    // Bonus fence: the field is OPTIONAL at the schema level (only populated
    // when the count is null). It must NOT appear in `required`.
    let required = schema
        .get("required")
        .and_then(Value::as_array)
        .map(|a| {
            a.iter()
                .filter_map(Value::as_str)
                .map(str::to_owned)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    assert!(
        !required.iter().any(|s| s == "exercisedEgressReason"),
        "exercisedEgressReason must be optional (only present when the count is null); \
         required = {required:?}"
    );
}

#[test]
fn null_exercised_payload_validates_against_schema_required_set() {
    // The `null` payload still satisfies the schema's `required` list — this
    // pins the contract that E2-06 only relaxed the *type* of
    // exercisedEgressConnections, not its presence.
    let spec = minimal_spec();
    let signal = signal_with(None, Some("telemetry_pending_L2-04"), 0, 1.0);
    let data = homeostasis_signal_data_v1(&spec, "cell-003", None, &signal)
        .expect("constructor must succeed");
    let obj = data.as_object().expect("homeostasis data is a JSON object");

    let schema: Value = serde_json::from_str(HOMEOSTASIS_SCHEMA_JSON).unwrap();
    let required = schema
        .get("required")
        .and_then(Value::as_array)
        .expect("schema must declare a required field list");
    for field in required {
        let key = field.as_str().expect("required entries are strings");
        assert!(
            obj.contains_key(key),
            "homeostasis payload missing required field `{key}` — \
             constructor regressed against the schema's `required` list"
        );
    }
}
