//! Behavior tests for capability and placement negotiation (issue #13).
//!
//! These tests build a `RoutingPlan` via the router skeleton's
//! `route()`, then exercise `negotiate()` against a fixture adapter
//! manifest tuned per test. The fake registry mirrors the one
//! `tests/router.rs` uses.

use std::collections::BTreeMap;

use lifeloop::router::{
    AdapterRegistry, AdapterResolution, CapabilityKind, CapabilityRequest, CapabilityRequirement,
    DefaultNegotiationStrategy, NegotiationStrategy, PayloadPlacementDecision, PlacementRejection,
    RoutingPlan, negotiate, route,
};
use lifeloop::{
    AcceptablePlacement, AdapterManifest, AdapterRole, CallbackRequest, ConformanceLevel,
    FailureClass, IntegrationMode, LifecycleEventKind, ManifestContextPressure,
    ManifestLifecycleEventSupport, ManifestPlacementClass, ManifestPlacementSupport,
    ManifestReceipts, ManifestRenewal, ManifestRenewalContinuation, ManifestRenewalReset,
    ManifestSessionIdentity, NegotiationOutcome, PayloadEnvelope, PlacementClass,
    RegisteredAdapter, RequirementLevel, SCHEMA_VERSION, SupportState,
};

const FAKE_ID: &str = "fake-adapter";
const FAKE_VERSION: &str = "0.0.1";

/// Build a manifest with every placement class declared, knobs
/// adjustable per call.
fn manifest_with(
    placement: BTreeMap<ManifestPlacementClass, ManifestPlacementSupport>,
    lifecycle: BTreeMap<LifecycleEventKind, ManifestLifecycleEventSupport>,
    context_pressure: SupportState,
    session_identity: Option<ManifestSessionIdentity>,
) -> AdapterManifest {
    AdapterManifest {
        contract_version: SCHEMA_VERSION.to_string(),
        adapter_id: FAKE_ID.into(),
        adapter_version: FAKE_VERSION.into(),
        display_name: "Fake".into(),
        role: AdapterRole::PrimaryWorker,
        integration_modes: vec![IntegrationMode::NativeHook],
        lifecycle_events: lifecycle,
        placement,
        context_pressure: ManifestContextPressure {
            support: context_pressure,
            evidence: None,
        },
        receipts: ManifestReceipts {
            native: false,
            lifeloop_synthesized: true,
            receipt_ledger: SupportState::Unavailable,
        },
        session_identity,
        session_rename: None,
        renewal: None,
        approval_surface: None,
        failure_modes: Vec::new(),
        telemetry_sources: Vec::new(),
        known_degradations: Vec::new(),
    }
}

fn full_placement_support() -> BTreeMap<ManifestPlacementClass, ManifestPlacementSupport> {
    BTreeMap::from([
        (
            ManifestPlacementClass::PreSession,
            ManifestPlacementSupport {
                support: SupportState::Native,
                max_bytes: None,
            },
        ),
        (
            ManifestPlacementClass::PreFrameLeading,
            ManifestPlacementSupport {
                support: SupportState::Native,
                max_bytes: None,
            },
        ),
        (
            ManifestPlacementClass::PreFrameTrailing,
            ManifestPlacementSupport {
                support: SupportState::Native,
                max_bytes: Some(1024),
            },
        ),
        (
            ManifestPlacementClass::ToolResult,
            ManifestPlacementSupport {
                support: SupportState::Synthesized,
                max_bytes: None,
            },
        ),
        (
            ManifestPlacementClass::ManualOperator,
            ManifestPlacementSupport {
                support: SupportState::Manual,
                max_bytes: None,
            },
        ),
    ])
}

struct Fixture {
    manifest: AdapterManifest,
}

impl AdapterRegistry for Fixture {
    fn resolve(&self, adapter_id: &str, adapter_version: &str) -> AdapterResolution {
        if adapter_id != self.manifest.adapter_id {
            return AdapterResolution::UnknownId;
        }
        if adapter_version != self.manifest.adapter_version {
            return AdapterResolution::VersionMismatch {
                registered_version: self.manifest.adapter_version.clone(),
            };
        }
        AdapterResolution::Found(RegisteredAdapter {
            manifest: self.manifest.clone(),
            conformance: ConformanceLevel::PreConformance,
        })
    }
}

fn base_request() -> CallbackRequest {
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::SessionStarting,
        event_id: "evt_1".into(),
        adapter_id: FAKE_ID.into(),
        adapter_version: FAKE_VERSION.into(),
        invocation_id: "inv_1".into(),
        harness_session_id: None,
        harness_run_id: None,
        harness_task_id: None,
        integration_mode: IntegrationMode::NativeHook,
        frame_context: None,
        payload_refs: Vec::new(),
        capability_snapshot_ref: None,
        sequence: None,
        idempotency_key: None,
        metadata: serde_json::Map::new(),
    }
}

fn build_plan(manifest: AdapterManifest) -> RoutingPlan {
    let fx = Fixture { manifest };
    route(&base_request(), &fx).expect("plan builds")
}

fn payload_with(
    id: &str,
    body: &str,
    byte_size: u64,
    placements: Vec<(PlacementClass, RequirementLevel)>,
) -> PayloadEnvelope {
    PayloadEnvelope {
        schema_version: SCHEMA_VERSION.to_string(),
        payload_id: id.into(),
        client_id: "test-client".into(),
        payload_kind: "instruction_frame".into(),
        format: "client-defined".into(),
        content_encoding: "utf8".into(),
        body: Some(body.to_string()),
        body_ref: None,
        byte_size,
        content_digest: None,
        acceptable_placements: placements
            .into_iter()
            .map(|(placement, requirement)| AcceptablePlacement {
                placement,
                requirement,
            })
            .collect(),
        idempotency_key: None,
        expires_at_epoch_s: None,
        redaction: None,
        metadata: serde_json::Map::new(),
    }
}

// ===========================================================================
// Capability negotiation outcome variants
// ===========================================================================

#[test]
fn satisfied_outcome_when_all_required_and_preferred_are_met() {
    let mut lifecycle = BTreeMap::new();
    lifecycle.insert(
        LifecycleEventKind::SessionStarting,
        ManifestLifecycleEventSupport {
            support: SupportState::Native,
            modes: vec![IntegrationMode::NativeHook],
        },
    );
    let m = manifest_with(
        full_placement_support(),
        lifecycle,
        SupportState::Native,
        None,
    );
    let plan = build_plan(m);
    let req = CapabilityRequest::new()
        .with(CapabilityRequirement::required(
            CapabilityKind::LifecycleEvent(LifecycleEventKind::SessionStarting),
            SupportState::Native,
        ))
        .with(CapabilityRequirement::preferred(
            CapabilityKind::ContextPressure,
            SupportState::Synthesized,
        ));
    let r = negotiate(&plan, &req, &[]);
    assert_eq!(r.outcome, NegotiationOutcome::Satisfied);
    assert!(r.capability_degradations.is_empty());
    assert!(r.warnings.is_empty());
    assert!(r.failure_class.is_none());
    assert!(!r.blocks_dispatch());
}

#[test]
fn degraded_outcome_when_preferred_capability_lacks_support() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::from([(
            LifecycleEventKind::SessionStarting,
            ManifestLifecycleEventSupport {
                support: SupportState::Native,
                modes: vec![IntegrationMode::NativeHook],
            },
        )]),
        SupportState::Unavailable,
        None,
    ));
    let req = CapabilityRequest::new()
        .with(CapabilityRequirement::required(
            CapabilityKind::LifecycleEvent(LifecycleEventKind::SessionStarting),
            SupportState::Native,
        ))
        .with(CapabilityRequirement::preferred(
            CapabilityKind::ContextPressure,
            SupportState::Native,
        ));
    let r = negotiate(&plan, &req, &[]);
    assert_eq!(r.outcome, NegotiationOutcome::Degraded);
    assert_eq!(r.capability_degradations.len(), 1);
    let deg = &r.capability_degradations[0];
    assert_eq!(deg.capability, "context_pressure");
    assert_eq!(deg.previous_support, SupportState::Native);
    assert_eq!(deg.current_support, SupportState::Unavailable);
    assert!(!r.warnings.is_empty());
    assert!(r.failure_class.is_none());
    assert!(!r.blocks_dispatch());
}

#[test]
fn unsupported_outcome_when_required_capability_unavailable() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Unavailable,
        None,
    ));
    let req = CapabilityRequest::new().with(CapabilityRequirement::required(
        CapabilityKind::LifecycleEvent(LifecycleEventKind::SessionStarting),
        SupportState::Native,
    ));
    let r = negotiate(&plan, &req, &[]);
    assert_eq!(r.outcome, NegotiationOutcome::Unsupported);
    assert_eq!(r.failure_class, Some(FailureClass::CapabilityUnsupported));
    assert!(r.blocks_dispatch());
    // Capability degradations are not produced for unsupported
    // required capabilities; the receipt path uses failure_class
    // for that signal.
    assert!(r.capability_degradations.is_empty());
}

#[test]
fn requires_operator_outcome_when_required_capability_only_manual() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Unavailable,
        None,
    ));
    // Approval surface is the canonical "operator-only" capability
    // shape on the manifest.
    let mut m = plan.adapter.clone();
    m.approval_surface = Some(lifeloop::ManifestApprovalSurface {
        support: SupportState::Manual,
    });
    let plan = build_plan(m);
    let req = CapabilityRequest::new().with(CapabilityRequirement::required(
        CapabilityKind::ApprovalSurface,
        SupportState::Native,
    ));
    let r = negotiate(&plan, &req, &[]);
    assert_eq!(r.outcome, NegotiationOutcome::RequiresOperator);
    assert_eq!(r.failure_class, Some(FailureClass::OperatorRequired));
    assert!(r.blocks_dispatch());
}

#[test]
fn optional_capabilities_may_be_omitted_without_warning() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Unavailable,
        None,
    ));
    // session_identity is None on the manifest; an Optional
    // requirement asking for it must produce a Satisfied outcome
    // with no warning.
    let req = CapabilityRequest::new().with(CapabilityRequirement::optional(
        CapabilityKind::HarnessSessionId,
        SupportState::Native,
    ));
    let r = negotiate(&plan, &req, &[]);
    assert_eq!(r.outcome, NegotiationOutcome::Satisfied);
    assert!(r.warnings.is_empty());
    assert!(r.capability_degradations.is_empty());
}

#[test]
fn capability_kind_name_pins_lifecycle_event_wire_name() {
    assert_eq!(
        CapabilityKind::LifecycleEvent(LifecycleEventKind::FrameOpening).name(),
        "lifecycle_event:frame.opening"
    );
    assert_eq!(
        CapabilityKind::RenewalContinuationPayloadDelivery.name(),
        "renewal.continuation.payload_delivery"
    );
}

#[test]
fn support_strength_order_degrades_weaker_claims() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Partial,
        None,
    ));
    let req = CapabilityRequest::new().with(CapabilityRequirement::preferred(
        CapabilityKind::ContextPressure,
        SupportState::Synthesized,
    ));
    let r = negotiate(&plan, &req, &[]);

    assert_eq!(r.outcome, NegotiationOutcome::Degraded);
    assert_eq!(r.capability_degradations.len(), 1);
    assert_eq!(
        r.capability_degradations[0].previous_support,
        SupportState::Synthesized
    );
    assert_eq!(
        r.capability_degradations[0].current_support,
        SupportState::Partial
    );
}

#[test]
fn manual_requirement_accepts_stronger_non_unavailable_support() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let req = CapabilityRequest::new().with(CapabilityRequirement::required(
        CapabilityKind::ContextPressure,
        SupportState::Manual,
    ));
    let r = negotiate(&plan, &req, &[]);

    assert_eq!(r.outcome, NegotiationOutcome::Satisfied);
    assert_eq!(r.failure_class, None);
    assert!(r.warnings.is_empty());
}

#[test]
fn renewal_negotiation_distinguishes_observation_from_payload_delivery() {
    let mut manifest = manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    );
    manifest.renewal = Some(ManifestRenewal {
        reset: ManifestRenewalReset {
            native: SupportState::Unavailable,
            wrapper_mediated: SupportState::Partial,
            manual: SupportState::Manual,
        },
        continuation: ManifestRenewalContinuation {
            observation: SupportState::Partial,
            payload_delivery: SupportState::Unavailable,
        },
        profiles: Vec::new(),
        evidence: Some("fixture renewal surface".into()),
    });
    let plan = build_plan(manifest);

    let observation = negotiate(
        &plan,
        &CapabilityRequest::new().with(CapabilityRequirement::required(
            CapabilityKind::RenewalContinuationObservation,
            SupportState::Partial,
        )),
        &[],
    );
    assert_eq!(observation.outcome, NegotiationOutcome::Satisfied);
    assert_eq!(observation.failure_class, None);

    let delivery = negotiate(
        &plan,
        &CapabilityRequest::new().with(CapabilityRequirement::required(
            CapabilityKind::RenewalContinuationPayloadDelivery,
            SupportState::Partial,
        )),
        &[],
    );
    assert_eq!(delivery.outcome, NegotiationOutcome::Unsupported);
    assert_eq!(
        delivery.failure_class,
        Some(FailureClass::CapabilityUnsupported)
    );
    assert_eq!(
        delivery.warnings[0].capability.as_deref(),
        Some("renewal.continuation.payload_delivery")
    );
}

#[test]
fn renewal_manual_reset_distinguishes_manual_from_non_manual_requests() {
    let mut manifest = manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    );
    manifest.renewal = Some(ManifestRenewal {
        reset: ManifestRenewalReset {
            native: SupportState::Unavailable,
            wrapper_mediated: SupportState::Unavailable,
            manual: SupportState::Manual,
        },
        continuation: ManifestRenewalContinuation {
            observation: SupportState::Manual,
            payload_delivery: SupportState::Manual,
        },
        profiles: Vec::new(),
        evidence: None,
    });
    let plan = build_plan(manifest);

    let manual = negotiate(
        &plan,
        &CapabilityRequest::new().with(CapabilityRequirement::required(
            CapabilityKind::RenewalResetManual,
            SupportState::Manual,
        )),
        &[],
    );
    assert_eq!(manual.outcome, NegotiationOutcome::Satisfied);
    assert_eq!(manual.failure_class, None);

    let non_manual = negotiate(
        &plan,
        &CapabilityRequest::new().with(CapabilityRequirement::required(
            CapabilityKind::RenewalResetManual,
            SupportState::Partial,
        )),
        &[],
    );
    assert_eq!(non_manual.outcome, NegotiationOutcome::RequiresOperator);
    assert_eq!(
        non_manual.failure_class,
        Some(FailureClass::OperatorRequired)
    );
}

// ===========================================================================
// Placement negotiation
// ===========================================================================

#[test]
fn placement_chosen_first_preference_developer_equivalent_frame() {
    // DeveloperEquivalentFrame -> ManifestPlacementClass::PreFrameLeading
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let env = payload_with(
        "p1",
        "opaque-body",
        100,
        vec![(
            PlacementClass::DeveloperEquivalentFrame,
            RequirementLevel::Required,
        )],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), std::slice::from_ref(&env));
    assert_eq!(r.outcome, NegotiationOutcome::Satisfied);
    assert_eq!(r.placement_decisions.len(), 1);
    match &r.placement_decisions[0] {
        PayloadPlacementDecision::Chosen {
            chosen,
            first_preference,
            payload_id,
            payload_kind,
            byte_size,
            content_digest,
            ..
        } => {
            assert_eq!(*chosen, PlacementClass::DeveloperEquivalentFrame);
            assert!(*first_preference);
            assert_eq!(payload_id, "p1");
            assert_eq!(payload_kind, "instruction_frame");
            assert_eq!(*byte_size, "opaque-body".len() as u64);
            assert_eq!(content_digest, &None);
        }
        other => panic!("expected Chosen, got {other:?}"),
    }
    // Body opacity: bytes pass through unchanged.
    assert_eq!(env.body.as_deref(), Some("opaque-body"));
}

#[test]
fn placement_chosen_pre_prompt_frame_maps_to_pre_frame_trailing() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let env = payload_with(
        "p1",
        "opaque",
        50,
        vec![(PlacementClass::PrePromptFrame, RequirementLevel::Required)],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);
    matches!(
        r.placement_decisions[0],
        PayloadPlacementDecision::Chosen {
            chosen: PlacementClass::PrePromptFrame,
            ..
        }
    );
}

#[test]
fn placement_chosen_side_channel_context_maps_to_manual_operator() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let env = payload_with(
        "p1",
        "opaque",
        50,
        vec![(
            PlacementClass::SideChannelContext,
            RequirementLevel::Optional,
        )],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);
    match &r.placement_decisions[0] {
        PayloadPlacementDecision::Chosen { chosen, .. } => {
            assert_eq!(*chosen, PlacementClass::SideChannelContext);
        }
        other => panic!("expected Chosen, got {other:?}"),
    }
}

#[test]
fn placement_receipt_only_is_universally_available() {
    // Even if the manifest declares no placements, ReceiptOnly is
    // satisfied because it does not inject content into a frame.
    let plan = build_plan(manifest_with(
        BTreeMap::new(),
        BTreeMap::new(),
        SupportState::Unavailable,
        None,
    ));
    let env = payload_with(
        "p_receipt",
        "opaque",
        10,
        vec![(PlacementClass::ReceiptOnly, RequirementLevel::Required)],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);
    assert_eq!(r.outcome, NegotiationOutcome::Satisfied);
    match &r.placement_decisions[0] {
        PayloadPlacementDecision::Chosen { chosen, .. } => {
            assert_eq!(*chosen, PlacementClass::ReceiptOnly);
        }
        other => panic!("expected Chosen, got {other:?}"),
    }
}

#[test]
fn placement_pre_session_class_exercised_via_developer_equivalent_with_only_pre_session() {
    // Manifest only declares PreSession + PreFrameTrailing. A
    // payload asking for DeveloperEquivalentFrame (->PreFrameLeading)
    // first, then PrePromptFrame (->PreFrameTrailing) falls back.
    let mut placement = BTreeMap::new();
    placement.insert(
        ManifestPlacementClass::PreSession,
        ManifestPlacementSupport {
            support: SupportState::Native,
            max_bytes: None,
        },
    );
    placement.insert(
        ManifestPlacementClass::PreFrameTrailing,
        ManifestPlacementSupport {
            support: SupportState::Native,
            max_bytes: None,
        },
    );
    let plan = build_plan(manifest_with(
        placement,
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let env = payload_with(
        "p_fb",
        "opaque",
        25,
        vec![
            (
                PlacementClass::DeveloperEquivalentFrame,
                RequirementLevel::Preferred,
            ),
            (PlacementClass::PrePromptFrame, RequirementLevel::Required),
        ],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);
    match &r.placement_decisions[0] {
        PayloadPlacementDecision::Chosen {
            chosen,
            first_preference,
            rejected,
            ..
        } => {
            assert_eq!(*chosen, PlacementClass::PrePromptFrame);
            assert!(!*first_preference);
            assert_eq!(rejected.len(), 1);
            assert_eq!(
                rejected[0].placement(),
                PlacementClass::DeveloperEquivalentFrame
            );
        }
        other => panic!("expected fallback Chosen, got {other:?}"),
    }
}

#[test]
fn placement_payload_too_large_falls_through_then_fails() {
    // PreFrameTrailing has max_bytes = 1024. A 4KB payload that
    // only accepts PrePromptFrame (->PreFrameTrailing) fails with
    // PayloadTooLarge.
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let body = "x".repeat(4096);
    let env = payload_with(
        "p_big",
        &body,
        body.len() as u64,
        vec![(PlacementClass::PrePromptFrame, RequirementLevel::Required)],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);
    assert_eq!(r.outcome, NegotiationOutcome::Unsupported);
    match &r.placement_decisions[0] {
        PayloadPlacementDecision::Failed {
            failure_class,
            rejected,
            ..
        } => {
            assert_eq!(*failure_class, FailureClass::PayloadTooLarge);
            assert!(matches!(
                rejected[0],
                PlacementRejection::PayloadTooLarge { .. }
            ));
        }
        other => panic!("expected Failed, got {other:?}"),
    }
}

#[test]
fn placement_allows_payload_at_exact_manifest_byte_limit() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let body = "x".repeat(1024);
    let env = payload_with(
        "p_exact",
        &body,
        body.len() as u64,
        vec![(PlacementClass::PrePromptFrame, RequirementLevel::Required)],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);

    assert_eq!(r.outcome, NegotiationOutcome::Satisfied);
    assert_eq!(r.failure_class, None);
    assert!(matches!(
        r.placement_decisions[0],
        PayloadPlacementDecision::Chosen {
            chosen: PlacementClass::PrePromptFrame,
            ..
        }
    ));
}

#[test]
fn placement_payload_too_large_uses_actual_inline_body_bytes() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let body = "x".repeat(4096);
    let env = payload_with(
        "p_underreported",
        &body,
        1,
        vec![(PlacementClass::PrePromptFrame, RequirementLevel::Required)],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);
    assert_eq!(r.failure_class, Some(FailureClass::PayloadTooLarge));
    match &r.placement_decisions[0] {
        PayloadPlacementDecision::Failed { rejected, .. } => {
            assert!(matches!(
                rejected[0],
                PlacementRejection::PayloadTooLarge {
                    byte_size: 4096,
                    max_bytes: 1024,
                    ..
                }
            ));
        }
        other => panic!("expected Failed, got {other:?}"),
    }
}

#[test]
fn placement_unavailable_when_no_acceptable_placement_exists() {
    // Manifest only declares PreSession; a payload that doesn't
    // accept it has no satisfiable placement.
    let mut placement = BTreeMap::new();
    placement.insert(
        ManifestPlacementClass::PreSession,
        ManifestPlacementSupport {
            support: SupportState::Native,
            max_bytes: None,
        },
    );
    let plan = build_plan(manifest_with(
        placement,
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let env = payload_with(
        "p_no",
        "opaque",
        10,
        vec![(
            PlacementClass::DeveloperEquivalentFrame,
            RequirementLevel::Required,
        )],
    );
    let r = negotiate(&plan, &CapabilityRequest::new(), &[env]);
    assert_eq!(r.outcome, NegotiationOutcome::Unsupported);
    assert_eq!(r.failure_class, Some(FailureClass::PlacementUnavailable));
    assert!(matches!(
        &r.placement_decisions[0],
        PayloadPlacementDecision::Failed {
            failure_class: FailureClass::PlacementUnavailable,
            ..
        }
    ));
}

#[test]
fn payload_placement_decision_accessors_distinguish_chosen_and_failed() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let chosen_env = payload_with(
        "p_chosen",
        "opaque",
        6,
        vec![(PlacementClass::PrePromptFrame, RequirementLevel::Required)],
    );
    let chosen = negotiate(&plan, &CapabilityRequest::new(), &[chosen_env]);
    assert_eq!(chosen.placement_decisions[0].payload_id(), "p_chosen");
    assert!(!chosen.placement_decisions[0].is_failed());

    let plan = build_plan(manifest_with(
        BTreeMap::new(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let failed_env = payload_with(
        "p_failed",
        "opaque",
        6,
        vec![(
            PlacementClass::DeveloperEquivalentFrame,
            RequirementLevel::Required,
        )],
    );
    let failed = negotiate(&plan, &CapabilityRequest::new(), &[failed_env]);
    assert_eq!(failed.placement_decisions[0].payload_id(), "p_failed");
    assert!(failed.placement_decisions[0].is_failed());
}

// ===========================================================================
// Body opacity & dispatch fail-closed
// ===========================================================================

#[test]
fn payload_body_bytes_pass_through_unchanged() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Native,
        None,
    ));
    let body = "{\"opaque\":\"data\",\"nested\":{\"x\":1}}".to_string();
    let env = payload_with(
        "p_body",
        &body,
        body.len() as u64,
        vec![(
            PlacementClass::DeveloperEquivalentFrame,
            RequirementLevel::Required,
        )],
    );
    let original_body = env.body.clone();
    let r = negotiate(&plan, &CapabilityRequest::new(), std::slice::from_ref(&env));
    // The negotiated plan does not consume or mutate the envelope;
    // bytes are still intact on the caller's copy.
    assert_eq!(env.body, original_body);
    assert_eq!(env.body.as_deref(), Some(body.as_str()));
    assert!(matches!(
        r.placement_decisions[0],
        PayloadPlacementDecision::Chosen { .. }
    ));
}

#[test]
fn required_capability_failure_halts_before_dispatch_path() {
    // Even when payload placement *would* succeed, an unsupported
    // required capability must produce blocks_dispatch=true and
    // suppress placement evaluation entirely.
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::new(),
        SupportState::Unavailable,
        None,
    ));
    let req = CapabilityRequest::new().with(CapabilityRequirement::required(
        CapabilityKind::LifecycleEvent(LifecycleEventKind::SessionStarting),
        SupportState::Native,
    ));
    let env = payload_with(
        "p_x",
        "opaque",
        10,
        vec![(
            PlacementClass::DeveloperEquivalentFrame,
            RequirementLevel::Required,
        )],
    );
    let r = negotiate(&plan, &req, &[env]);
    assert_eq!(r.outcome, NegotiationOutcome::Unsupported);
    assert!(r.blocks_dispatch());
    // Placement decisions must be empty: dispatch is blocked, so
    // we never advanced into the placement-evaluation branch.
    assert!(
        r.placement_decisions.is_empty(),
        "placement evaluation must not run after fail-closed capability"
    );
    assert_eq!(r.failure_class, Some(FailureClass::CapabilityUnsupported));
}

#[test]
fn negotiation_strategy_trait_returns_outcome_only_view() {
    let plan = build_plan(manifest_with(
        full_placement_support(),
        BTreeMap::from([(
            LifecycleEventKind::SessionStarting,
            ManifestLifecycleEventSupport {
                support: SupportState::Native,
                modes: vec![IntegrationMode::NativeHook],
            },
        )]),
        SupportState::Native,
        None,
    ));
    let strat = DefaultNegotiationStrategy::new(
        CapabilityRequest::new().with(CapabilityRequirement::required(
            CapabilityKind::LifecycleEvent(LifecycleEventKind::SessionStarting),
            SupportState::Native,
        )),
        Vec::new(),
    );
    assert_eq!(
        <DefaultNegotiationStrategy as NegotiationStrategy>::negotiate(&strat, &plan),
        NegotiationOutcome::Satisfied
    );
}
