//! Hook-protocol payload rendering for harness adapters.
//!
//! Lifeloop owns the translation from neutral lifecycle inputs (event kind,
//! adapter identity, integration mode, frame context, opaque payload
//! envelopes, placement classes, frame-admission directives) into the
//! adapter-shaped JSON the harness's hook protocol consumes on stdout.
//!
//! # Boundary (issue #3)
//!
//! This module owns:
//! * mapping a [`LifecycleEventKind`] plus an adapter id to the harness's
//!   own hook event name (e.g. Claude's `SessionStart`, Codex's
//!   `UserPromptSubmit`),
//! * rendering the per-event JSON payload with optional contextual
//!   payloads and frame-admission directives,
//! * the neutral [`FrameAdmissionDirective`] vocabulary that lets a client
//!   tell a harness "block the next input" or "request a continuation"
//!   without naming any client-specific session policy.
//!
//! This module does **not** own:
//! * the meaning of those directives (a client decides when to block,
//!   when to allow);
//! * memory, recall, promotion, compaction, or any other client
//!   continuity vocabulary;
//! * receipt emission (callers wrap the rendered payload in their own
//!   receipt flow);
//! * filesystem IO, hook registration, or asset installation (issue #4
//!   `host_assets` owns that),
//! * adapter manifest negotiation (issue #6).
//!
//! # Compatibility labels
//!
//! Hook event names like `"SessionStart"`, `"UserPromptSubmit"`,
//! `"PreCompact"`, `"Stop"`, `"SessionEnd"` are **harness-defined wire
//! tokens**, not Lifeloop semantics. They appear here only because the
//! harness's hook protocol contract requires them as the
//! `hookSpecificOutput.hookEventName` value. They are documented here
//! analogously to the `CCD_COMPAT_*` pattern in [`crate::host_assets`]:
//! external-vocabulary tokens grouped in one auditable place.
//!
//! # Format-agnostic rendering
//!
//! The payload body is produced as `serde_json::Value` because the
//! current harness hook protocols (Claude Code, Codex) consume JSON on
//! stdout. The renderer entry point also exposes a string form
//! ([`RenderedHookPayload::body_string`]) for direct stdout emission.

use crate::{FrameContext, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PlacementClass};
use serde::{Deserialize, Serialize};
use serde_json::Value;

mod claude;
mod codex;

pub use claude::{ClaudeHookEvent, claude_hook_event_for};
pub use codex::{CodexHookEvent, codex_hook_event_for};

// ============================================================================
// Neutral inputs
// ============================================================================

/// Adapter targeted by a render. Renderer dispatch is keyed on this.
///
/// The wire string mirrors the canonical adapter id used elsewhere
/// (`AdapterManifest::adapter_id`, [`crate::host_assets::HostAdapter`]).
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolAdapter {
    Claude,
    Codex,
}

impl ProtocolAdapter {
    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Codex => "codex",
        }
    }

    /// Recognizes the canonical adapter id; returns `None` for unknown
    /// names. Aliases used by host-asset rendering (e.g. `claude-code`)
    /// are accepted so callers can pass through whatever the harness
    /// reports.
    pub fn from_id(value: &str) -> Option<Self> {
        match value {
            "claude" | "claude-code" => Some(Self::Claude),
            "codex" => Some(Self::Codex),
            _ => None,
        }
    }
}

/// Neutral directive a client can attach to a render request to ask the
/// harness to admit, block, or request continuation of the next
/// lifecycle moment.
///
/// This vocabulary is intentionally policy-free: it carries the
/// transport intent (allow/block) and a free-form `reason` the harness
/// surfaces to the model. Clients decide *why* to block; Lifeloop only
/// transports the directive.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FrameAdmissionDirective {
    /// Default: do nothing, let the harness proceed.
    Allow,
    /// Ask the harness to block the next input/turn and surface `reason`
    /// to the model. On harnesses where `block` means "request a
    /// continuation prompt" (Codex `Stop`), the same shape applies.
    Block { reason: String },
}

impl FrameAdmissionDirective {
    pub fn allow() -> Self {
        Self::Allow
    }

    pub fn block(reason: impl Into<String>) -> Self {
        Self::Block {
            reason: reason.into(),
        }
    }

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

/// One opaque contextual payload to render into the harness's
/// per-event payload slot (e.g. Claude's `additionalContext`).
///
/// Lifeloop does not parse or rewrite the payload body — it is
/// delivered as the client supplied it. The `placement` field is the
/// trust-neutral routing class the client requested for this payload;
/// the renderer uses it only to decide which payload slot the body goes
/// into and to skip payloads whose placement is not appropriate for
/// the target event.
#[derive(Clone, Debug)]
pub struct ProtocolPayload<'a> {
    pub envelope: &'a PayloadEnvelope,
    pub placement: PlacementClass,
}

impl<'a> ProtocolPayload<'a> {
    pub fn new(envelope: &'a PayloadEnvelope, placement: PlacementClass) -> Self {
        Self {
            envelope,
            placement,
        }
    }
}

/// Inputs for a hook-protocol render.
///
/// `adapter_id` and `adapter_version` are passed as neutral strings so
/// the renderer can be invoked without a manifest registry lookup;
/// callers that have a [`crate::AdapterManifest`] in hand can pass its
/// fields directly. `integration_mode` and `frame` are accepted but
/// currently advisory — they enable later renderer variants without
/// signature churn.
#[derive(Clone, Debug)]
pub struct RenderRequest<'a> {
    pub adapter: ProtocolAdapter,
    pub adapter_id: &'a str,
    pub adapter_version: &'a str,
    pub integration_mode: IntegrationMode,
    pub event: LifecycleEventKind,
    pub frame: Option<&'a FrameContext>,
    pub payloads: &'a [ProtocolPayload<'a>],
    pub directive: Option<&'a FrameAdmissionDirective>,
}

impl<'a> RenderRequest<'a> {
    /// Convenience constructor for the common no-payload, no-directive case.
    pub fn minimal(
        adapter: ProtocolAdapter,
        adapter_id: &'a str,
        adapter_version: &'a str,
        integration_mode: IntegrationMode,
        event: LifecycleEventKind,
    ) -> Self {
        Self {
            adapter,
            adapter_id,
            adapter_version,
            integration_mode,
            event,
            frame: None,
            payloads: &[],
            directive: None,
        }
    }
}

// ============================================================================
// Output
// ============================================================================

/// A rendered harness hook-protocol payload, ready to be emitted on
/// the harness's hook stdout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderedHookPayload {
    /// The harness's wire token for the event (e.g. Claude's
    /// `SessionStart`). May be `None` for events the adapter does not
    /// surface a hook payload for under its own protocol.
    pub hook_event_name: Option<&'static str>,
    /// The JSON payload body the harness expects on stdout. For events
    /// the adapter does not carry, this is `{}` (the harness's
    /// quiet-default contract).
    pub body: Value,
}

impl RenderedHookPayload {
    /// Compact JSON suitable for emission on stdout.
    pub fn body_string(&self) -> String {
        serde_json::to_string(&self.body).unwrap_or_else(|_| "{}".to_string())
    }
}

// ============================================================================
// Render errors
// ============================================================================

/// Why a hook-protocol render failed.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
pub enum RenderError {
    /// The adapter does not surface a hook payload for the requested
    /// lifecycle event under its native hook protocol.
    UnsupportedEvent {
        adapter: ProtocolAdapter,
        event: LifecycleEventKind,
    },
    /// The adapter id passed in does not match the requested adapter.
    /// Renderer is paranoid about this so a client passing the wrong
    /// adapter manifest cannot silently produce a Claude payload while
    /// claiming Codex.
    AdapterIdMismatch {
        adapter: ProtocolAdapter,
        adapter_id: String,
    },
    /// The frame-admission directive is invalid (e.g. a `Block` with an
    /// empty `reason`).
    InvalidDirective(String),
}

impl std::fmt::Display for RenderError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::UnsupportedEvent { adapter, event } => {
                write!(
                    f,
                    "adapter `{}` does not surface a hook payload for lifecycle event `{:?}`",
                    adapter.as_str(),
                    event
                )
            }
            Self::AdapterIdMismatch {
                adapter,
                adapter_id,
            } => write!(
                f,
                "adapter id `{}` does not match requested adapter `{}`",
                adapter_id,
                adapter.as_str()
            ),
            Self::InvalidDirective(msg) => write!(f, "invalid frame-admission directive: {msg}"),
        }
    }
}

impl std::error::Error for RenderError {}

// ============================================================================
// Entry point
// ============================================================================

/// Render a harness hook-protocol payload from neutral lifecycle inputs.
///
/// Dispatches on [`RenderRequest::adapter`]. Returns
/// [`RenderError::UnsupportedEvent`] when the adapter's hook protocol
/// does not surface a payload for the requested
/// [`LifecycleEventKind`]; callers in routing layers may treat that as
/// "skip emission" rather than an error. The future router (issue #7)
/// is the expected primary caller of this entry point.
pub fn render_hook_payload(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
    if let Some(d) = req.directive {
        validate_directive(d)?;
    }
    if !matches_adapter_id(req.adapter, req.adapter_id) {
        return Err(RenderError::AdapterIdMismatch {
            adapter: req.adapter,
            adapter_id: req.adapter_id.to_string(),
        });
    }
    match req.adapter {
        ProtocolAdapter::Claude => claude::render(req),
        ProtocolAdapter::Codex => codex::render(req),
    }
}

fn validate_directive(d: &FrameAdmissionDirective) -> Result<(), RenderError> {
    if let FrameAdmissionDirective::Block { reason } = d
        && reason.trim().is_empty()
    {
        return Err(RenderError::InvalidDirective(
            "Block directive requires a non-empty reason".into(),
        ));
    }
    Ok(())
}

fn matches_adapter_id(adapter: ProtocolAdapter, id: &str) -> bool {
    ProtocolAdapter::from_id(id) == Some(adapter)
}

// ============================================================================
// Shared rendering helpers (used by claude.rs and codex.rs)
// ============================================================================

/// The set of payload placements that flow into the per-event
/// "additional context" slot the Claude/Codex hook protocols expose
/// inside `hookSpecificOutput.additionalContext`.
///
/// This is a routing decision local to the renderer: payloads whose
/// placement is one of these classes are concatenated into the
/// additional-context slot; others are ignored by the renderer (they
/// flow through other Lifeloop transports).
pub(crate) fn placement_targets_pre_prompt(p: PlacementClass) -> bool {
    matches!(
        p,
        PlacementClass::PrePromptFrame | PlacementClass::DeveloperEquivalentFrame
    )
}

pub(crate) fn placement_targets_side_channel(p: PlacementClass) -> bool {
    matches!(p, PlacementClass::SideChannelContext)
}

/// Build the harness `additionalContext` string from the eligible
/// payloads in the request.
///
/// # Lifeloop transport envelope
///
/// For harness hook protocols (Claude Code, Codex) that surface a
/// single `hookSpecificOutput.additionalContext` slot per event,
/// Lifeloop renders a transport envelope of the form:
///
/// ```json
/// {
///   "payloads": [
///     { "payload_id": "...", "payload_kind": "...", "body": "<verbatim string>" },
///     { "payload_id": "...", "payload_kind": "...", "body_ref": "..." }
///   ]
/// }
/// ```
///
/// Each payload whose [`PlacementClass`] passes
/// [`placement_targets_pre_prompt`] becomes exactly one object in the
/// `payloads` array, in input order.
///
/// # Body opacity (issue #21, spec body.md line 385)
///
/// `body` and `body_ref` are passed through verbatim:
///
/// * If [`PayloadEnvelope::body`] is `Some(s)`, it is emitted as a
///   JSON **string** under the key `body`. Lifeloop never calls
///   `serde_json::from_str` on it — a body that happens to be a JSON
///   object literal is carried as a string, preserving opacity and
///   keeping overlapping JSON keys across payloads distinguishable.
/// * Else if [`PayloadEnvelope::body_ref`] is `Some(r)`, it is emitted
///   under the key `body_ref` and is never dereferenced here.
/// * If both are `None`, the payload is skipped entirely (no eligible
///   body to transport).
///
/// `payloads` is the only Lifeloop-reserved key in the wrapper.
///
/// If no eligible payloads are present, returns the literal `"{}"` to
/// match the harness's quiet-default empty-context shape.
pub(crate) fn render_additional_context(payloads: &[ProtocolPayload<'_>]) -> String {
    render_context_payloads(payloads, placement_targets_pre_prompt)
}

pub(crate) fn render_side_channel_context(payloads: &[ProtocolPayload<'_>]) -> String {
    render_context_payloads(payloads, placement_targets_side_channel)
}

fn render_context_payloads(
    payloads: &[ProtocolPayload<'_>],
    eligible: fn(PlacementClass) -> bool,
) -> String {
    let mut entries: Vec<Value> = Vec::new();

    for p in payloads {
        if !eligible(p.placement) {
            continue;
        }
        let mut entry = serde_json::Map::new();
        entry.insert(
            "payload_id".to_string(),
            Value::String(p.envelope.payload_id.clone()),
        );
        entry.insert(
            "payload_kind".to_string(),
            Value::String(p.envelope.payload_kind.clone()),
        );
        match (&p.envelope.body, &p.envelope.body_ref) {
            (Some(b), _) => {
                // Verbatim body string. Never parsed — preserves opacity
                // and keeps overlapping keys across payloads distinguishable.
                entry.insert("body".to_string(), Value::String(b.clone()));
            }
            (None, Some(r)) => {
                entry.insert("body_ref".to_string(), Value::String(r.clone()));
            }
            (None, None) => continue,
        }
        entries.push(Value::Object(entry));
    }

    if entries.is_empty() {
        return "{}".to_string();
    }

    let mut wrapper = serde_json::Map::new();
    wrapper.insert("payloads".to_string(), Value::Array(entries));
    serde_json::to_string_pretty(&Value::Object(wrapper)).unwrap_or_else(|_| "{}".to_string())
}
