//! Wire-contract tests. Each assertion pins the literal JSON shape of a
//! public Lifeloop type. Discipline lives in
//! `docs/playbooks/wire-contract-review.md`; the normative target is
//! `docs/specs/lifecycle-contract/body.md`.
//!
//! Pin literal strings, not the type's own constants — refactoring the type
//! must not silently rewrite the assertion.

use lifeloop::{
    AcceptablePlacement, AdapterManifest, AdapterRole, CallbackRequest, CallbackResponse,
    CapabilityDegradation, DispatchEnvelope, FailureClass, FrameClass, FrameContext,
    IntegrationMode, LifecycleEventKind, LifecycleReceipt, ManifestContextPressure,
    ManifestLifecycleEventSupport, ManifestPlacementClass, ManifestPlacementSupport,
    ManifestReceipts, ManifestRenewal, ManifestRenewalContinuation, ManifestRenewalReset,
    ManifestSessionIdentity, NegotiationOutcome, PayloadEnvelope, PayloadReceipt, PayloadRef,
    PlacementClass, PlacementOutcome, ReceiptStatus, RenewalAutomationState,
    RenewalAutomationStatus, RequirementLevel, RetryClass, SCHEMA_VERSION, SupportState,
    ValidationError, Warning,
};
use serde_json::json;
use std::collections::BTreeMap;

#[test]
fn schema_version_is_pinned() {
    assert_eq!(SCHEMA_VERSION, "lifeloop.v0.2");
}

// ============================================================================
// Exhaustive enum serialization (issue #2 acceptance)
// ============================================================================

/// Asserts each `(variant, wire)` pair roundtrips through serde, AND that the
/// case table covers every variant in `T::ALL`. The coverage check is what
/// catches "added a variant to the enum and to ::ALL but forgot the test."
fn roundtrip_enum<T>(cases: &[(T, &str)], all: &[T])
where
    T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug + PartialEq + Copy,
{
    assert_eq!(
        cases.len(),
        all.len(),
        "case table size {} but ::ALL has {} variants — adding a variant requires a roundtrip case",
        cases.len(),
        all.len()
    );
    for variant in all {
        assert!(
            cases.iter().any(|(c, _)| c == variant),
            "variant {variant:?} appears in ::ALL but not in roundtrip cases",
        );
    }
    for (variant, wire) in cases {
        let serialized = serde_json::to_value(variant).unwrap();
        assert_eq!(
            serialized,
            json!(wire),
            "serialize wire mismatch for {wire}"
        );
        let parsed: T = serde_json::from_value(json!(wire)).unwrap();
        assert_eq!(&parsed, variant, "deserialize wire mismatch for {wire}");
    }
}

#[test]
fn integration_mode_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (IntegrationMode::ManualSkill, "manual_skill"),
            (IntegrationMode::LauncherWrapper, "launcher_wrapper"),
            (IntegrationMode::NativeHook, "native_hook"),
            (IntegrationMode::ReferenceAdapter, "reference_adapter"),
            (IntegrationMode::TelemetryOnly, "telemetry_only"),
        ],
        IntegrationMode::ALL,
    );
    assert_eq!(IntegrationMode::ALL.len(), 5);
}

#[test]
fn support_state_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (SupportState::Native, "native"),
            (SupportState::Synthesized, "synthesized"),
            (SupportState::Manual, "manual"),
            (SupportState::Partial, "partial"),
            (SupportState::Unavailable, "unavailable"),
        ],
        SupportState::ALL,
    );
    assert_eq!(SupportState::ALL.len(), 5);
}

#[test]
fn adapter_role_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (AdapterRole::PrimaryWorker, "primary_worker"),
            (AdapterRole::Worker, "worker"),
            (AdapterRole::Supervisor, "supervisor"),
            (AdapterRole::Observer, "observer"),
        ],
        AdapterRole::ALL,
    );
    assert_eq!(AdapterRole::ALL.len(), 4);
}

#[test]
fn lifecycle_event_kind_pins_dotted_names_and_roundtrips() {
    roundtrip_enum(
        &[
            (LifecycleEventKind::SessionStarting, "session.starting"),
            (LifecycleEventKind::SessionStarted, "session.started"),
            (LifecycleEventKind::FrameOpening, "frame.opening"),
            (LifecycleEventKind::FrameOpened, "frame.opened"),
            (
                LifecycleEventKind::ContextPressureObserved,
                "context.pressure_observed",
            ),
            (LifecycleEventKind::ContextCompacted, "context.compacted"),
            (LifecycleEventKind::FrameEnding, "frame.ending"),
            (LifecycleEventKind::FrameEnded, "frame.ended"),
            (LifecycleEventKind::SessionEnding, "session.ending"),
            (LifecycleEventKind::SessionEnded, "session.ended"),
            (LifecycleEventKind::SupervisorTick, "supervisor.tick"),
            (
                LifecycleEventKind::CapabilityDegraded,
                "capability.degraded",
            ),
            (LifecycleEventKind::ReceiptEmitted, "receipt.emitted"),
            (
                LifecycleEventKind::ReceiptGapDetected,
                "receipt.gap_detected",
            ),
        ],
        LifecycleEventKind::ALL,
    );
    assert_eq!(LifecycleEventKind::ALL.len(), 14);
}

#[test]
fn lifecycle_event_kinds_helper_matches_canonical_table() {
    assert_eq!(lifeloop::lifecycle_event_kinds(), LifecycleEventKind::ALL);
}

#[test]
fn receipt_status_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (ReceiptStatus::Observed, "observed"),
            (ReceiptStatus::Delivered, "delivered"),
            (ReceiptStatus::Skipped, "skipped"),
            (ReceiptStatus::Degraded, "degraded"),
            (ReceiptStatus::Failed, "failed"),
        ],
        ReceiptStatus::ALL,
    );
    assert_eq!(ReceiptStatus::ALL.len(), 5);
}

#[test]
fn failure_class_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (FailureClass::AdapterUnavailable, "adapter_unavailable"),
            (
                FailureClass::CapabilityUnsupported,
                "capability_unsupported",
            ),
            (FailureClass::CapabilityDegraded, "capability_degraded"),
            (FailureClass::PlacementUnavailable, "placement_unavailable"),
            (FailureClass::PayloadTooLarge, "payload_too_large"),
            (FailureClass::PayloadRejected, "payload_rejected"),
            (FailureClass::IdentityUnavailable, "identity_unavailable"),
            (FailureClass::TransportError, "transport_error"),
            (FailureClass::Timeout, "timeout"),
            (FailureClass::OperatorRequired, "operator_required"),
            (FailureClass::StateConflict, "state_conflict"),
            (FailureClass::InvalidRequest, "invalid_request"),
            (FailureClass::InternalError, "internal_error"),
        ],
        FailureClass::ALL,
    );
    assert_eq!(FailureClass::ALL.len(), 13);
}

#[test]
fn retry_class_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (RetryClass::SafeRetry, "safe_retry"),
            (RetryClass::RetryAfterReread, "retry_after_reread"),
            (RetryClass::RetryAfterReconfigure, "retry_after_reconfigure"),
            (RetryClass::RetryAfterOperator, "retry_after_operator"),
            (RetryClass::DoNotRetry, "do_not_retry"),
        ],
        RetryClass::ALL,
    );
    assert_eq!(RetryClass::ALL.len(), 5);
}

#[test]
fn placement_class_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (
                PlacementClass::DeveloperEquivalentFrame,
                "developer_equivalent_frame",
            ),
            (PlacementClass::PrePromptFrame, "pre_prompt_frame"),
            (PlacementClass::SideChannelContext, "side_channel_context"),
            (PlacementClass::ReceiptOnly, "receipt_only"),
        ],
        PlacementClass::ALL,
    );
    assert_eq!(PlacementClass::ALL.len(), 4);
}

#[test]
fn placement_outcome_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (PlacementOutcome::Delivered, "delivered"),
            (PlacementOutcome::Skipped, "skipped"),
            (PlacementOutcome::Degraded, "degraded"),
            (PlacementOutcome::Failed, "failed"),
        ],
        PlacementOutcome::ALL,
    );
    assert_eq!(PlacementOutcome::ALL.len(), 4);
}

#[test]
fn requirement_level_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (RequirementLevel::Required, "required"),
            (RequirementLevel::Preferred, "preferred"),
            (RequirementLevel::Optional, "optional"),
        ],
        RequirementLevel::ALL,
    );
    assert_eq!(RequirementLevel::ALL.len(), 3);
}

#[test]
fn negotiation_outcome_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (NegotiationOutcome::Satisfied, "satisfied"),
            (NegotiationOutcome::Degraded, "degraded"),
            (NegotiationOutcome::Unsupported, "unsupported"),
            (NegotiationOutcome::RequiresOperator, "requires_operator"),
        ],
        NegotiationOutcome::ALL,
    );
    assert_eq!(NegotiationOutcome::ALL.len(), 4);
}

#[test]
fn frame_class_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (FrameClass::TopLevel, "top_level"),
            (FrameClass::Subcall, "subcall"),
        ],
        FrameClass::ALL,
    );
    assert_eq!(FrameClass::ALL.len(), 2);
}

#[test]
fn renewal_automation_state_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (RenewalAutomationState::NotAttempted, "not_attempted"),
            (RenewalAutomationState::NoRenewal, "no_renewal"),
            (RenewalAutomationState::PrepareStarted, "prepare_started"),
            (RenewalAutomationState::ReceiptWritten, "receipt_written"),
            (RenewalAutomationState::LeaseCreated, "lease_created"),
            (
                RenewalAutomationState::PendingContinuation,
                "pending_continuation",
            ),
            (RenewalAutomationState::Fulfilled, "fulfilled"),
            (RenewalAutomationState::Failed, "failed"),
            (RenewalAutomationState::Expired, "expired"),
        ],
        RenewalAutomationState::ALL,
    );
    assert_eq!(RenewalAutomationState::ALL.len(), 9);
}

#[test]
fn callback_request_rejects_unknown_top_level_field() {
    // deny_unknown_fields means a producer that adds a stale or typo'd
    // field gets a parse error rather than silent acceptance. This is
    // the "wire-shape drift" defense the validate command needs.
    let mut value = serde_json::to_value(fixture_request()).unwrap();
    value
        .as_object_mut()
        .unwrap()
        .insert("bogus_field".into(), serde_json::Value::from(1));
    assert!(
        serde_json::from_value::<CallbackRequest>(value).is_err(),
        "CallbackRequest must reject unknown fields"
    );
}

#[test]
fn callback_response_rejects_unknown_top_level_field() {
    let resp = CallbackResponse::ok(ReceiptStatus::Delivered);
    let mut value = serde_json::to_value(&resp).unwrap();
    value
        .as_object_mut()
        .unwrap()
        .insert("bogus_field".into(), serde_json::Value::from("x"));
    assert!(
        serde_json::from_value::<CallbackResponse>(value).is_err(),
        "CallbackResponse must reject unknown fields"
    );
}

#[test]
fn payload_envelope_rejects_unknown_top_level_field() {
    let mut value = serde_json::to_value(fixture_payload()).unwrap();
    value
        .as_object_mut()
        .unwrap()
        .insert("bogus_field".into(), serde_json::Value::from(true));
    assert!(
        serde_json::from_value::<PayloadEnvelope>(value).is_err(),
        "PayloadEnvelope must reject unknown fields"
    );
}

#[test]
fn lifecycle_receipt_rejects_unknown_top_level_field() {
    let mut value = serde_json::to_value(fixture_receipt()).unwrap();
    value
        .as_object_mut()
        .unwrap()
        .insert("bogus_field".into(), serde_json::Value::Null);
    assert!(
        serde_json::from_value::<LifecycleReceipt>(value).is_err(),
        "LifecycleReceipt must reject unknown fields"
    );
}

/// A required-nullable key dropped from inbound JSON must fail deserialize,
/// not silently default to `None`. Each of the 5 fields gets its own case so
/// the failure points at the exact contract field.
#[test]
fn lifecycle_receipt_rejects_each_missing_required_nullable_field() {
    for field in LifecycleReceipt::REQUIRED_NULLABLE_FIELDS {
        let mut value = serde_json::to_value(fixture_receipt()).unwrap();
        value.as_object_mut().unwrap().remove(*field);
        let err = serde_json::from_value::<LifecycleReceipt>(value)
            .expect_err(&format!("expected error when `{field}` is omitted"));
        let msg = err.to_string();
        assert!(
            msg.contains(field),
            "error must name the missing field `{field}`, got: {msg}"
        );
        assert!(
            msg.contains("required-nullable"),
            "error must call out the required-nullable rule, got: {msg}"
        );
    }
}

/// Dropping multiple required-nullable keys at once should fail with a
/// message that names every missing key. A draft client that drops `sequence`
/// and `parent_receipt_id` should not have to fix one, retry, and learn about
/// the next.
#[test]
fn lifecycle_receipt_rejection_lists_all_missing_required_nullable_fields() {
    let mut value = serde_json::to_value(fixture_receipt()).unwrap();
    {
        let obj = value.as_object_mut().unwrap();
        obj.remove("sequence");
        obj.remove("parent_receipt_id");
        obj.remove("retry_class");
    }
    let err = serde_json::from_value::<LifecycleReceipt>(value)
        .expect_err("expected error for multiple missing required-nullable keys");
    let msg = err.to_string();
    for field in ["sequence", "parent_receipt_id", "retry_class"] {
        assert!(
            msg.contains(field),
            "error must name `{field}` (multi-key form), got: {msg}"
        );
    }
}

/// `"sequence": null` (and the other four required-nullable keys set to null)
/// is the on-the-wire shape Lifeloop owes its callers. It must roundtrip
/// without error so a producer can honestly say "I don't have a sequence
/// number" without dropping the key.
#[test]
fn lifecycle_receipt_accepts_explicit_null_for_required_nullable_fields() {
    let mut r = fixture_receipt();
    r.sequence = None;
    r.parent_receipt_id = None;
    r.idempotency_key = None;
    r.failure_class = None;
    r.retry_class = None;
    let value = serde_json::to_value(&r).unwrap();
    for field in LifecycleReceipt::REQUIRED_NULLABLE_FIELDS {
        assert_eq!(
            value[*field],
            serde_json::Value::Null,
            "fixture must serialize `{field}` as explicit null"
        );
    }
    let roundtrip: LifecycleReceipt =
        serde_json::from_value(value).expect("explicit null must deserialize");
    assert_eq!(roundtrip, r);
    roundtrip
        .validate()
        .expect("explicit-null receipt must validate");
}

#[test]
fn unknown_enum_wire_name_is_rejected() {
    assert!(serde_json::from_value::<IntegrationMode>(json!("not_a_mode")).is_err());
    assert!(serde_json::from_value::<LifecycleEventKind>(json!("session.unknown")).is_err());
    assert!(serde_json::from_value::<ReceiptStatus>(json!("approved")).is_err());
    assert!(serde_json::from_value::<FailureClass>(json!("oops")).is_err());
    assert!(serde_json::from_value::<RetryClass>(json!("retry_eventually")).is_err());
    assert!(serde_json::from_value::<SupportState>(json!("emulated")).is_err());
    assert!(serde_json::from_value::<AdapterRole>(json!("foreman")).is_err());
    assert!(serde_json::from_value::<PlacementClass>(json!("inline_assistant")).is_err());
    assert!(serde_json::from_value::<PlacementOutcome>(json!("partial")).is_err());
    assert!(serde_json::from_value::<RequirementLevel>(json!("nice_to_have")).is_err());
    assert!(serde_json::from_value::<NegotiationOutcome>(json!("pending")).is_err());
    assert!(serde_json::from_value::<FrameClass>(json!("recursive")).is_err());
    assert!(serde_json::from_value::<RenewalAutomationState>(json!("running")).is_err());
}

#[test]
fn failure_class_default_retry_table_matches_spec() {
    let cases = [
        (
            FailureClass::AdapterUnavailable,
            RetryClass::RetryAfterReconfigure,
        ),
        (FailureClass::CapabilityUnsupported, RetryClass::DoNotRetry),
        (
            FailureClass::CapabilityDegraded,
            RetryClass::RetryAfterReread,
        ),
        (
            FailureClass::PlacementUnavailable,
            RetryClass::RetryAfterReconfigure,
        ),
        (FailureClass::PayloadTooLarge, RetryClass::DoNotRetry),
        (
            FailureClass::PayloadRejected,
            RetryClass::RetryAfterReconfigure,
        ),
        (
            FailureClass::IdentityUnavailable,
            RetryClass::RetryAfterReconfigure,
        ),
        (FailureClass::TransportError, RetryClass::SafeRetry),
        (FailureClass::Timeout, RetryClass::SafeRetry),
        (
            FailureClass::OperatorRequired,
            RetryClass::RetryAfterOperator,
        ),
        (FailureClass::StateConflict, RetryClass::RetryAfterReread),
        (FailureClass::InvalidRequest, RetryClass::DoNotRetry),
        (FailureClass::InternalError, RetryClass::RetryAfterReread),
    ];
    for (failure, expected) in cases {
        assert_eq!(failure.default_retry(), expected, "for {failure:?}");
    }
}

// ============================================================================
// Frame context
// ============================================================================

#[test]
fn frame_context_top_level_pins_shape() {
    let fc = FrameContext::top_level("frm-1");
    assert_eq!(
        serde_json::to_value(&fc).unwrap(),
        json!({"frame_id": "frm-1", "frame_class": "top_level"})
    );
}

#[test]
fn frame_context_subcall_pins_shape() {
    let fc = FrameContext::subcall("frm-2", "frm-1");
    assert_eq!(
        serde_json::to_value(&fc).unwrap(),
        json!({
            "frame_id": "frm-2",
            "parent_frame_id": "frm-1",
            "frame_class": "subcall"
        })
    );
}

#[test]
fn frame_context_validation_rejects_bad_shapes() {
    let mut fc = FrameContext::top_level("frm-1");
    fc.parent_frame_id = Some("frm-0".into());
    assert!(matches!(
        fc.validate().unwrap_err(),
        ValidationError::InvalidFrameContext(_)
    ));

    let bad = FrameContext {
        frame_id: "frm-3".into(),
        parent_frame_id: None,
        frame_class: FrameClass::Subcall,
    };
    assert!(matches!(
        bad.validate().unwrap_err(),
        ValidationError::InvalidFrameContext(_)
    ));

    let empty = FrameContext::top_level("");
    assert!(matches!(
        empty.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}

// ============================================================================
// Payload envelope
// ============================================================================

fn fixture_payload() -> PayloadEnvelope {
    PayloadEnvelope {
        schema_version: SCHEMA_VERSION.to_string(),
        payload_id: "pay-1".into(),
        client_id: "ccd".into(),
        payload_kind: "instruction_frame".into(),
        format: "client-defined".into(),
        content_encoding: "utf8".into(),
        body: Some("hello".into()),
        body_ref: None,
        byte_size: 5,
        content_digest: None,
        acceptable_placements: vec![AcceptablePlacement {
            placement: PlacementClass::PrePromptFrame,
            requirement: RequirementLevel::Required,
        }],
        idempotency_key: None,
        expires_at_epoch_s: None,
        redaction: None,
        metadata: serde_json::Map::new(),
    }
}

#[test]
fn payload_envelope_pins_shape_with_body() {
    let p = fixture_payload();
    assert_eq!(
        serde_json::to_value(&p).unwrap(),
        json!({
            "schema_version": "lifeloop.v0.2",
            "payload_id": "pay-1",
            "client_id": "ccd",
            "payload_kind": "instruction_frame",
            "format": "client-defined",
            "content_encoding": "utf8",
            "body": "hello",
            "byte_size": 5,
            "acceptable_placements": [
                {"placement": "pre_prompt_frame", "requirement": "required"}
            ]
        })
    );
}

#[test]
fn payload_envelope_validation_rejects_invalid() {
    let mut p = fixture_payload();
    p.body_ref = Some("ref-1".into());
    assert!(matches!(
        p.validate().unwrap_err(),
        ValidationError::InvalidPayload(_)
    ));

    let mut p = fixture_payload();
    p.body = None;
    p.body_ref = None;
    assert!(matches!(
        p.validate().unwrap_err(),
        ValidationError::InvalidPayload(_)
    ));

    let mut p = fixture_payload();
    p.acceptable_placements.clear();
    assert!(matches!(
        p.validate().unwrap_err(),
        ValidationError::InvalidPayload(_)
    ));

    let mut p = fixture_payload();
    p.byte_size = 1;
    assert!(matches!(
        p.validate().unwrap_err(),
        ValidationError::InvalidPayload(_)
    ));

    let mut p = fixture_payload();
    p.payload_kind = String::new();
    assert!(matches!(
        p.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));

    let mut p = fixture_payload();
    p.schema_version = "lifeloop.v0".into();
    assert!(matches!(
        p.validate().unwrap_err(),
        ValidationError::SchemaVersionMismatch { .. }
    ));
}

#[test]
fn payload_ref_rejects_empty_required_strings() {
    let mut payload_ref = PayloadRef {
        payload_id: "pay-1".into(),
        payload_kind: "instruction_frame".into(),
        content_digest: None,
        byte_size: Some(5),
    };

    payload_ref.payload_id.clear();
    assert!(matches!(
        payload_ref.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));

    payload_ref.payload_id = "pay-1".into();
    payload_ref.payload_kind.clear();
    assert!(matches!(
        payload_ref.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}

// ============================================================================
// Renewal automation status
// ============================================================================

#[test]
fn renewal_automation_status_pins_token_safe_shape() {
    let status = RenewalAutomationStatus {
        schema_version: SCHEMA_VERSION.to_string(),
        state: RenewalAutomationState::PendingContinuation,
        client_id: "ccd".into(),
        adapter_id: "codex".into(),
        updated_at_epoch_s: 1_778_100_100,
        pending_token_present: true,
        reset_path: Some("wrapper".into()),
        thread_id: Some("thread-1".into()),
        renewal_lease_id: Some("lease-1".into()),
        prepared_at_epoch_s: Some(1_778_100_000),
        fulfilled_at_epoch_s: None,
        pending_path: Some("/repo/.git/lifeloop/renewal/ccd-renewal-pending.json".into()),
        reset_prepare_receipt_path: Some("/repo/.git/lifeloop/renewal/reset-prepare-1.json".into()),
        failure_class: None,
        retry_class: None,
        message: Some("pending continuation token is stored out of band".into()),
    };
    status.validate().expect("status validates");
    let value = serde_json::to_value(&status).unwrap();
    assert_eq!(
        value,
        json!({
            "schema_version": "lifeloop.v0.2",
            "state": "pending_continuation",
            "client_id": "ccd",
            "adapter_id": "codex",
            "updated_at_epoch_s": 1778100100,
            "pending_token_present": true,
            "reset_path": "wrapper",
            "thread_id": "thread-1",
            "renewal_lease_id": "lease-1",
            "prepared_at_epoch_s": 1778100000,
            "pending_path": "/repo/.git/lifeloop/renewal/ccd-renewal-pending.json",
            "reset_prepare_receipt_path": "/repo/.git/lifeloop/renewal/reset-prepare-1.json",
            "message": "pending continuation token is stored out of band"
        })
    );
    assert!(
        !value.to_string().contains("cnt-"),
        "renewal status must not expose continuation tokens"
    );
}

#[test]
fn renewal_automation_failed_status_requires_retry_posture() {
    let status = RenewalAutomationStatus {
        schema_version: SCHEMA_VERSION.to_string(),
        state: RenewalAutomationState::Failed,
        client_id: "ccd".into(),
        adapter_id: "codex".into(),
        updated_at_epoch_s: 1_778_100_100,
        pending_token_present: false,
        reset_path: None,
        thread_id: None,
        renewal_lease_id: None,
        prepared_at_epoch_s: None,
        fulfilled_at_epoch_s: None,
        pending_path: None,
        reset_prepare_receipt_path: None,
        failure_class: None,
        retry_class: None,
        message: Some("failed".into()),
    };
    assert!(matches!(
        status.validate().unwrap_err(),
        ValidationError::InvalidRequest(_)
    ));
}

// ============================================================================
// Lifecycle receipt
// ============================================================================

fn fixture_receipt() -> LifecycleReceipt {
    LifecycleReceipt {
        schema_version: SCHEMA_VERSION.to_string(),
        receipt_id: "lfr-1".into(),
        idempotency_key: None,
        client_id: "ccd".into(),
        adapter_id: "codex".into(),
        invocation_id: "inv-1".into(),
        event: LifecycleEventKind::FrameOpening,
        event_id: "evt-1".into(),
        sequence: Some(42),
        parent_receipt_id: None,
        integration_mode: IntegrationMode::NativeHook,
        status: ReceiptStatus::Delivered,
        at_epoch_s: 1_778_100_000,
        harness_session_id: Some("session-123".into()),
        harness_run_id: None,
        harness_task_id: None,
        payload_receipts: vec![PayloadReceipt {
            payload_id: "pay-1".into(),
            payload_kind: "instruction_frame".into(),
            placement: PlacementClass::PrePromptFrame,
            status: PlacementOutcome::Delivered,
            byte_size: 5,
            content_digest: Some("sha256:pay1".into()),
        }],
        telemetry_summary: serde_json::Map::new(),
        capability_degradations: Vec::new(),
        failure_class: None,
        retry_class: None,
        warnings: Vec::new(),
    }
}

#[test]
fn lifecycle_receipt_pins_required_nullable_fields() {
    let r = fixture_receipt();
    let value = serde_json::to_value(&r).unwrap();
    assert_eq!(value["schema_version"], "lifeloop.v0.2");
    assert_eq!(value["receipt_id"], "lfr-1");
    assert!(value.get("sequence").is_some());
    assert_eq!(value["sequence"], 42);
    assert!(value.get("parent_receipt_id").is_some());
    assert_eq!(value["parent_receipt_id"], serde_json::Value::Null);
    assert_eq!(value["idempotency_key"], serde_json::Value::Null);
    assert_eq!(value["failure_class"], serde_json::Value::Null);
    assert_eq!(value["retry_class"], serde_json::Value::Null);
    assert_eq!(value["event"], "frame.opening");
    assert_eq!(value["status"], "delivered");
    assert_eq!(
        value["payload_receipts"][0]["payload_kind"],
        "instruction_frame"
    );
    assert_eq!(
        value["payload_receipts"][0]["content_digest"],
        "sha256:pay1"
    );
    assert_eq!(value["payload_receipts"][0]["status"], "delivered");
}

#[test]
fn payload_receipt_pins_optional_digest_and_validation() {
    let with_digest = PayloadReceipt {
        payload_id: "pay-1".into(),
        payload_kind: "instruction_frame".into(),
        placement: PlacementClass::PrePromptFrame,
        status: PlacementOutcome::Delivered,
        byte_size: 5,
        content_digest: Some("sha256:pay1".into()),
    };
    assert_eq!(
        serde_json::to_value(&with_digest).unwrap(),
        json!({
            "payload_id": "pay-1",
            "payload_kind": "instruction_frame",
            "placement": "pre_prompt_frame",
            "status": "delivered",
            "byte_size": 5,
            "content_digest": "sha256:pay1"
        })
    );

    let without_digest = PayloadReceipt {
        content_digest: None,
        ..with_digest.clone()
    };
    let value = serde_json::to_value(&without_digest).unwrap();
    assert!(
        value.get("content_digest").is_none(),
        "content_digest must be omitted when absent"
    );

    let mut invalid = with_digest;
    invalid.payload_kind.clear();
    assert!(matches!(
        invalid.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}

#[test]
fn lifecycle_receipt_rejects_receipt_emitted_event() {
    let mut r = fixture_receipt();
    r.event = LifecycleEventKind::ReceiptEmitted;
    assert!(matches!(
        r.validate().unwrap_err(),
        ValidationError::InvalidReceipt(_)
    ));
}

#[test]
fn lifecycle_receipt_failed_status_requires_failure_class() {
    let mut r = fixture_receipt();
    r.status = ReceiptStatus::Failed;
    r.failure_class = None;
    r.retry_class = Some(RetryClass::SafeRetry);
    assert!(matches!(
        r.validate().unwrap_err(),
        ValidationError::InvalidReceipt(_)
    ));
    r.failure_class = Some(FailureClass::TransportError);
    assert!(r.validate().is_ok());
}

#[test]
fn lifecycle_receipt_failed_status_requires_retry_class() {
    let mut r = fixture_receipt();
    r.status = ReceiptStatus::Failed;
    r.failure_class = Some(FailureClass::TransportError);
    r.retry_class = None;
    let err = r.validate().unwrap_err();
    assert!(
        matches!(err, ValidationError::InvalidReceipt(ref m) if m.contains("retry_class")),
        "expected InvalidReceipt mentioning retry_class, got {err:?}"
    );
}

#[test]
fn lifecycle_receipt_rejects_empty_required_strings() {
    let mut r = fixture_receipt();
    r.adapter_id.clear();
    assert!(matches!(
        r.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}

#[test]
fn lifecycle_receipt_rejects_failure_class_on_non_failed_status() {
    let mut r = fixture_receipt();
    r.status = ReceiptStatus::Delivered;
    r.failure_class = Some(FailureClass::TransportError);
    assert!(matches!(
        r.validate().unwrap_err(),
        ValidationError::InvalidReceipt(_)
    ));
}

#[test]
fn lifecycle_receipt_pins_full_literal_shape() {
    // Catches stray field renames that the field-by-field assertions in
    // `lifecycle_receipt_pins_required_nullable_fields` would miss.
    let r = fixture_receipt();
    assert_eq!(
        serde_json::to_value(&r).unwrap(),
        json!({
            "schema_version": "lifeloop.v0.2",
            "receipt_id": "lfr-1",
            "idempotency_key": serde_json::Value::Null,
            "client_id": "ccd",
            "adapter_id": "codex",
            "invocation_id": "inv-1",
            "event": "frame.opening",
            "event_id": "evt-1",
            "sequence": 42,
            "parent_receipt_id": serde_json::Value::Null,
            "integration_mode": "native_hook",
            "status": "delivered",
            "at_epoch_s": 1_778_100_000_u64,
            "harness_session_id": "session-123",
            "payload_receipts": [
                {
                    "payload_id": "pay-1",
                    "payload_kind": "instruction_frame",
                    "placement": "pre_prompt_frame",
                    "status": "delivered",
                    "byte_size": 5,
                    "content_digest": "sha256:pay1"
                }
            ],
            "failure_class": serde_json::Value::Null,
            "retry_class": serde_json::Value::Null
        })
    );
}

// ============================================================================
// Callback request and response
// ============================================================================

fn fixture_request() -> CallbackRequest {
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::FrameOpening,
        event_id: "evt-1".into(),
        adapter_id: "codex".into(),
        adapter_version: "0.1.0".into(),
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: "inv-1".into(),
        harness_session_id: Some("session-123".into()),
        harness_run_id: None,
        harness_task_id: None,
        frame_context: Some(FrameContext::top_level("frm-1")),
        capability_snapshot_ref: None,
        payload_refs: vec![PayloadRef {
            payload_id: "pay-1".into(),
            payload_kind: "instruction_frame".into(),
            content_digest: None,
            byte_size: Some(5),
        }],
        sequence: Some(1),
        idempotency_key: Some("idem-1".into()),
        metadata: serde_json::Map::new(),
    }
}

// ============================================================================
// Dispatch envelope (issue #22 transport boundary)
// ============================================================================

#[test]
fn dispatch_envelope_pins_shape_with_payloads() {
    let envelope = DispatchEnvelope::new(fixture_request(), vec![fixture_payload()]);
    let value = serde_json::to_value(&envelope).unwrap();
    // Schema version is pinned and present at the top level.
    assert_eq!(value["schema_version"], "lifeloop.v0.2");
    // `request` and `payloads` keys are both present and carry the
    // canonical inner shapes.
    assert_eq!(value["request"]["adapter_id"], "codex");
    assert_eq!(value["request"]["event"], "frame.opening");
    assert_eq!(value["payloads"][0]["payload_id"], "pay-1");
    assert_eq!(value["payloads"][0]["body"], "hello");
    assert!(envelope.validate().is_ok());
}

#[test]
fn dispatch_envelope_omits_empty_payloads_on_the_wire() {
    let envelope = DispatchEnvelope::new(fixture_request(), Vec::new());
    let value = serde_json::to_value(&envelope).unwrap();
    // Empty payloads array is skip-serialized so the empty case is
    // indistinguishable from the legacy "request alone" wire shape's
    // top-level keys (modulo the `schema_version` + `request` wrapper
    // a transport-boundary message must always carry).
    assert!(value.get("payloads").is_none(), "got: {value}");
    assert_eq!(value["schema_version"], "lifeloop.v0.2");
    assert_eq!(value["request"]["adapter_id"], "codex");
}

#[test]
fn dispatch_envelope_rejects_unknown_top_level_field() {
    let envelope = DispatchEnvelope::new(fixture_request(), Vec::new());
    let mut value = serde_json::to_value(&envelope).unwrap();
    value
        .as_object_mut()
        .unwrap()
        .insert("bogus_field".into(), serde_json::Value::Null);
    assert!(
        serde_json::from_value::<DispatchEnvelope>(value).is_err(),
        "DispatchEnvelope must reject unknown fields",
    );
}

#[test]
fn dispatch_envelope_rejects_schema_version_mismatch() {
    let mut envelope = DispatchEnvelope::new(fixture_request(), Vec::new());
    envelope.schema_version = "lifeloop.v0.0".into();
    assert!(matches!(
        envelope.validate().unwrap_err(),
        ValidationError::SchemaVersionMismatch { .. }
    ));
}

#[test]
fn dispatch_envelope_round_trips_through_json() {
    let envelope = DispatchEnvelope::new(fixture_request(), vec![fixture_payload()]);
    let bytes = serde_json::to_vec(&envelope).unwrap();
    let parsed: DispatchEnvelope = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(parsed, envelope);
    parsed.validate().expect("round-tripped envelope validates");
}

#[test]
fn callback_request_pins_shape() {
    let req = fixture_request();
    assert_eq!(
        serde_json::to_value(&req).unwrap(),
        json!({
            "schema_version": "lifeloop.v0.2",
            "event": "frame.opening",
            "event_id": "evt-1",
            "adapter_id": "codex",
            "adapter_version": "0.1.0",
            "integration_mode": "native_hook",
            "invocation_id": "inv-1",
            "harness_session_id": "session-123",
            "frame_context": {"frame_id": "frm-1", "frame_class": "top_level"},
            "payload_refs": [
                {"payload_id": "pay-1", "payload_kind": "instruction_frame", "byte_size": 5}
            ],
            "sequence": 1,
            "idempotency_key": "idem-1"
        })
    );
}

#[test]
fn callback_request_rejects_frame_event_without_frame_context() {
    let mut req = fixture_request();
    req.frame_context = None;
    assert!(matches!(
        req.validate().unwrap_err(),
        ValidationError::InvalidRequest(_)
    ));
}

#[test]
fn callback_request_rejects_receipt_emitted_with_idempotency_key() {
    let mut req = fixture_request();
    req.event = LifecycleEventKind::ReceiptEmitted;
    req.frame_context = None;
    req.idempotency_key = Some("idem-x".into());
    assert!(matches!(
        req.validate().unwrap_err(),
        ValidationError::InvalidRequest(_)
    ));
}

#[test]
fn callback_request_rejects_partial_frame_context() {
    let mut req = fixture_request();
    req.frame_context = Some(FrameContext {
        frame_id: String::new(),
        parent_frame_id: None,
        frame_class: FrameClass::TopLevel,
    });
    assert!(matches!(
        req.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}

#[test]
fn callback_request_rejects_empty_sentinel_strings() {
    let blanks: &[fn(&mut CallbackRequest)] = &[
        |r| r.event_id.clear(),
        |r| r.adapter_id.clear(),
        |r| r.adapter_version.clear(),
        |r| r.invocation_id.clear(),
    ];
    for blank in blanks {
        let mut req = fixture_request();
        blank(&mut req);
        assert!(matches!(
            req.validate().unwrap_err(),
            ValidationError::EmptyField(_)
        ));
    }
}

#[test]
fn callback_request_roundtrips_through_json() {
    let req = fixture_request();
    let bytes = serde_json::to_vec(&req).unwrap();
    let parsed: CallbackRequest = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(parsed, req);
}

#[test]
fn callback_response_ok_pins_shape() {
    let resp = CallbackResponse::ok(ReceiptStatus::Delivered);
    assert_eq!(
        serde_json::to_value(&resp).unwrap(),
        json!({
            "schema_version": "lifeloop.v0.2",
            "status": "delivered",
            "failure_class": serde_json::Value::Null,
            "retry_class": serde_json::Value::Null
        })
    );
}

#[test]
fn callback_response_ok_accepts_null_failure_and_retry_classes() {
    let resp = CallbackResponse::ok(ReceiptStatus::Delivered);
    resp.validate().expect("ok response is valid");
}

#[test]
fn callback_response_failed_carries_default_retry() {
    let resp = CallbackResponse::failed(FailureClass::Timeout);
    assert_eq!(resp.status, ReceiptStatus::Failed);
    assert_eq!(resp.failure_class, Some(FailureClass::Timeout));
    assert_eq!(resp.retry_class, Some(RetryClass::SafeRetry));
}

#[test]
fn callback_response_failed_status_requires_failure_class() {
    let mut resp = CallbackResponse::failed(FailureClass::Timeout);
    resp.failure_class = None;
    assert!(matches!(
        resp.validate().unwrap_err(),
        ValidationError::InvalidResponse(_)
    ));
}

#[test]
fn callback_response_failed_status_requires_retry_class() {
    let mut resp = CallbackResponse::failed(FailureClass::Timeout);
    resp.retry_class = None;
    let err = resp.validate().unwrap_err();
    assert!(
        matches!(err, ValidationError::InvalidResponse(ref m) if m.contains("retry_class")),
        "expected InvalidResponse mentioning retry_class, got {err:?}"
    );
}

#[test]
fn callback_response_rejects_failure_class_on_non_failed_status() {
    let mut resp = CallbackResponse::ok(ReceiptStatus::Delivered);
    resp.failure_class = Some(FailureClass::Timeout);
    assert!(matches!(
        resp.validate().unwrap_err(),
        ValidationError::InvalidResponse(_)
    ));
}

#[test]
fn callback_response_with_client_payloads_pins_shape() {
    let payload = fixture_payload();
    let mut resp = CallbackResponse::ok(ReceiptStatus::Delivered);
    resp.client_payloads.push(payload);
    let value = serde_json::to_value(&resp).unwrap();
    assert_eq!(value["schema_version"], "lifeloop.v0.2");
    assert_eq!(value["status"], "delivered");
    assert_eq!(
        value["client_payloads"][0]["schema_version"],
        "lifeloop.v0.2"
    );
    assert_eq!(value["client_payloads"][0]["payload_id"], "pay-1");
    assert_eq!(value["client_payloads"][0]["body"], "hello");
    assert_eq!(
        value["client_payloads"][0]["acceptable_placements"][0]["placement"],
        "pre_prompt_frame"
    );
    assert_eq!(value["failure_class"], serde_json::Value::Null);
    assert_eq!(value["retry_class"], serde_json::Value::Null);
}

// ============================================================================
// Adapter manifest (issue #6: full shape and registry)
// ============================================================================

fn minimal_manifest() -> AdapterManifest {
    AdapterManifest {
        contract_version: SCHEMA_VERSION.to_string(),
        adapter_id: "codex".into(),
        adapter_version: "0.1.0".into(),
        display_name: "Codex".into(),
        role: AdapterRole::PrimaryWorker,
        integration_modes: vec![IntegrationMode::NativeHook],
        lifecycle_events: BTreeMap::from([(
            LifecycleEventKind::SessionStarting,
            ManifestLifecycleEventSupport {
                support: SupportState::Native,
                modes: vec![IntegrationMode::NativeHook],
            },
        )]),
        placement: BTreeMap::from([(
            ManifestPlacementClass::PreSession,
            ManifestPlacementSupport {
                support: SupportState::Native,
                max_bytes: Some(8192),
            },
        )]),
        context_pressure: ManifestContextPressure {
            support: SupportState::Synthesized,
            evidence: None,
        },
        receipts: ManifestReceipts {
            native: false,
            lifeloop_synthesized: true,
            receipt_ledger: SupportState::Unavailable,
        },
        session_identity: None,
        session_rename: None,
        renewal: None,
        approval_surface: None,
        failure_modes: Vec::new(),
        telemetry_sources: Vec::new(),
        known_degradations: Vec::new(),
    }
}

#[test]
fn manifest_placement_class_serializes_and_roundtrips() {
    roundtrip_enum(
        &[
            (ManifestPlacementClass::PreSession, "pre_session"),
            (ManifestPlacementClass::PreFrameLeading, "pre_frame_leading"),
            (
                ManifestPlacementClass::PreFrameTrailing,
                "pre_frame_trailing",
            ),
            (ManifestPlacementClass::ToolResult, "tool_result"),
            (ManifestPlacementClass::ManualOperator, "manual_operator"),
        ],
        ManifestPlacementClass::ALL,
    );
    assert_eq!(ManifestPlacementClass::ALL.len(), 5);
}

#[test]
fn adapter_manifest_pins_minimal_shape() {
    let manifest = minimal_manifest();
    assert_eq!(
        serde_json::to_value(&manifest).unwrap(),
        json!({
            "contract_version": "lifeloop.v0.2",
            "adapter_id": "codex",
            "adapter_version": "0.1.0",
            "display_name": "Codex",
            "role": "primary_worker",
            "integration_modes": ["native_hook"],
            "lifecycle_events": {
                "session.starting": {
                    "support": "native",
                    "modes": ["native_hook"]
                }
            },
            "placement": {
                "pre_session": {
                    "support": "native",
                    "max_bytes": 8192
                }
            },
            "context_pressure": {"support": "synthesized"},
            "receipts": {
                "native": false,
                "lifeloop_synthesized": true,
                "receipt_ledger": "unavailable"
            }
        })
    );
    assert!(manifest.validate().is_ok());
}

#[test]
fn adapter_manifest_rejects_empty_integration_modes() {
    let mut manifest = minimal_manifest();
    manifest.integration_modes.clear();
    assert!(matches!(
        manifest.validate().unwrap_err(),
        ValidationError::InvalidManifest(_)
    ));
}

#[test]
fn adapter_manifest_rejects_empty_required_strings() {
    for blank in [
        |m: &mut AdapterManifest| m.adapter_id.clear(),
        |m: &mut AdapterManifest| m.adapter_version.clear(),
        |m: &mut AdapterManifest| m.display_name.clear(),
    ] {
        let mut manifest = minimal_manifest();
        blank(&mut manifest);
        assert!(matches!(
            manifest.validate().unwrap_err(),
            ValidationError::EmptyField(_)
        ));
    }
}

#[test]
fn adapter_manifest_rejects_unknown_top_level_field() {
    let manifest = minimal_manifest();
    let mut value = serde_json::to_value(&manifest).unwrap();
    value
        .as_object_mut()
        .unwrap()
        .insert("bogus_field".into(), serde_json::Value::Null);
    assert!(
        serde_json::from_value::<AdapterManifest>(value).is_err(),
        "AdapterManifest must reject unknown fields"
    );
}

#[test]
fn adapter_manifest_round_trips_through_json() {
    let manifest = minimal_manifest();
    let json = serde_json::to_string(&manifest).unwrap();
    let parsed: AdapterManifest = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed, manifest);
}

#[test]
fn adapter_manifest_serializes_optional_fields_when_present() {
    let mut manifest = minimal_manifest();
    manifest.session_identity = Some(ManifestSessionIdentity {
        harness_session_id: SupportState::Native,
        harness_run_id: SupportState::Synthesized,
        harness_task_id: SupportState::Unavailable,
    });
    manifest.renewal = Some(ManifestRenewal {
        reset: ManifestRenewalReset {
            native: SupportState::Native,
            wrapper_mediated: SupportState::Partial,
            manual: SupportState::Manual,
        },
        continuation: ManifestRenewalContinuation {
            observation: SupportState::Synthesized,
            payload_delivery: SupportState::Partial,
        },
        profiles: vec!["ccd-renewal".into()],
        evidence: Some("adapter proves renewal delivery without owning client tokens".into()),
    });
    manifest.failure_modes = vec![FailureClass::TransportError];
    let value = serde_json::to_value(&manifest).unwrap();
    assert_eq!(
        value["session_identity"],
        json!({
            "harness_session_id": "native",
            "harness_run_id": "synthesized",
            "harness_task_id": "unavailable"
        })
    );
    assert_eq!(
        value["renewal"],
        json!({
            "reset": {
                "native": "native",
                "wrapper_mediated": "partial",
                "manual": "manual"
            },
            "continuation": {
                "observation": "synthesized",
                "payload_delivery": "partial"
            },
            "profiles": ["ccd-renewal"],
            "evidence": "adapter proves renewal delivery without owning client tokens"
        })
    );
    assert_eq!(value["failure_modes"], json!(["transport_error"]));
}

#[test]
fn adapter_manifest_rejects_empty_renewal_evidence() {
    let mut manifest = minimal_manifest();
    manifest.renewal = Some(ManifestRenewal {
        reset: ManifestRenewalReset {
            native: SupportState::Unavailable,
            wrapper_mediated: SupportState::Unavailable,
            manual: SupportState::Unavailable,
        },
        continuation: ManifestRenewalContinuation {
            observation: SupportState::Unavailable,
            payload_delivery: SupportState::Unavailable,
        },
        profiles: Vec::new(),
        evidence: Some(String::new()),
    });
    assert!(matches!(
        manifest.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}

// ============================================================================
// Capability degradation, warning shapes
// ============================================================================

#[test]
fn capability_degradation_pins_shape() {
    let deg = CapabilityDegradation {
        capability: "context_pressure".into(),
        previous_support: SupportState::Native,
        current_support: SupportState::Unavailable,
        evidence: Some("telemetry file missing".into()),
        retry_class: Some(RetryClass::RetryAfterReconfigure),
    };
    assert_eq!(
        serde_json::to_value(&deg).unwrap(),
        json!({
            "capability": "context_pressure",
            "previous_support": "native",
            "current_support": "unavailable",
            "evidence": "telemetry file missing",
            "retry_class": "retry_after_reconfigure"
        })
    );
}

#[test]
fn capability_degradation_rejects_empty_capability() {
    let deg = CapabilityDegradation {
        capability: String::new(),
        previous_support: SupportState::Native,
        current_support: SupportState::Unavailable,
        evidence: None,
        retry_class: None,
    };
    assert!(matches!(
        deg.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}

#[test]
fn warning_pins_shape() {
    let w = Warning {
        code: "placement_degraded".into(),
        message: "fell back to pre_prompt_frame".into(),
        capability: Some("developer_equivalent_frame".into()),
    };
    assert_eq!(
        serde_json::to_value(&w).unwrap(),
        json!({
            "code": "placement_degraded",
            "message": "fell back to pre_prompt_frame",
            "capability": "developer_equivalent_frame"
        })
    );
}

#[test]
fn warning_rejects_empty_code() {
    let w = Warning {
        code: String::new(),
        message: "fell back to pre_prompt_frame".into(),
        capability: None,
    };
    assert!(matches!(
        w.validate().unwrap_err(),
        ValidationError::EmptyField(_)
    ));
}
