//! `lifeloop host-hook` — synchronous host-hook broker.
//!
//! This command is intentionally narrow. It gives rendered host assets
//! a Lifeloop-owned command path, and for the opt-in `ccd-renewal`
//! profile it mediates CCD renewal through CCD's public CLI without
//! importing CCD state or token semantics into Lifeloop.

use std::fs;
use std::io::{ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

use lifeloop::protocol::{
    FrameAdmissionDirective, ProtocolAdapter, ProtocolPayload, RenderError, RenderRequest,
    render_hook_payload,
};
use lifeloop::router::{
    BuiltinAdapterRegistry, CallbackInvoker, SubprocessCallbackInvoker, SubprocessInvokerConfig,
    route,
};
use lifeloop::{
    AcceptablePlacement, CallbackRequest, CallbackResponse, FailureClass, FrameContext,
    IntegrationMode, LifecycleEventKind, LifecycleReceipt, PayloadEnvelope, PayloadReceipt,
    PlacementClass, PlacementOutcome, ReceiptStatus, RenewalAutomationState,
    RenewalAutomationStatus, RequirementLevel, RetryClass, SCHEMA_VERSION, lookup_manifest,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use super::host_context_status;
use super::renewal;
use super::{CliError, MAX_STDIN_BYTES};

const CLIENT_ID: &str = "ccd";
const CLIENT_CALLBACK_TIMEOUT_MS: u64 = 5_000;
const PROMPT_METADATA_MAX_BYTES: usize = 64 * 1024;
static RECEIPT_COUNTER: AtomicU64 = AtomicU64::new(0);

#[derive(Debug)]
struct HostHookArgs {
    path: PathBuf,
    state_dir: Option<PathBuf>,
    host: String,
    hook: String,
    client_cmd: Option<String>,
    client_profile: Option<String>,
    reset_path: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PendingRenewal {
    schema_version: String,
    adapter_id: String,
    reset_path: String,
    continuation_token: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    renewal_lease_id: Option<String>,
    thread_id: String,
    prepared_at_epoch_s: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    reset_prepare_receipt_path: Option<String>,
}

/// Entry point for `lifeloop host-hook`.
pub fn run<I: Iterator<Item = String>>(args: I, output: Option<&str>) -> Result<(), CliError> {
    let mut raw_args: Vec<String> = args.collect();
    let local_output = take_output_flag(&mut raw_args)?;
    let selected_output = match (output, local_output.as_deref()) {
        (Some(_), Some(_)) => {
            return Err(CliError::Usage(
                "host-hook: --output was provided more than once".into(),
            ));
        }
        (Some(value), None) | (None, Some(value)) => value,
        (None, None) => "hook-protocol",
    };

    match selected_output {
        "hook-protocol" => {}
        other => {
            return Err(CliError::Usage(format!(
                "host-hook: unsupported --output `{other}` (expected: hook-protocol)"
            )));
        }
    }

    let args = HostHookArgs::parse(raw_args.into_iter())?;
    let host_input = read_optional_json_stdin()?;
    let rendered = run_host_hook(&args, &host_input)?;
    println!(
        "{}",
        serde_json::to_string(&rendered).unwrap_or_else(|_| "{}".to_string())
    );
    Ok(())
}

impl HostHookArgs {
    fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Self, CliError> {
        let mut parsed = Self {
            path: PathBuf::from("."),
            state_dir: None,
            host: String::new(),
            hook: String::new(),
            client_cmd: None,
            client_profile: None,
            reset_path: "wrapper".to_string(),
        };

        while let Some(arg) = args.next() {
            match arg.as_str() {
                "--path" => parsed.path = PathBuf::from(require_value(&arg, args.next())?),
                "--state-dir" => {
                    parsed.state_dir = Some(PathBuf::from(require_value(&arg, args.next())?));
                }
                "--host" => parsed.host = require_value(&arg, args.next())?,
                "--hook" => parsed.hook = require_value(&arg, args.next())?,
                "--client-cmd" => parsed.client_cmd = Some(require_value(&arg, args.next())?),
                "--profile" => parsed.client_profile = Some(require_value(&arg, args.next())?),
                "--reset-path" => {
                    parsed.reset_path = require_value(&arg, args.next())?;
                    if !matches!(parsed.reset_path.as_str(), "native" | "wrapper" | "manual") {
                        return Err(CliError::Usage(
                            "host-hook: --reset-path must be native, wrapper, or manual".into(),
                        ));
                    }
                }
                other => {
                    return Err(CliError::Usage(format!(
                        "host-hook: unknown flag `{other}`"
                    )));
                }
            }
        }

        if parsed.host.is_empty() {
            return Err(CliError::Usage("host-hook: --host <id> is required".into()));
        }
        if parsed.hook.is_empty() {
            return Err(CliError::Usage(
                "host-hook: --hook <hook> is required".into(),
            ));
        }
        Ok(parsed)
    }

    fn state_dir(&self) -> PathBuf {
        self.state_dir
            .clone()
            .unwrap_or_else(|| renewal::default_state_dir(&self.path))
    }
}

fn run_host_hook(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
    match (args.host.as_str(), args.hook.as_str()) {
        ("codex", "on-agent-end") => codex_stop(args, host_input),
        ("codex", "on-session-start") => codex_session_start(args, host_input),
        ("claude" | "codex", "before-prompt-build") => host_before_prompt_build(args, host_input),
        (_, "on-compaction-notice") => host_on_compaction_notice(args, host_input),
        (_, "on-session-end") => host_on_session_end(args, host_input),
        _ => Ok(json!({})),
    }
}

fn host_before_prompt_build(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
    let Some(client_cmd) = args.client_cmd.as_deref() else {
        return Ok(json!({}));
    };
    let Some(prompt) = host_input
        .get("prompt")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
    else {
        return Ok(json!({}));
    };

    let request = frame_opening_request(args, host_input, prompt)?;
    let response = run_ccd_lifeloop_client(args, client_cmd, &request)?;
    reject_unaccepted_ccd_response(&response)?;
    Ok(json!({}))
}

fn frame_opening_request(
    args: &HostHookArgs,
    host_input: &Value,
    prompt: &str,
) -> Result<CallbackRequest, CliError> {
    let manifest = lookup_manifest(&args.host).ok_or_else(|| {
        CliError::Validation(format!(
            "host-hook: unknown adapter `{}` for prompt callback",
            args.host
        ))
    })?;
    let prompt = bounded_prompt_metadata(prompt)?;
    let event_id = format!("evt-host-hook-{}", unique_suffix());
    let invocation_id = format!("inv-host-hook-{}", unique_suffix());
    let frame_id = host_input
        .get("turn_id")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .map(str::to_owned)
        .unwrap_or_else(|| event_id.clone());
    let mut metadata = serde_json::Map::new();
    metadata.insert("prompt".to_owned(), Value::String(prompt.to_owned()));
    insert_host_metadata(
        &mut metadata,
        "hook_event_name",
        host_input
            .get("hook_event_name")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .or(Some("UserPromptSubmit")),
    );
    insert_host_metadata(
        &mut metadata,
        "transcript_path",
        host_input.get("transcript_path").and_then(Value::as_str),
    );
    insert_host_metadata(
        &mut metadata,
        "turn_id",
        host_input.get("turn_id").and_then(Value::as_str),
    );

    Ok(CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::FrameOpening,
        event_id,
        adapter_id: args.host.clone(),
        adapter_version: manifest.manifest.adapter_version,
        integration_mode: IntegrationMode::NativeHook,
        invocation_id,
        harness_session_id: host_input
            .get("session_id")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .map(str::to_owned),
        harness_run_id: host_input
            .get("run_id")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .map(str::to_owned),
        harness_task_id: None,
        frame_context: Some(FrameContext::top_level(frame_id)),
        capability_snapshot_ref: None,
        payload_refs: Vec::new(),
        sequence: None,
        idempotency_key: None,
        metadata,
    })
}

fn insert_host_metadata(
    metadata: &mut serde_json::Map<String, Value>,
    key: &str,
    value: Option<&str>,
) {
    if let Some(value) = value.filter(|value| !value.is_empty()) {
        metadata.insert(key.to_owned(), Value::String(value.to_owned()));
    }
}

fn bounded_prompt_metadata(prompt: &str) -> Result<&str, CliError> {
    if prompt.len() > PROMPT_METADATA_MAX_BYTES {
        return Err(CliError::Validation(format!(
            "host-hook: prompt metadata exceeds {PROMPT_METADATA_MAX_BYTES} bytes"
        )));
    }
    Ok(prompt)
}

fn run_ccd_lifeloop_client(
    args: &HostHookArgs,
    client_cmd: &str,
    request: &CallbackRequest,
) -> Result<CallbackResponse, CliError> {
    let registry = BuiltinAdapterRegistry;
    let plan = route(request, &registry).map_err(|err| {
        CliError::Validation(format!("host-hook: callback request rejected: {err}"))
    })?;
    let mut cmd_args = vec![
        "lifeloop".to_owned(),
        "client".to_owned(),
        "--path".to_owned(),
        args.path.display().to_string(),
    ];
    append_profile(args, &mut cmd_args);
    let config = SubprocessInvokerConfig::new(
        client_cmd,
        Duration::from_millis(CLIENT_CALLBACK_TIMEOUT_MS),
    )
    .args(cmd_args);
    let invoker = SubprocessCallbackInvoker::new(config);
    invoker
        .invoke(&plan, &[])
        .map_err(|err| CliError::Validation(format!("host-hook: CCD callback failed: {err}")))
}

fn reject_unaccepted_ccd_response(response: &CallbackResponse) -> Result<(), CliError> {
    if !matches!(
        response.status,
        ReceiptStatus::Failed | ReceiptStatus::Skipped
    ) {
        return Ok(());
    }

    let failure_class = response
        .failure_class
        .unwrap_or(FailureClass::InternalError);
    let reason = response
        .metadata
        .get("reason")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .unwrap_or(match response.status {
            ReceiptStatus::Failed => "status=failed",
            ReceiptStatus::Skipped => "status=skipped",
            _ => "status not accepted",
        });
    Err(CliError::Validation(format!(
        "host-hook: CCD callback did not accept prompt event: status={:?}, failure_class={failure_class:?}, reason={reason}",
        response.status
    )))
}

fn host_on_compaction_notice(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
    let session_id = host_input
        .get("session_id")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .ok_or_else(|| {
            CliError::Input("on-compaction-notice: host input missing required `session_id`".into())
        })?;
    let state_dir = args.state_dir();
    host_context_status::record_compaction(&state_dir, &args.host, session_id, epoch_s())?;

    let Some(client_cmd) = args.client_cmd.as_deref() else {
        return Ok(json!({}));
    };
    let Some(compaction) = compaction_lifecycle_event(host_input) else {
        return Ok(json!({}));
    };
    let request = compaction_request(args, host_input, compaction)?;
    let response = run_ccd_lifeloop_client(args, client_cmd, &request)?;
    reject_failed_ccd_response(&response, "compaction event")?;
    render_compaction_response(args, compaction.event, &response)
}

#[derive(Clone, Copy)]
struct CompactionEvent {
    event: LifecycleEventKind,
    source_hook: &'static str,
}

fn compaction_lifecycle_event(host_input: &Value) -> Option<CompactionEvent> {
    match host_input
        .get("hook_event_name")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .unwrap_or("PreCompact")
    {
        "PreCompact" => Some(CompactionEvent {
            event: LifecycleEventKind::ContextPressureObserved,
            source_hook: "PreCompact",
        }),
        "PostCompact" => Some(CompactionEvent {
            event: LifecycleEventKind::ContextCompacted,
            source_hook: "PostCompact",
        }),
        _ => None,
    }
}

fn compaction_request(
    args: &HostHookArgs,
    host_input: &Value,
    compaction: CompactionEvent,
) -> Result<CallbackRequest, CliError> {
    let manifest = lookup_manifest(&args.host).ok_or_else(|| {
        CliError::Validation(format!(
            "host-hook: unknown adapter `{}` for compaction callback",
            args.host
        ))
    })?;
    let event_id = format!("evt-host-hook-{}", unique_suffix());
    let invocation_id = format!("inv-host-hook-{}", unique_suffix());
    let mut metadata = serde_json::Map::new();
    insert_host_metadata(
        &mut metadata,
        "hook_event_name",
        Some(compaction.source_hook),
    );
    insert_host_metadata(&mut metadata, "source_hook", Some(compaction.source_hook));
    insert_host_metadata(
        &mut metadata,
        "trigger",
        host_input.get("trigger").and_then(Value::as_str),
    );
    if compaction.event == LifecycleEventKind::ContextPressureObserved {
        metadata.insert("compaction_signal".to_owned(), Value::Bool(true));
    }

    Ok(CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: compaction.event,
        event_id,
        adapter_id: args.host.clone(),
        adapter_version: manifest.manifest.adapter_version,
        integration_mode: IntegrationMode::NativeHook,
        invocation_id,
        harness_session_id: host_input
            .get("session_id")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .map(str::to_owned),
        harness_run_id: host_input
            .get("run_id")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .map(str::to_owned),
        harness_task_id: host_input
            .get("turn_id")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .map(str::to_owned),
        frame_context: None,
        capability_snapshot_ref: None,
        payload_refs: Vec::new(),
        sequence: None,
        idempotency_key: None,
        metadata,
    })
}

fn reject_failed_ccd_response(response: &CallbackResponse, label: &str) -> Result<(), CliError> {
    if !matches!(response.status, ReceiptStatus::Failed) {
        return Ok(());
    }
    let failure_class = response
        .failure_class
        .unwrap_or(FailureClass::InternalError);
    let reason = response
        .metadata
        .get("reason")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .unwrap_or("status=failed");
    Err(CliError::Validation(format!(
        "host-hook: CCD callback failed for {label}: failure_class={failure_class:?}, reason={reason}"
    )))
}

fn render_compaction_response(
    args: &HostHookArgs,
    event: LifecycleEventKind,
    response: &CallbackResponse,
) -> Result<Value, CliError> {
    if event != LifecycleEventKind::ContextCompacted || response.client_payloads.is_empty() {
        return Ok(json!({}));
    }
    let Some(adapter) = ProtocolAdapter::from_id(&args.host) else {
        return Ok(json!({}));
    };
    let manifest = lookup_manifest(&args.host).ok_or_else(|| {
        CliError::Validation(format!(
            "host-hook: unknown adapter `{}` for compaction response render",
            args.host
        ))
    })?;
    let payloads = response
        .client_payloads
        .iter()
        .filter_map(|payload| {
            payload
                .acceptable_placements
                .iter()
                .find(|placement| placement.placement == PlacementClass::SideChannelContext)
                .map(|placement| ProtocolPayload::new(payload, placement.placement))
        })
        .collect::<Vec<_>>();
    if payloads.is_empty() {
        return Ok(json!({}));
    }

    let req = RenderRequest {
        adapter,
        adapter_id: &args.host,
        adapter_version: &manifest.manifest.adapter_version,
        integration_mode: IntegrationMode::NativeHook,
        event,
        frame: None,
        payloads: &payloads,
        directive: None,
    };
    match render_hook_payload(&req) {
        Ok(rendered) => Ok(rendered.body),
        Err(RenderError::UnsupportedEvent { .. }) => Ok(json!({})),
        Err(err) => Err(CliError::Validation(format!(
            "host-hook: failed to render compaction response: {err}"
        ))),
    }
}

fn host_on_session_end(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
    let session_id = host_input
        .get("session_id")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .ok_or_else(|| {
            CliError::Input("on-session-end: host input missing required `session_id`".into())
        })?;
    let state_dir = args.state_dir();
    host_context_status::clear_for_session(&state_dir, session_id)?;
    Ok(json!({}))
}

fn codex_stop(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
    if host_input
        .get("stop_hook_active")
        .and_then(Value::as_bool)
        .unwrap_or(false)
    {
        return Ok(json!({}));
    }

    let Some(client_cmd) = args.client_cmd.as_deref() else {
        return Ok(json!({}));
    };

    let state_dir = args.state_dir();
    fs::create_dir_all(&state_dir).map_err(|err| {
        CliError::Input(format!(
            "host-hook: failed to create renewal state dir {}: {err}",
            state_dir.display()
        ))
    })?;

    let boundary = match run_ccd_json(
        client_cmd,
        ccd_start_boundary_args(args),
        "ccd start boundary probe",
    ) {
        Ok(boundary) => boundary,
        Err(err) => {
            write_failed_status(
                args,
                &state_dir,
                None,
                FailureClass::TransportError,
                RetryClass::SafeRetry,
                err.message(),
            )?;
            return Err(err);
        }
    };
    if boundary
        .pointer("/session_boundary/action")
        .and_then(Value::as_str)
        != Some("renew")
    {
        let mut status = renewal_status(
            args,
            RenewalAutomationState::NoRenewal,
            "boundary probe completed without a renewal action",
        );
        status.pending_token_present = pending_path(&state_dir).is_file();
        renewal::write_status(&state_dir, &status)?;
        return Ok(json!({}));
    }

    renewal::write_status(
        &state_dir,
        &renewal_status(
            args,
            RenewalAutomationState::PrepareStarted,
            "renewal boundary observed; reset prepare is starting",
        ),
    )?;

    let directive = FrameAdmissionDirective::block(
        "CCD renewal is prepared. Restart this Codex session through the Lifeloop CCD renewal profile; the continuation token is stored out of band.",
    );
    let block_payload = render_codex_hook(LifecycleEventKind::FrameEnded, &[], Some(&directive))?;

    let receipt_path = write_reset_prepare_receipt(args, host_input, &state_dir)?;
    let mut status = renewal_status(
        args,
        RenewalAutomationState::ReceiptWritten,
        "reset-prepare receipt written",
    );
    status.reset_prepare_receipt_path = Some(receipt_path.display().to_string());
    renewal::write_status(&state_dir, &status)?;

    let prepare = match run_ccd_json(
        client_cmd,
        ccd_prepare_args(args, &receipt_path),
        "ccd session renew prepare",
    ) {
        Ok(prepare) => prepare,
        Err(err) => {
            write_failed_status(
                args,
                &state_dir,
                None,
                FailureClass::TransportError,
                RetryClass::SafeRetry,
                err.message(),
            )?;
            return Err(err);
        }
    };
    let token = match required_json_string(
        &prepare,
        "/continuation/token",
        "host-hook: renewal continuation payload lost; CCD prepare did not return continuation.token",
    ) {
        Ok(token) => token,
        Err(err) => {
            write_failed_status(
                args,
                &state_dir,
                None,
                FailureClass::PayloadRejected,
                FailureClass::PayloadRejected.default_retry(),
                err.message(),
            )?;
            return Err(err);
        }
    };
    let thread_id = match required_json_string(
        &prepare,
        "/renewal/thread_id",
        "host-hook: CCD prepare did not return renewal.thread_id",
    ) {
        Ok(thread_id) => thread_id,
        Err(err) => {
            write_failed_status(
                args,
                &state_dir,
                None,
                FailureClass::IdentityUnavailable,
                FailureClass::IdentityUnavailable.default_retry(),
                err.message(),
            )?;
            return Err(err);
        }
    };

    let pending = PendingRenewal {
        schema_version: SCHEMA_VERSION.to_string(),
        adapter_id: args.host.clone(),
        reset_path: args.reset_path.clone(),
        continuation_token: token.to_string(),
        renewal_lease_id: prepare
            .pointer("/renewal/renewal_lease_id")
            .and_then(Value::as_str)
            .map(str::to_owned),
        thread_id: thread_id.to_string(),
        prepared_at_epoch_s: epoch_s(),
        reset_prepare_receipt_path: Some(receipt_path.display().to_string()),
    };
    renewal::write_status(
        &state_dir,
        &status_from_pending(
            args,
            &state_dir,
            &pending,
            RenewalAutomationState::LeaseCreated,
            "renewal lease created; continuation token is not yet committed",
        ),
    )?;
    if let Err(err) = write_pending(&state_dir, &pending) {
        write_failed_status(
            args,
            &state_dir,
            Some(&pending),
            FailureClass::StateConflict,
            FailureClass::StateConflict.default_retry(),
            err.message(),
        )?;
        return Err(err);
    }
    renewal::write_status(
        &state_dir,
        &status_from_pending(
            args,
            &state_dir,
            &pending,
            RenewalAutomationState::PendingContinuation,
            "renewal lease prepared; continuation token is stored out of band",
        ),
    )?;

    Ok(block_payload)
}

fn codex_session_start(args: &HostHookArgs, host_input: &Value) -> Result<Value, CliError> {
    // Codex's managed event table does not install on-session-end, so
    // on-session-start is the boundary at which stale host-context status
    // from a previous session must be cleared. Codex SessionStart fires
    // for several `source` values (`startup`, `resume`, `clear`, ...);
    // only `resume` of the same `session_id` should preserve the
    // compaction signal. For every other source — including `clear`,
    // which is itself a context-reset boundary — the status must be
    // cleared even if the `session_id` happens to match. Codex always
    // supplies `session_id` on SessionStart, so a missing/empty id is a
    // malformed payload.
    let session_id = host_input
        .get("session_id")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .ok_or_else(|| {
            CliError::Input(
                "codex on-session-start: host input missing required `session_id`".into(),
            )
        })?;
    let source = host_input.get("source").and_then(Value::as_str);
    let state_dir = args.state_dir();
    if source == Some("resume") {
        host_context_status::clear_if_session_changed(&state_dir, session_id)?;
    } else {
        host_context_status::clear_unconditional(&state_dir)?;
    }

    let Some(client_cmd) = args.client_cmd.as_deref() else {
        return Ok(json!({}));
    };
    let pending_path = pending_path(&state_dir);
    if !pending_path.is_file() {
        return Ok(json!({}));
    }
    let pending = match read_pending(&pending_path) {
        Ok(pending) => pending,
        Err(err) => {
            write_failed_status(
                args,
                &state_dir,
                None,
                FailureClass::StateConflict,
                FailureClass::StateConflict.default_retry(),
                err.message(),
            )?;
            return Err(err);
        }
    };
    if pending.adapter_id != args.host {
        let err = CliError::Validation(format!(
            "host-hook: pending renewal adapter `{}` does not match hook host `{}`",
            pending.adapter_id, args.host
        ));
        write_failed_status(
            args,
            &state_dir,
            Some(&pending),
            FailureClass::StateConflict,
            FailureClass::StateConflict.default_retry(),
            err.message(),
        )?;
        return Err(err);
    }
    if pending.thread_id.trim().is_empty() {
        let err =
            CliError::Validation("host-hook: pending renewal is missing thread binding".into());
        write_failed_status(
            args,
            &state_dir,
            Some(&pending),
            FailureClass::IdentityUnavailable,
            FailureClass::IdentityUnavailable.default_retry(),
            err.message(),
        )?;
        return Err(err);
    }

    let body = format!(
        "CCD renewal continuation restored for thread `{}`.",
        pending.thread_id
    );
    let envelope = PayloadEnvelope {
        schema_version: SCHEMA_VERSION.to_string(),
        payload_id: "pay-lifeloop-renewal-continuation".into(),
        client_id: "ccd".into(),
        payload_kind: "lifeloop.renewal.continuation".into(),
        format: "text/plain".into(),
        content_encoding: "identity".into(),
        byte_size: body.len() as u64,
        body: Some(body),
        body_ref: None,
        content_digest: None,
        acceptable_placements: vec![AcceptablePlacement {
            placement: PlacementClass::DeveloperEquivalentFrame,
            requirement: RequirementLevel::Required,
        }],
        idempotency_key: None,
        expires_at_epoch_s: None,
        redaction: None,
        metadata: serde_json::Map::new(),
    };
    let payloads = [ProtocolPayload::new(
        &envelope,
        PlacementClass::DeveloperEquivalentFrame,
    )];
    let rendered = render_codex_hook(LifecycleEventKind::SessionStarted, &payloads, None)?;

    let continuation = match run_ccd_json(
        client_cmd,
        ccd_start_continuation_args(args, &pending.continuation_token),
        "ccd start renewal continuation",
    ) {
        Ok(continuation) => continuation,
        Err(err) => {
            write_failed_status(
                args,
                &state_dir,
                Some(&pending),
                FailureClass::TransportError,
                RetryClass::SafeRetry,
                err.message(),
            )?;
            return Err(err);
        }
    };
    if let Err(err) = ensure_thread_binding(&pending, &continuation) {
        write_failed_status(
            args,
            &state_dir,
            Some(&pending),
            FailureClass::StateConflict,
            FailureClass::StateConflict.default_retry(),
            err.message(),
        )?;
        return Err(err);
    }
    if let Err(err) = fs::remove_file(&pending_path) {
        let cli_err = CliError::Input(format!(
            "host-hook: failed to clear pending renewal state {}: {err}",
            pending_path.display()
        ));
        write_failed_status(
            args,
            &state_dir,
            Some(&pending),
            FailureClass::StateConflict,
            FailureClass::StateConflict.default_retry(),
            cli_err.message(),
        )?;
        return Err(cli_err);
    }
    let mut status = status_from_pending(
        args,
        &state_dir,
        &pending,
        RenewalAutomationState::Fulfilled,
        "renewal continuation fulfilled and thread binding preserved",
    );
    status.pending_token_present = false;
    status.pending_path = None;
    status.fulfilled_at_epoch_s = Some(epoch_s());
    renewal::write_status(&state_dir, &status)?;

    Ok(rendered)
}

fn renewal_status(
    args: &HostHookArgs,
    state: RenewalAutomationState,
    message: &str,
) -> RenewalAutomationStatus {
    let mut status = renewal::base_status(state, &args.host, CLIENT_ID, epoch_s(), message);
    status.reset_path = Some(args.reset_path.clone());
    status
}

fn status_from_pending(
    args: &HostHookArgs,
    state_dir: &Path,
    pending: &PendingRenewal,
    state: RenewalAutomationState,
    message: &str,
) -> RenewalAutomationStatus {
    let mut status = renewal_status(args, state, message);
    status.reset_path = Some(pending.reset_path.clone());
    status.thread_id = Some(pending.thread_id.clone());
    status.renewal_lease_id = pending.renewal_lease_id.clone();
    status.prepared_at_epoch_s = Some(pending.prepared_at_epoch_s);
    status.pending_token_present = pending_path(state_dir).is_file();
    if status.pending_token_present {
        status.pending_path = Some(pending_path(state_dir).display().to_string());
    }
    status.reset_prepare_receipt_path = pending.reset_prepare_receipt_path.clone();
    status
}

fn write_failed_status(
    args: &HostHookArgs,
    state_dir: &Path,
    pending: Option<&PendingRenewal>,
    failure_class: FailureClass,
    retry_class: RetryClass,
    message: &str,
) -> Result<(), CliError> {
    let mut status = if let Some(pending) = pending {
        status_from_pending(
            args,
            state_dir,
            pending,
            RenewalAutomationState::Failed,
            message,
        )
    } else {
        renewal_status(args, RenewalAutomationState::Failed, message)
    };
    status.failure_class = Some(failure_class);
    status.retry_class = Some(retry_class);
    status.pending_token_present = pending_path(state_dir).is_file();
    if status.pending_token_present {
        status.pending_path = Some(pending_path(state_dir).display().to_string());
    }
    renewal::write_status(state_dir, &status)
}

fn render_codex_hook(
    event: LifecycleEventKind,
    payloads: &[ProtocolPayload<'_>],
    directive: Option<&FrameAdmissionDirective>,
) -> Result<Value, CliError> {
    let req = RenderRequest {
        adapter: ProtocolAdapter::Codex,
        adapter_id: "codex",
        adapter_version: "0.1.0",
        integration_mode: IntegrationMode::NativeHook,
        event,
        frame: None,
        payloads,
        directive,
    };
    render_hook_payload(&req)
        .map(|payload| payload.body)
        .map_err(|err| {
            CliError::Validation(format!("host-hook: failed to render Codex hook: {err}"))
        })
}

fn ccd_start_boundary_args(args: &HostHookArgs) -> Vec<String> {
    let mut cmd = vec![
        "--output".into(),
        "json".into(),
        "start".into(),
        "--refresh".into(),
        "--fields".into(),
        "command,ok,session_boundary,thread".into(),
        "--path".into(),
        args.path.display().to_string(),
    ];
    append_profile(args, &mut cmd);
    cmd
}

fn ccd_prepare_args(args: &HostHookArgs, receipt_path: &Path) -> Vec<String> {
    let mut cmd = vec![
        "--output".into(),
        "json".into(),
        "session".into(),
        "renew".into(),
        "prepare".into(),
        "--path".into(),
        args.path.display().to_string(),
        "--adapter".into(),
        args.host.clone(),
        "--reset-path".into(),
        args.reset_path.clone(),
        "--lifeloop-receipt".into(),
        receipt_path.display().to_string(),
    ];
    append_profile(args, &mut cmd);
    cmd
}

fn ccd_start_continuation_args(args: &HostHookArgs, token: &str) -> Vec<String> {
    let mut cmd = vec![
        "--output".into(),
        "json".into(),
        "start".into(),
        "--refresh".into(),
        "--continuation".into(),
        token.to_string(),
        "--fields".into(),
        "command,ok,activation,thread".into(),
        "--path".into(),
        args.path.display().to_string(),
    ];
    append_profile(args, &mut cmd);
    cmd
}

fn append_profile(args: &HostHookArgs, cmd: &mut Vec<String>) {
    if let Some(profile) = &args.client_profile {
        cmd.push("--profile".into());
        cmd.push(profile.clone());
    }
}

fn run_ccd_json(program: &str, args: Vec<String>, label: &str) -> Result<Value, CliError> {
    let output = Command::new(program)
        .args(&args)
        .output()
        .map_err(|err| CliError::Input(format!("host-hook: failed to spawn {label}: {err}")))?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(CliError::Validation(format!(
            "host-hook: {label} failed: {}",
            stderr.trim()
        )));
    }
    let value: Value = serde_json::from_slice(&output.stdout).map_err(|err| {
        CliError::Input(format!("host-hook: {label} returned invalid JSON: {err}"))
    })?;
    if value.get("ok").and_then(Value::as_bool) == Some(false) {
        let detail = value
            .get("error")
            .or_else(|| value.get("message"))
            .and_then(Value::as_str)
            .unwrap_or("ok=false");
        return Err(CliError::Validation(format!(
            "host-hook: {label} failed: {detail}"
        )));
    }
    Ok(value)
}

fn ensure_thread_binding(pending: &PendingRenewal, continuation: &Value) -> Result<(), CliError> {
    if continuation
        .pointer("/thread/active_session_matches")
        .and_then(Value::as_bool)
        != Some(true)
    {
        return Err(CliError::Validation(
            "host-hook: CCD renewal continuation did not preserve active thread binding".into(),
        ));
    }

    let expected = pending.thread_id.as_str();
    let actual = continuation
        .pointer("/thread/thread_id")
        .or_else(|| continuation.pointer("/activation/thread_id"))
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| {
            CliError::Validation(
                "host-hook: CCD renewal continuation did not report a thread binding".into(),
            )
        })?;
    if actual != expected {
        return Err(CliError::Validation(format!(
            "host-hook: CCD renewal continuation changed thread binding from `{expected}` to `{actual}`"
        )));
    }

    Ok(())
}

fn write_reset_prepare_receipt(
    args: &HostHookArgs,
    host_input: &Value,
    state_dir: &Path,
) -> Result<PathBuf, CliError> {
    let now = epoch_s();
    let id = unique_suffix();
    let payload_id = format!("pay-renewal-reset-prepare-{id}");
    let receipt = LifecycleReceipt {
        schema_version: SCHEMA_VERSION.to_string(),
        receipt_id: format!("lfr-renewal-reset-prepare-{id}"),
        idempotency_key: Some(format!("idem-renewal-reset-prepare-{id}")),
        client_id: "ccd".into(),
        adapter_id: args.host.clone(),
        invocation_id: format!("inv-renewal-reset-prepare-{id}"),
        event: LifecycleEventKind::SessionEnding,
        event_id: format!("evt-renewal-reset-prepare-{id}"),
        sequence: None,
        parent_receipt_id: None,
        integration_mode: IntegrationMode::NativeHook,
        status: ReceiptStatus::Observed,
        at_epoch_s: now,
        harness_session_id: host_input
            .get("session_id")
            .and_then(Value::as_str)
            .map(str::to_owned),
        harness_run_id: host_input
            .get("run_id")
            .and_then(Value::as_str)
            .map(str::to_owned),
        harness_task_id: host_input
            .get("turn_id")
            .and_then(Value::as_str)
            .map(str::to_owned),
        payload_receipts: vec![PayloadReceipt {
            payload_id,
            payload_kind: "lifeloop.renewal.reset_prepare".into(),
            placement: PlacementClass::ReceiptOnly,
            status: PlacementOutcome::Delivered,
            byte_size: 0,
            content_digest: None,
        }],
        telemetry_summary: serde_json::Map::new(),
        capability_degradations: Vec::new(),
        failure_class: None,
        retry_class: None,
        warnings: Vec::new(),
    };
    receipt.validate().map_err(|err| {
        CliError::Validation(format!("host-hook: generated invalid receipt: {err}"))
    })?;

    let path = state_dir.join(format!("reset-prepare-{id}.json"));
    let json = serde_json::to_string_pretty(&receipt)
        .map_err(|err| CliError::Input(format!("host-hook: failed to serialize receipt: {err}")))?;
    fs::write(&path, json).map_err(|err| {
        CliError::Input(format!(
            "host-hook: failed to write reset-prepare receipt {}: {err}",
            path.display()
        ))
    })?;
    Ok(path)
}

fn write_pending(state_dir: &Path, pending: &PendingRenewal) -> Result<(), CliError> {
    let path = pending_path(state_dir);
    if path.exists() {
        return Err(CliError::Validation(format!(
            "host-hook: pending renewal already exists at {}; refusing to overwrite continuation token",
            path.display()
        )));
    }

    let json = serde_json::to_string_pretty(pending).map_err(|err| {
        CliError::Input(format!(
            "host-hook: failed to serialize pending renewal: {err}"
        ))
    })?;
    let pending_file_name = pending_file_name();
    let temp_path = state_dir.join(format!("{pending_file_name}.{}.tmp", unique_suffix()));
    let mut open_options = fs::OpenOptions::new();
    open_options.write(true).create_new(true);
    #[cfg(unix)]
    open_options.mode(0o600);
    let mut file = open_options.open(&temp_path).map_err(|err| {
        CliError::Input(format!(
            "host-hook: failed to create pending renewal temp file {}: {err}",
            temp_path.display()
        ))
    })?;
    if let Err(err) = file.write_all(json.as_bytes()) {
        let _ = fs::remove_file(&temp_path);
        return Err(CliError::Input(format!(
            "host-hook: failed to write pending renewal temp file {}: {err}",
            temp_path.display()
        )));
    }
    if let Err(err) = file.sync_all() {
        let _ = fs::remove_file(&temp_path);
        return Err(CliError::Input(format!(
            "host-hook: failed to sync pending renewal temp file {}: {err}",
            temp_path.display()
        )));
    }
    drop(file);

    match fs::hard_link(&temp_path, &path) {
        Ok(()) => {
            let _ = fs::remove_file(&temp_path);
            Ok(())
        }
        Err(err) if err.kind() == ErrorKind::AlreadyExists => {
            let _ = fs::remove_file(&temp_path);
            Err(CliError::Validation(format!(
                "host-hook: pending renewal already exists at {}; refusing to overwrite continuation token",
                path.display()
            )))
        }
        Err(err) => {
            let _ = fs::remove_file(&temp_path);
            Err(CliError::Input(format!(
                "host-hook: failed to commit pending renewal {}: {err}",
                path.display()
            )))
        }
    }
}

fn read_pending(path: &Path) -> Result<PendingRenewal, CliError> {
    let raw = fs::read_to_string(path).map_err(|err| {
        CliError::Input(format!(
            "host-hook: failed to read pending renewal {}: {err}",
            path.display()
        ))
    })?;
    serde_json::from_str(&raw).map_err(|err| {
        CliError::Input(format!(
            "host-hook: invalid pending renewal {}: {err}",
            path.display()
        ))
    })
}

fn pending_path(state_dir: &Path) -> PathBuf {
    renewal::pending_path(state_dir, CLIENT_ID)
        .expect("static host-hook client id must produce a safe pending file name")
}

fn pending_file_name() -> String {
    renewal::pending_file_name(CLIENT_ID)
        .expect("static host-hook client id must produce a safe pending file name")
}

fn read_optional_json_stdin() -> Result<Value, CliError> {
    let mut buf = Vec::new();
    std::io::stdin()
        .lock()
        .take(MAX_STDIN_BYTES + 1)
        .read_to_end(&mut buf)
        .map_err(|err| CliError::Input(format!("host-hook: failed to read stdin: {err}")))?;
    if buf.len() as u64 > MAX_STDIN_BYTES {
        return Err(CliError::Input(format!(
            "host-hook: stdin exceeds {MAX_STDIN_BYTES} bytes"
        )));
    }
    if buf.iter().all(|b| b.is_ascii_whitespace()) {
        return Ok(json!({}));
    }
    serde_json::from_slice(&buf)
        .map_err(|err| CliError::Input(format!("host-hook: invalid hook JSON on stdin: {err}")))
}

fn epoch_s() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

fn unique_suffix() -> String {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let seq = RECEIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
    format!("{}-{nanos}-{seq}", std::process::id())
}

fn required_json_string<'a>(
    value: &'a Value,
    pointer: &str,
    message: &str,
) -> Result<&'a str, CliError> {
    value
        .pointer(pointer)
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| CliError::Validation(message.into()))
}

fn take_output_flag(args: &mut Vec<String>) -> Result<Option<String>, CliError> {
    let mut found = None;
    let mut index = 0;
    while index < args.len() {
        if args[index] == "--output" {
            if index + 1 >= args.len() {
                return Err(CliError::Usage("flag `--output` requires a value".into()));
            }
            let value = args.remove(index + 1);
            args.remove(index);
            if found.replace(value).is_some() {
                return Err(CliError::Usage(
                    "host-hook: --output was provided more than once".into(),
                ));
            }
            continue;
        }
        if let Some(value) = args[index].strip_prefix("--output=") {
            let value = value.to_string();
            args.remove(index);
            if found.replace(value).is_some() {
                return Err(CliError::Usage(
                    "host-hook: --output was provided more than once".into(),
                ));
            }
            continue;
        }
        index += 1;
    }
    Ok(found)
}

fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
    value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}
