//! Capability and placement negotiation (issue #13).
//!
//! This module fills the [`NegotiationStrategy`] seam declared in
//! issue #7. It is a *separate step* layered on top of [`route`]:
//! the caller first produces a [`RoutingPlan`] via
//! [`crate::router::route`], then evaluates a
//! [`CapabilityRequest`] against that plan via [`negotiate`] (or the
//! [`DefaultNegotiationStrategy`] type that implements
//! [`NegotiationStrategy::negotiate`] for the simpler outcome-only
//! API).
//!
//! Keeping negotiation a separate step rather than baking it into
//! `route()` lets issue #14 (receipt synthesis) consume the resulting
//! [`NegotiatedPlan`] without forcing every caller of `route` to
//! supply a capability request, and lets future issues swap in
//! richer negotiation policies without touching the validation /
//! plan-synthesis stages.
//!
//! # Boundary
//!
//! This module owns:
//! * the [`CapabilityRequest`] / [`CapabilityRequirement`] vocabulary
//!   a caller uses to declare what they need from the adapter for
//!   one lifecycle event;
//! * the comparison of those requirements against the resolved
//!   [`AdapterManifest`]'s [`SupportState`] claims;
//! * per-payload placement evaluation (`acceptable_placements` ->
//!   manifest [`ManifestPlacementSupport`]) including payload-size
//!   rejection and structured fallback;
//! * synthesis of a typed [`NegotiatedPlan`] that wraps the
//!   [`RoutingPlan`] with the negotiation decision, demoted
//!   capability records, per-payload [`PayloadPlacementDecision`]s,
//!   and structured warnings.
//!
//! This module does **not** own:
//! * mid-session degradation tracking — `capability.degraded` event
//!   emission is owned by a follow-up router issue (#14+);
//! * receipt synthesis — issue #14 will read [`NegotiatedPlan`] and
//!   produce a [`crate::LifecycleReceipt`];
//! * payload body parsing — payload bodies remain opaque. This module
//!   only measures inline body bytes for placement limits and reads
//!   [`PayloadEnvelope::acceptable_placements`].
//!
//! # Manual support and `requires_operator`
//!
//! The spec body's negotiation table treats `requires_operator` as
//! distinct from `degraded`: it surfaces when an adapter's only
//! pathway for a *required* capability is `manual` (operator-driven).
//! The rule we apply:
//!
//! * a required capability whose manifest support is `manual` and
//!   whose request asked for any non-manual support level produces
//!   `requires_operator` (operator action is the only way forward);
//! * a required capability whose manifest support is `unavailable`
//!   produces `unsupported` (no path forward at all);
//! * `unsupported` strictly dominates `requires_operator` (a missing
//!   capability is worse than one that needs an operator), and
//!   `requires_operator` dominates `degraded` (operator action is
//!   not just a softer outcome — it changes the dispatch path).
//!
//! # Routing vs manifest placement vocabularies
//!
//! [`PlacementClass`] (the routing vocabulary on
//! `acceptable_placements`) and [`ManifestPlacementClass`] (the
//! manifest claim vocabulary) are intentionally separate per the
//! spec. This module owns the small, internal mapping between them
//! used to look a routing class up in the manifest's claims.

use crate::{
    AcceptablePlacement, AdapterManifest, CapabilityDegradation, FailureClass, LifecycleEventKind,
    ManifestPlacementClass, ManifestPlacementSupport, NegotiationOutcome, PayloadEnvelope,
    PlacementClass, RequirementLevel, SupportState, Warning,
};

use super::plan::RoutingPlan;
use super::seams::NegotiationStrategy;

// ===========================================================================
// CapabilityRequest
// ===========================================================================

/// Vocabulary of lifecycle capabilities a client can name in a
/// [`CapabilityRequest`].
///
/// Each variant maps to a specific support claim on the
/// [`AdapterManifest`]. `LifecycleEvent` is parameterized by the
/// concrete [`LifecycleEventKind`] so a request can ask for "this
/// event must be `native`" without enumerating every event up front.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CapabilityKind {
    /// Per-event support: the adapter must claim the named lifecycle
    /// event in its `lifecycle_events` map.
    LifecycleEvent(LifecycleEventKind),
    /// Native or synthesized context-pressure observation surface.
    ContextPressure,
    /// Adapter emits its own native receipts.
    NativeReceipts,
    /// Lifeloop synthesizes receipts on the adapter's behalf.
    LifeloopSynthesizedReceipts,
    /// Durable cross-invocation receipt ledger.
    ReceiptLedger,
    /// Harness session-id correlation.
    HarnessSessionId,
    /// Harness run-id correlation.
    HarnessRunId,
    /// Harness task-id correlation.
    HarnessTaskId,
    /// Adapter's session-rename surface (optional manifest field).
    SessionRename,
    /// Native harness reset path for renewal.
    RenewalResetNative,
    /// Launcher/wrapper-mediated reset path for renewal.
    RenewalResetWrapperMediated,
    /// Operator/manual reset path for renewal.
    RenewalResetManual,
    /// Adapter can observe that a continuation boundary happened.
    RenewalContinuationObservation,
    /// Adapter can deliver client-provided continuation facts across
    /// the reset/continuation boundary.
    RenewalContinuationPayloadDelivery,
    /// Adapter's operator approval surface (optional manifest field).
    ApprovalSurface,
}

impl CapabilityKind {
    /// Stable wire-style name for diagnostics and warning records.
    /// This is *not* serialized as part of any public wire envelope —
    /// it is the human-readable code surfaced on
    /// [`CapabilityDegradation::capability`] and
    /// [`Warning::capability`].
    pub fn name(&self) -> String {
        match self {
            Self::LifecycleEvent(ev) => match serde_json::to_value(ev) {
                Ok(serde_json::Value::String(s)) => format!("lifecycle_event:{s}"),
                _ => "lifecycle_event".to_string(),
            },
            Self::ContextPressure => "context_pressure".into(),
            Self::NativeReceipts => "receipts.native".into(),
            Self::LifeloopSynthesizedReceipts => "receipts.lifeloop_synthesized".into(),
            Self::ReceiptLedger => "receipts.receipt_ledger".into(),
            Self::HarnessSessionId => "session_identity.harness_session_id".into(),
            Self::HarnessRunId => "session_identity.harness_run_id".into(),
            Self::HarnessTaskId => "session_identity.harness_task_id".into(),
            Self::SessionRename => "session_rename".into(),
            Self::RenewalResetNative => "renewal.reset.native".into(),
            Self::RenewalResetWrapperMediated => "renewal.reset.wrapper_mediated".into(),
            Self::RenewalResetManual => "renewal.reset.manual".into(),
            Self::RenewalContinuationObservation => "renewal.continuation.observation".into(),
            Self::RenewalContinuationPayloadDelivery => {
                "renewal.continuation.payload_delivery".into()
            }
            Self::ApprovalSurface => "approval_surface".into(),
        }
    }
}

/// One requirement in a [`CapabilityRequest`].
///
/// `desired` is the support level the client is requesting. The
/// negotiation comparison uses [`SupportState`] equality plus the
/// implicit ordering "any non-`Unavailable` state is at least as
/// supportive as `Unavailable`" — adapters whose claim equals or
/// strengthens the desired state satisfy the requirement; adapters
/// whose claim is `Unavailable` (or the optional capability is
/// missing entirely) do not.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityRequirement {
    pub kind: CapabilityKind,
    pub level: RequirementLevel,
    /// Minimum acceptable support. `Native` is the strongest.
    /// `Synthesized`, `Partial`, `Manual`, and `Unavailable` are
    /// progressively weaker. A client typically asks for
    /// `Synthesized` or `Native`; asking for `Manual` is unusual
    /// but valid.
    pub desired: SupportState,
}

impl CapabilityRequirement {
    pub fn required(kind: CapabilityKind, desired: SupportState) -> Self {
        Self {
            kind,
            level: RequirementLevel::Required,
            desired,
        }
    }

    pub fn preferred(kind: CapabilityKind, desired: SupportState) -> Self {
        Self {
            kind,
            level: RequirementLevel::Preferred,
            desired,
        }
    }

    pub fn optional(kind: CapabilityKind, desired: SupportState) -> Self {
        Self {
            kind,
            level: RequirementLevel::Optional,
            desired,
        }
    }
}

/// A client's capability request for one lifecycle dispatch.
///
/// Optional capabilities may be omitted entirely; their absence is
/// not an error and produces no warning. Mid-session degradation
/// (capabilities that change after dispatch starts) is a follow-up
/// issue — this struct captures the *pre-dispatch* request only.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapabilityRequest {
    pub requirements: Vec<CapabilityRequirement>,
}

impl CapabilityRequest {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with(mut self, req: CapabilityRequirement) -> Self {
        self.requirements.push(req);
        self
    }
}

// ===========================================================================
// Placement decision
// ===========================================================================

/// Why a particular [`AcceptablePlacement`] was rejected during
/// per-payload placement evaluation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlacementRejection {
    /// The adapter's manifest does not declare this placement at
    /// all (or declares it as `Unavailable`).
    Unsupported {
        placement: PlacementClass,
        manifest_support: SupportState,
    },
    /// The adapter declares the placement but the payload's
    /// `byte_size` exceeds the manifest's `max_bytes` for that
    /// placement.
    PayloadTooLarge {
        placement: PlacementClass,
        byte_size: u64,
        max_bytes: u64,
    },
}

impl PlacementRejection {
    pub fn placement(&self) -> PlacementClass {
        match self {
            Self::Unsupported { placement, .. } => *placement,
            Self::PayloadTooLarge { placement, .. } => *placement,
        }
    }
}

/// Per-payload placement decision produced by negotiation.
///
/// Either a [`PlacementClass`] was chosen (`status` then carries
/// the resulting [`crate::PlacementOutcome`] equivalent — encoded
/// here as a typed decision rather than a wire enum so the receipt
/// stage can map it onto `payload_receipts[].status`), or no
/// acceptable placement could be satisfied and the decision is
/// `Failed` with the structured rejection list. Both variants carry
/// the payload provenance observed during negotiation so receipt
/// synthesis does not need to infer it from callback responses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PayloadPlacementDecision {
    /// A placement was chosen. `payload_kind`, `byte_size`, and
    /// `content_digest` are copied from the negotiated payload
    /// envelope. `chosen` is the routing [`PlacementClass`] that won;
    /// `first_preference` is true when the chosen placement was the
    /// first entry in `acceptable_placements`. `rejected` lists any
    /// earlier placements that failed before the chosen one was
    /// reached.
    Chosen {
        payload_id: String,
        payload_kind: String,
        byte_size: u64,
        content_digest: Option<String>,
        chosen: PlacementClass,
        first_preference: bool,
        rejected: Vec<PlacementRejection>,
    },
    /// No acceptable placement was satisfiable. `payload_kind`,
    /// `byte_size`, and `content_digest` are copied from the
    /// negotiated payload envelope. `failure_class` is either
    /// [`FailureClass::PlacementUnavailable`] (no acceptable placement
    /// supported at all) or [`FailureClass::PayloadTooLarge`] (every
    /// otherwise-supported placement rejected the byte size).
    Failed {
        payload_id: String,
        payload_kind: String,
        byte_size: u64,
        content_digest: Option<String>,
        failure_class: FailureClass,
        rejected: Vec<PlacementRejection>,
    },
}

impl PayloadPlacementDecision {
    pub fn payload_id(&self) -> &str {
        match self {
            Self::Chosen { payload_id, .. } => payload_id,
            Self::Failed { payload_id, .. } => payload_id,
        }
    }

    pub fn is_failed(&self) -> bool {
        matches!(self, Self::Failed { .. })
    }
}

// ===========================================================================
// NegotiatedPlan
// ===========================================================================

/// Negotiation result wrapping a [`RoutingPlan`].
///
/// Issue #14 will consume this directly:
/// * `outcome` flows into `LifecycleReceipt.status`
///   (`satisfied` -> `delivered`, `degraded` -> `degraded`,
///   `unsupported` -> `failed` with `failure_class=capability_unsupported`,
///   `requires_operator` -> `failed` with
///   `failure_class=operator_required`);
/// * `capability_degradations` flows into
///   `LifecycleReceipt.capability_degradations` verbatim;
/// * `placement_decisions` flows into `payload_receipts` (one
///   `PayloadReceipt` per decision);
/// * `warnings` flows into `LifecycleReceipt.warnings`;
/// * `failure_class` (when `Some`) flows into
///   `LifecycleReceipt.failure_class` and seeds the default
///   `retry_class` via [`FailureClass::default_retry`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiatedPlan {
    pub plan: RoutingPlan,
    pub outcome: NegotiationOutcome,
    pub capability_degradations: Vec<CapabilityDegradation>,
    pub placement_decisions: Vec<PayloadPlacementDecision>,
    pub warnings: Vec<Warning>,
    /// Set when `outcome` is fail-closed (`Unsupported` /
    /// `RequiresOperator`) or when at least one payload placement
    /// failed. `None` when the dispatch path may continue.
    pub failure_class: Option<FailureClass>,
}

impl NegotiatedPlan {
    /// True when `outcome` blocks dispatch (`Unsupported` or
    /// `RequiresOperator`). The router skeleton fails closed: a
    /// caller MUST check this flag before invoking the
    /// [`crate::router::CallbackInvoker`] seam.
    pub fn blocks_dispatch(&self) -> bool {
        matches!(
            self.outcome,
            NegotiationOutcome::Unsupported | NegotiationOutcome::RequiresOperator
        )
    }
}

// ===========================================================================
// Strategy implementation
// ===========================================================================

/// Concrete [`NegotiationStrategy`] for issue #13.
///
/// Holds no state. The capability request is supplied per call via
/// [`Self::negotiate_full`]. The trait method
/// [`NegotiationStrategy::negotiate`] returns only the outcome
/// summary — it cannot observe placement results because the trait
/// signature predates this issue. Callers that need the full
/// negotiation result use [`Self::negotiate_full`] directly (or the
/// free [`negotiate`] function).
#[derive(Debug, Clone, Default)]
pub struct DefaultNegotiationStrategy {
    pub request: CapabilityRequest,
    pub payloads: Vec<PayloadEnvelope>,
}

impl DefaultNegotiationStrategy {
    pub fn new(request: CapabilityRequest, payloads: Vec<PayloadEnvelope>) -> Self {
        Self { request, payloads }
    }

    pub fn negotiate_full(&self, plan: &RoutingPlan) -> NegotiatedPlan {
        negotiate(plan, &self.request, &self.payloads)
    }
}

impl NegotiationStrategy for DefaultNegotiationStrategy {
    fn negotiate(&self, plan: &RoutingPlan) -> NegotiationOutcome {
        self.negotiate_full(plan).outcome
    }
}

/// Negotiate a [`CapabilityRequest`] (and any payload envelopes)
/// against an existing [`RoutingPlan`].
///
/// Returns a [`NegotiatedPlan`] wrapping the input plan with the
/// negotiation decision. The input plan is cloned into the result —
/// this preserves issue #7's guarantee that a `RoutingPlan` is
/// `'static`-friendly.
pub fn negotiate(
    plan: &RoutingPlan,
    request: &CapabilityRequest,
    payloads: &[PayloadEnvelope],
) -> NegotiatedPlan {
    let manifest = &plan.adapter;

    // Capability outcome aggregate.
    let mut outcome = NegotiationOutcome::Satisfied;
    let mut capability_degradations: Vec<CapabilityDegradation> = Vec::new();
    let mut warnings: Vec<Warning> = Vec::new();

    for req in &request.requirements {
        let manifest_support = manifest_support_for(manifest, &req.kind);
        let cap_outcome = classify_capability(req, manifest_support);
        match cap_outcome {
            CapabilityVerdict::Satisfied => {}
            CapabilityVerdict::Degraded { previous, current } => {
                capability_degradations.push(CapabilityDegradation {
                    capability: req.kind.name(),
                    previous_support: previous,
                    current_support: current,
                    evidence: None,
                    retry_class: None,
                });
                warnings.push(Warning {
                    code: "capability_degraded".into(),
                    message: format!(
                        "preferred capability `{}` degraded from `{previous:?}` to `{current:?}`",
                        req.kind.name()
                    ),
                    capability: Some(req.kind.name()),
                });
                outcome = strongest(outcome, NegotiationOutcome::Degraded);
            }
            CapabilityVerdict::RequiresOperator => {
                warnings.push(Warning {
                    code: "operator_required".into(),
                    message: format!(
                        "required capability `{}` is only available through manual operator action",
                        req.kind.name()
                    ),
                    capability: Some(req.kind.name()),
                });
                outcome = strongest(outcome, NegotiationOutcome::RequiresOperator);
            }
            CapabilityVerdict::Unsupported => {
                warnings.push(Warning {
                    code: "capability_unsupported".into(),
                    message: format!(
                        "required capability `{}` not supported by adapter `{}`",
                        req.kind.name(),
                        manifest.adapter_id
                    ),
                    capability: Some(req.kind.name()),
                });
                outcome = strongest(outcome, NegotiationOutcome::Unsupported);
            }
        }
    }

    // Per-payload placement decisions. Skipped on capability-level
    // fail-closed outcomes — there is no point evaluating placement
    // for a dispatch that cannot proceed. An empty payload list
    // (no payloads on the request) produces no decisions and no
    // additional warnings.
    let mut placement_decisions: Vec<PayloadPlacementDecision> = Vec::new();
    let dispatch_blocked = matches!(
        outcome,
        NegotiationOutcome::Unsupported | NegotiationOutcome::RequiresOperator
    );
    let mut placement_failure_class: Option<FailureClass> = None;

    if !dispatch_blocked {
        for env in payloads {
            let decision = decide_placement(env, manifest);
            if let PayloadPlacementDecision::Failed {
                failure_class,
                payload_id,
                ..
            } = &decision
            {
                outcome = strongest(outcome, NegotiationOutcome::Unsupported);
                placement_failure_class = Some(*failure_class);
                warnings.push(Warning {
                    code: "placement_unavailable".into(),
                    message: format!(
                        "payload `{}` had no acceptable placement on adapter `{}`",
                        payload_id, manifest.adapter_id
                    ),
                    capability: None,
                });
            }
            placement_decisions.push(decision);
        }
    }

    let failure_class = match outcome {
        NegotiationOutcome::Satisfied | NegotiationOutcome::Degraded => None,
        NegotiationOutcome::Unsupported => {
            Some(placement_failure_class.unwrap_or(FailureClass::CapabilityUnsupported))
        }
        NegotiationOutcome::RequiresOperator => Some(FailureClass::OperatorRequired),
    };

    NegotiatedPlan {
        plan: plan.clone(),
        outcome,
        capability_degradations,
        placement_decisions,
        warnings,
        failure_class,
    }
}

// ---------------------------------------------------------------------------
// Capability classification
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq)]
enum CapabilityVerdict {
    Satisfied,
    Degraded {
        previous: SupportState,
        current: SupportState,
    },
    RequiresOperator,
    Unsupported,
}

fn classify_capability(
    req: &CapabilityRequirement,
    manifest_support: SupportState,
) -> CapabilityVerdict {
    let satisfies = support_satisfies(manifest_support, req.desired);

    match req.level {
        RequirementLevel::Required => {
            if satisfies {
                CapabilityVerdict::Satisfied
            } else if manifest_support == SupportState::Manual
                && req.desired != SupportState::Manual
            {
                // Required, only manual path available -> operator action needed.
                CapabilityVerdict::RequiresOperator
            } else {
                CapabilityVerdict::Unsupported
            }
        }
        RequirementLevel::Preferred => {
            if satisfies {
                CapabilityVerdict::Satisfied
            } else {
                CapabilityVerdict::Degraded {
                    previous: req.desired,
                    current: manifest_support,
                }
            }
        }
        RequirementLevel::Optional => {
            // Optional capabilities never produce a degradation
            // record on their own; they are always satisfied
            // from the negotiation aggregate's point of view. A
            // future mid-session-degradation issue may revisit
            // this when an `optional` capability that *was*
            // supported drops out.
            CapabilityVerdict::Satisfied
        }
    }
}

/// Strength ordering on [`SupportState`] for negotiation purposes.
/// `Native` is strongest. `Manual` is treated as weaker than
/// `Synthesized`/`Partial` because it requires operator action; it
/// is *only* satisfying when the requested support was itself
/// `Manual`. `Unavailable` is the bottom.
fn support_rank(s: SupportState) -> u8 {
    match s {
        SupportState::Native => 4,
        SupportState::Synthesized => 3,
        SupportState::Partial => 2,
        SupportState::Manual => 1,
        SupportState::Unavailable => 0,
    }
}

fn support_satisfies(have: SupportState, want: SupportState) -> bool {
    if have == SupportState::Unavailable {
        return false;
    }
    if want == SupportState::Manual {
        // Manual is a specific opt-in: it is satisfied by Manual
        // or stronger non-Unavailable claims.
        return have != SupportState::Unavailable;
    }
    if have == SupportState::Manual {
        // Manual cannot silently satisfy a non-Manual request —
        // the operator-required pathway routes through the
        // RequiresOperator verdict instead.
        return false;
    }
    support_rank(have) >= support_rank(want)
}

/// Outcome combiner. The most-blocking outcome wins:
/// `Unsupported` > `RequiresOperator` > `Degraded` > `Satisfied`.
fn strongest(a: NegotiationOutcome, b: NegotiationOutcome) -> NegotiationOutcome {
    fn rank(o: NegotiationOutcome) -> u8 {
        match o {
            NegotiationOutcome::Satisfied => 0,
            NegotiationOutcome::Degraded => 1,
            NegotiationOutcome::RequiresOperator => 2,
            NegotiationOutcome::Unsupported => 3,
        }
    }
    if rank(a) >= rank(b) { a } else { b }
}

fn manifest_support_for(manifest: &AdapterManifest, kind: &CapabilityKind) -> SupportState {
    match kind {
        CapabilityKind::LifecycleEvent(ev) => manifest
            .lifecycle_events
            .get(ev)
            .map(|claim| claim.support)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::ContextPressure => manifest.context_pressure.support,
        CapabilityKind::NativeReceipts => {
            if manifest.receipts.native {
                SupportState::Native
            } else {
                SupportState::Unavailable
            }
        }
        CapabilityKind::LifeloopSynthesizedReceipts => {
            if manifest.receipts.lifeloop_synthesized {
                SupportState::Synthesized
            } else {
                SupportState::Unavailable
            }
        }
        CapabilityKind::ReceiptLedger => manifest.receipts.receipt_ledger,
        CapabilityKind::HarnessSessionId => manifest
            .session_identity
            .as_ref()
            .map(|si| si.harness_session_id)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::HarnessRunId => manifest
            .session_identity
            .as_ref()
            .map(|si| si.harness_run_id)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::HarnessTaskId => manifest
            .session_identity
            .as_ref()
            .map(|si| si.harness_task_id)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::SessionRename => manifest
            .session_rename
            .as_ref()
            .map(|s| s.support)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::RenewalResetNative => manifest
            .renewal
            .as_ref()
            .map(|r| r.reset.native)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::RenewalResetWrapperMediated => manifest
            .renewal
            .as_ref()
            .map(|r| r.reset.wrapper_mediated)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::RenewalResetManual => manifest
            .renewal
            .as_ref()
            .map(|r| r.reset.manual)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::RenewalContinuationObservation => manifest
            .renewal
            .as_ref()
            .map(|r| r.continuation.observation)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::RenewalContinuationPayloadDelivery => manifest
            .renewal
            .as_ref()
            .map(|r| r.continuation.payload_delivery)
            .unwrap_or(SupportState::Unavailable),
        CapabilityKind::ApprovalSurface => manifest
            .approval_surface
            .as_ref()
            .map(|s| s.support)
            .unwrap_or(SupportState::Unavailable),
    }
}

// ---------------------------------------------------------------------------
// Placement evaluation
// ---------------------------------------------------------------------------

/// Map a routing [`PlacementClass`] to the
/// [`ManifestPlacementClass`] under which the manifest declares its
/// support. The two vocabularies are intentionally separate per
/// the spec ("Manifest placement classes"); this mapping is local
/// to negotiation and is not exposed as a public conversion.
///
/// * `DeveloperEquivalentFrame` -> `PreFrameLeading` (strong
///   leading-edge instruction layer).
/// * `PrePromptFrame` -> `PreFrameTrailing` (prepended just before
///   the next prompt: trailing edge of the open frame, before
///   model execution).
/// * `SideChannelContext` -> `ManualOperator` (out-of-band channel
///   exposed by an operator-driven surface).
/// * `ReceiptOnly` -> `PreSession` is wrong; receipt-only payloads
///   are not delivered to any frame at all. We treat
///   `ReceiptOnly` as universally available — it records metadata
///   and never injects content — and skip the manifest claim
///   lookup for it.
fn manifest_placement_for(p: PlacementClass) -> Option<ManifestPlacementClass> {
    match p {
        PlacementClass::DeveloperEquivalentFrame => Some(ManifestPlacementClass::PreFrameLeading),
        PlacementClass::PrePromptFrame => Some(ManifestPlacementClass::PreFrameTrailing),
        PlacementClass::SideChannelContext => Some(ManifestPlacementClass::ManualOperator),
        PlacementClass::ReceiptOnly => None,
    }
}

/// Evaluate one [`AcceptablePlacement`] against the manifest.
fn evaluate_one(
    ap: &AcceptablePlacement,
    byte_size: u64,
    manifest: &AdapterManifest,
) -> Result<(), PlacementRejection> {
    // ReceiptOnly is universally available: it records metadata and
    // does not inject payload content into any frame, so no manifest
    // claim is required.
    let Some(mfc) = manifest_placement_for(ap.placement) else {
        return Ok(());
    };
    let support: ManifestPlacementSupport =
        manifest
            .placement
            .get(&mfc)
            .cloned()
            .unwrap_or(ManifestPlacementSupport {
                support: SupportState::Unavailable,
                max_bytes: None,
            });
    if support.support == SupportState::Unavailable {
        return Err(PlacementRejection::Unsupported {
            placement: ap.placement,
            manifest_support: support.support,
        });
    }
    if let Some(max) = support.max_bytes
        && byte_size > max
    {
        return Err(PlacementRejection::PayloadTooLarge {
            placement: ap.placement,
            byte_size,
            max_bytes: max,
        });
    }
    Ok(())
}

fn decide_placement(env: &PayloadEnvelope, manifest: &AdapterManifest) -> PayloadPlacementDecision {
    // Placement bodies are opaque; inline bodies are measured as
    // transported bytes so an under-reported `byte_size` cannot bypass
    // manifest limits.
    let byte_size = env.effective_byte_size();
    let mut rejected: Vec<PlacementRejection> = Vec::new();
    for (idx, ap) in env.acceptable_placements.iter().enumerate() {
        match evaluate_one(ap, byte_size, manifest) {
            Ok(()) => {
                return PayloadPlacementDecision::Chosen {
                    payload_id: env.payload_id.clone(),
                    payload_kind: env.payload_kind.clone(),
                    byte_size,
                    content_digest: env.content_digest.clone(),
                    chosen: ap.placement,
                    first_preference: idx == 0,
                    rejected,
                };
            }
            Err(rej) => rejected.push(rej),
        }
    }

    // No acceptable placement satisfied. Distinguish "nothing was
    // supported" from "everything was rejected for size".
    let all_size_failures = !rejected.is_empty()
        && rejected
            .iter()
            .all(|r| matches!(r, PlacementRejection::PayloadTooLarge { .. }));
    let failure_class = if all_size_failures {
        FailureClass::PayloadTooLarge
    } else {
        FailureClass::PlacementUnavailable
    };

    PayloadPlacementDecision::Failed {
        payload_id: env.payload_id.clone(),
        payload_kind: env.payload_kind.clone(),
        byte_size,
        content_digest: env.content_digest.clone(),
        failure_class,
        rejected,
    }
}
