use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::ExitCode;

use anyhow::{bail, Context, Result};
use serde::Serialize;
use serde_json::json;

use super::attach;
use crate::output::CommandReport;
use crate::paths::{state::StateLayout, write};
use crate::profile;
use crate::repo::marker;

mod claude_protocol;
mod settings_merge;

pub(crate) use claude_protocol::{
    build_hook_protocol_payload, claude_event_for_hook, HookProtocolInputs,
};

const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";
const CODEX_SOURCE_README: &str = ".ccd-hosts/codex/README.md";
const CODEX_SOURCE_LAUNCHER: &str = ".ccd-hosts/codex/launcher.sh";
const CODEX_TARGET_LAUNCHER: &str = ".codex/ccd-launch.sh";
const OPENCLAW_SOURCE_ADAPTER: &str = ".ccd-hosts/openclaw/adapter.json";
const OPENCLAW_TARGET_ADAPTER: &str = ".openclaw/ccd.json";
const HERMES_SOURCE_ADAPTER: &str = ".ccd-hosts/hermes/adapter.json";
const HERMES_TARGET_ADAPTER: &str = ".hermes/ccd.json";

#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ManagedHost {
    Claude,
    Codex,
    Hermes,
    OpenClaw,
}

impl ManagedHost {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Codex => "codex",
            Self::Hermes => "hermes",
            Self::OpenClaw => "openclaw",
        }
    }

    pub(crate) fn all() -> [Self; 4] {
        [Self::Claude, Self::Codex, Self::Hermes, Self::OpenClaw]
    }

    pub(crate) fn from_str_lower(name: &str) -> Option<Self> {
        match name {
            "claude" | "claude-code" => Some(Self::Claude),
            "codex" => Some(Self::Codex),
            "hermes" => Some(Self::Hermes),
            "openclaw" => Some(Self::OpenClaw),
            _ => None,
        }
    }

    pub(crate) fn default_mode(self) -> HostIntegrationMode {
        match self {
            Self::Claude => HostIntegrationMode::NativeHook,
            Self::Codex => HostIntegrationMode::ManualSkill,
            Self::Hermes | Self::OpenClaw => HostIntegrationMode::ReferenceAdapter,
        }
    }

    fn from_config_key(key: &str) -> Option<Self> {
        Self::from_str_lower(key)
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum HostIntegrationMode {
    ManualSkill,
    LauncherWrapper,
    NativeHook,
    ReferenceAdapter,
}

impl HostIntegrationMode {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::ManualSkill => "manual_skill",
            Self::LauncherWrapper => "launcher_wrapper",
            Self::NativeHook => "native_hook",
            Self::ReferenceAdapter => "reference_adapter",
        }
    }

    fn from_config(value: Option<&str>, host: ManagedHost) -> Self {
        match value {
            Some("manual_skill") => Self::ManualSkill,
            Some("launcher_wrapper") => Self::LauncherWrapper,
            Some("native_hook") => Self::NativeHook,
            Some("reference_adapter") => Self::ReferenceAdapter,
            _ => host.default_mode(),
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum FileAction {
    Installed,
    Updated,
    AlreadyPresent,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum IntegrationAssetStatus {
    Present,
    Missing,
    Drifted,
    InvalidMode,
    NotApplicable,
}

#[derive(Clone, Debug)]
struct RenderedAsset {
    relative_path: &'static str,
    contents: String,
    mode: Option<u32>,
}

#[derive(Serialize)]
pub struct InitReport {
    command: &'static str,
    ok: bool,
    path: String,
    profile: String,
    locality_id: String,
    attach: attach::AttachReport,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    host_scaffold: Vec<HostMutationView>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    next_steps: Vec<String>,
}

impl CommandReport for InitReport {
    fn exit_code(&self) -> ExitCode {
        ExitCode::SUCCESS
    }

    fn render_text(&self) {
        self.attach.render_text();
        if self.host_scaffold.is_empty() {
            return;
        }

        println!();
        println!("Host scaffolding:");
        for item in &self.host_scaffold {
            println!(
                "- {} ({}) -> {}",
                item.host,
                item.mode,
                match item.action {
                    FileAction::Installed => "installed",
                    FileAction::Updated => "updated",
                    FileAction::AlreadyPresent => "already present",
                }
            );
        }

        if self.next_steps.is_empty() {
            return;
        }

        println!();
        println!("Next steps:");
        for (index, step) in self.next_steps.iter().enumerate() {
            println!("{}. {step}", index + 1);
        }
    }
}

#[derive(Serialize)]
pub struct InstallReport {
    command: &'static str,
    ok: bool,
    path: String,
    profile: String,
    host: String,
    mode: String,
    action: FileAction,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    applied_paths: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    next_steps: Vec<String>,
}

impl CommandReport for InstallReport {
    fn exit_code(&self) -> ExitCode {
        ExitCode::SUCCESS
    }

    fn render_text(&self) {
        let action = match self.action {
            FileAction::Installed => "Installed",
            FileAction::Updated => "Updated",
            FileAction::AlreadyPresent => "Validated",
        };
        println!(
            "{action} {} runtime integration in {} mode.",
            self.host, self.mode
        );
        for path in &self.applied_paths {
            println!("Applied: {path}");
        }
        if self.next_steps.is_empty() {
            return;
        }

        println!();
        println!("Next steps:");
        for (index, step) in self.next_steps.iter().enumerate() {
            println!("{}. {step}", index + 1);
        }
    }
}

#[derive(Clone, Serialize)]
pub struct HostIntegrationStatus {
    pub host: String,
    pub mode: String,
    pub scaffold_status: IntegrationAssetStatus,
    pub install_status: IntegrationAssetStatus,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub source_paths: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub applied_paths: Vec<String>,
}

#[derive(Serialize)]
struct HostMutationView {
    host: String,
    mode: String,
    action: FileAction,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    source_paths: Vec<String>,
}

pub(crate) fn detect_applied_hosts(repo_root: &Path) -> Vec<ManagedHost> {
    let mut found = Vec::new();
    if repo_root.join(".claude").is_dir() {
        found.push(ManagedHost::Claude);
    }
    if repo_root.join(".codex").is_dir() {
        found.push(ManagedHost::Codex);
    }
    if repo_root.join(".hermes").is_dir() {
        found.push(ManagedHost::Hermes);
    }
    if repo_root.join(".openclaw").is_dir() {
        found.push(ManagedHost::OpenClaw);
    }
    found
}

#[derive(Clone, Debug)]
#[allow(dead_code)]
struct ApplyResult {
    host: ManagedHost,
    mode: HostIntegrationMode,
    action: FileAction,
    applied_paths: Vec<String>,
    launcher_applied: bool,
}

pub fn init(
    repo_root: &Path,
    explicit_profile: Option<&str>,
    explicit_locality_id: Option<&str>,
    display_name: Option<&str>,
    force: bool,
) -> Result<InitReport> {
    let attach_report = attach::run(
        repo_root,
        explicit_profile,
        explicit_locality_id,
        display_name,
    )?;
    let profile_name = profile::resolve(Some(attach_report.profile_name()))?;
    let layout = StateLayout::resolve(repo_root, profile_name)?;

    // 1-3) Scaffold sources into .ccd-hosts/ for ALL managed hosts.
    let mut host_scaffold = Vec::new();
    for host in ManagedHost::all() {
        let mode = host.default_mode();
        let result = scaffold_host(
            repo_root,
            &layout,
            attach_report.locality_id(),
            host,
            mode,
            force,
        )?;
        host_scaffold.push(result);
    }

    // 4) Determine the applied-host list.
    let marker = marker::load(repo_root)?.ok_or_else(|| {
        anyhow::anyhow!(
            "no CCD marker found after attach at {}; this indicates an internal inconsistency",
            repo_root.display()
        )
    })?;

    let applied_hosts: Vec<ManagedHost> = if let Some(list) = marker.hosts_applied.clone() {
        list.iter()
            .filter_map(|name| ManagedHost::from_str_lower(name))
            .collect()
    } else {
        let detected = detect_applied_hosts(repo_root);
        // Only persist when at least one host is detected. Persisting an empty list would
        // lock the project into "no hosts" forever — the user would have to hand-edit
        // .ccd.toml to recover. Keeping hosts_applied = None means subsequent runs
        // continue to auto-detect until at least one host directory exists.
        if !detected.is_empty() {
            let names: Vec<String> = detected.iter().map(|h| h.as_str().to_owned()).collect();
            let new_marker = marker.with_hosts_applied(Some(names))?;
            marker::write(repo_root, &new_marker)?;
        }
        detected
    };

    // 5) Apply non-destructive integrations for each applied host.
    let mut apply_results: Vec<ApplyResult> = Vec::new();
    for host in &applied_hosts {
        let result = apply_host_integration(
            repo_root,
            &layout,
            attach_report.locality_id(),
            *host,
            force,
            false,
        )?;
        apply_results.push(result);
    }

    // 6) Always invoke the legacy Claude hook migration.
    let legacy_migration = migrate_legacy_claude_hook(repo_root)?;

    // 7) Build next_steps.
    let mut next_steps: Vec<String> = Vec::new();

    if let Some(codex_result) = apply_results
        .iter()
        .find(|r| r.host == ManagedHost::Codex && r.mode == HostIntegrationMode::ManualSkill)
    {
        let _ = codex_result; // scope-bound match; message is fixed
        next_steps.push(
            "Use `ccd host apply --host codex --with-launcher` if you want the Codex launcher/eval harness."
                .to_owned(),
        );
    }

    match legacy_migration {
        LegacyHookMigration::NoLegacyFile => {}
        LegacyHookMigration::DeletedSentinelFile { path } => {
            next_steps.push(format!("Removed legacy CCD-managed Claude hook at {path}."));
        }
        LegacyHookMigration::PreservedUserFile { warning, .. } => {
            next_steps.push(warning);
        }
    }

    Ok(InitReport {
        command: "init",
        ok: true,
        path: repo_root.display().to_string(),
        profile: attach_report.profile_name().to_owned(),
        locality_id: attach_report.locality_id().to_owned(),
        attach: attach_report,
        host_scaffold,
        next_steps,
    })
}

fn apply_host_integration(
    repo_root: &Path,
    layout: &StateLayout,
    locality_id: &str,
    host: ManagedHost,
    force: bool,
    with_launcher: bool,
) -> Result<ApplyResult> {
    let mode = effective_install_mode(layout, locality_id, host, with_launcher)?;
    ensure_supported_mode(host, mode)?;

    let source_assets = render_source_assets(host);
    ensure_assets_present(repo_root, &source_assets, "scaffolded source")?;
    let applied_assets = render_applied_assets(host, mode);

    let mut action = FileAction::AlreadyPresent;
    let mut applied_paths = Vec::new();
    for asset in &applied_assets {
        let file_action =
            if host == ManagedHost::Claude && asset.relative_path == CLAUDE_TARGET_SETTINGS {
                merge_or_write_claude_settings(repo_root, asset, force)?
            } else {
                sync_asset(repo_root, asset, force)?
            };
        action = combine_actions(action, file_action);
        applied_paths.push(repo_root.join(asset.relative_path).display().to_string());
    }

    upsert_host_expectation(layout, locality_id, host, mode)?;

    Ok(ApplyResult {
        host,
        mode,
        action,
        applied_paths,
        launcher_applied: false,
    })
}

/// Additive merge for `.claude/settings.json` (§4.2). Reads any existing file,
/// merges CCD-managed hook entries into it, and writes the result. Non-CCD
/// entries are preserved in their original order.
fn merge_or_write_claude_settings(
    repo_root: &Path,
    asset: &RenderedAsset,
    force: bool,
) -> Result<FileAction> {
    // asset.contents intentionally unused — the merge derives CCD entries from
    // CCD_MANAGED_EVENTS internally rather than from the scaffolded template.
    let target = repo_root.join(asset.relative_path);
    let merge_output = settings_merge::merge_settings_file(&target, force)?;
    match merge_output {
        Some(rendered) => {
            let prior = std::fs::read_to_string(&target).ok();
            if prior.as_deref() == Some(rendered.as_str()) {
                return Ok(FileAction::AlreadyPresent);
            }
            std::fs::create_dir_all(target.parent().unwrap())
                .with_context(|| format!("creating dir for {}", target.display()))?;
            std::fs::write(&target, &rendered)
                .with_context(|| format!("writing {}", target.display()))?;
            Ok(if prior.is_some() {
                FileAction::Updated
            } else {
                FileAction::Installed
            })
        }
        None => {
            anyhow::bail!(
                "`{}` is malformed; re-run with `--force` to overwrite or repair manually",
                target.display()
            )
        }
    }
}

pub fn apply(
    repo_root: &Path,
    explicit_profile: Option<&str>,
    explicit_host: Option<ManagedHost>,
    force: bool,
    with_launcher: bool,
) -> Result<InstallReport> {
    let profile_name = profile::resolve(explicit_profile)?;
    let layout = StateLayout::resolve(repo_root, profile_name.clone())?;
    let marker = marker::load(repo_root)?.ok_or_else(|| {
        anyhow::anyhow!("this checkout is not attached to a CCD profile; run `ccd attach` first")
    })?;

    // Determine which hosts have been scaffolded under .ccd-hosts/.
    let scaffolded: Vec<ManagedHost> = ManagedHost::all()
        .iter()
        .copied()
        .filter(|host| {
            let source_dir = repo_root.join(".ccd-hosts").join(host.as_str());
            source_dir.is_dir()
        })
        .collect();

    let targets: Vec<ManagedHost> = match explicit_host {
        Some(host) => {
            if !scaffolded.contains(&host) {
                anyhow::bail!(
                    "host `{}` has no scaffolded sources under .ccd-hosts/; run `ccd init` first",
                    host.as_str()
                );
            }
            vec![host]
        }
        None => {
            if scaffolded.is_empty() {
                anyhow::bail!("no host sources found under .ccd-hosts/; run `ccd init` first");
            }
            // If multiple hosts are scaffolded AND one of them requires mode
            // disambiguation (Codex with with_launcher), require --host.
            if scaffolded.len() > 1 && with_launcher {
                anyhow::bail!(
                    "--with-launcher requires --host when multiple hosts are scaffolded; currently {:?}",
                    scaffolded.iter().map(|h| h.as_str()).collect::<Vec<_>>()
                );
            }
            scaffolded.clone()
        }
    };

    let mut action = FileAction::AlreadyPresent;
    let mut applied_paths: Vec<String> = Vec::new();
    let mut host_names: Vec<String> = Vec::new();
    let mut mode_names: Vec<String> = Vec::new();
    let mut next_steps: Vec<String> = Vec::new();
    let mut claude_targeted = false;

    for host in &targets {
        let result = apply_host_integration(
            repo_root,
            &layout,
            &marker.locality_id,
            *host,
            force,
            with_launcher,
        )?;

        if *host == ManagedHost::Claude {
            claude_targeted = true;
        }
        if *host == ManagedHost::Codex && result.mode == HostIntegrationMode::ManualSkill {
            next_steps.push(
                "Use `$ccd-start` or `ccd start --activate --path .` as the supported human-driven Codex baseline.".to_owned(),
            );
        }
        if *host == ManagedHost::Codex && result.mode == HostIntegrationMode::LauncherWrapper {
            next_steps.push(format!(
                "Use `./{CODEX_TARGET_LAUNCHER}` when you want the optional Codex launcher/eval harness."
            ));
        }

        host_names.push(result.host.as_str().to_owned());
        mode_names.push(result.mode.as_str().to_owned());
        applied_paths.extend(result.applied_paths.iter().cloned());
        action = combine_actions(action, result.action);
    }

    // Symmetry with Task 8's init: run the legacy Claude hook migration when
    // Claude is among the applied targets.
    if claude_targeted {
        match migrate_legacy_claude_hook(repo_root)? {
            LegacyHookMigration::NoLegacyFile => {}
            LegacyHookMigration::DeletedSentinelFile { path } => {
                next_steps.push(format!("Removed legacy CCD-managed Claude hook at {path}."));
            }
            LegacyHookMigration::PreservedUserFile { warning, .. } => {
                next_steps.push(warning);
            }
        }
    }

    Ok(InstallReport {
        command: "host apply",
        ok: true,
        path: repo_root.display().to_string(),
        profile: profile_name.to_string(),
        host: host_names.join(","),
        mode: mode_names.join(","),
        action,
        applied_paths,
        next_steps,
    })
}

pub fn inspect(
    repo_root: &Path,
    layout: &StateLayout,
    locality_id: &str,
) -> Result<Vec<HostIntegrationStatus>> {
    let mut statuses = Vec::new();
    for (host, mode) in expected_integrations(layout, locality_id)? {
        let source_assets = render_source_assets(host);
        let applied_assets = render_applied_assets(host, mode);
        let install_status = if supports_mode(host, mode) {
            inspect_assets(repo_root, &applied_assets)
        } else {
            IntegrationAssetStatus::InvalidMode
        };
        statuses.push(HostIntegrationStatus {
            host: host.as_str().to_owned(),
            mode: mode.as_str().to_owned(),
            scaffold_status: inspect_assets(repo_root, &source_assets),
            install_status,
            source_paths: source_assets
                .iter()
                .map(|asset| repo_root.join(asset.relative_path).display().to_string())
                .collect(),
            applied_paths: applied_assets
                .iter()
                .map(|asset| repo_root.join(asset.relative_path).display().to_string())
                .collect(),
        });
    }
    Ok(statuses)
}

fn supports_mode(host: ManagedHost, mode: HostIntegrationMode) -> bool {
    match host {
        ManagedHost::Claude => mode == HostIntegrationMode::NativeHook,
        ManagedHost::Codex => {
            matches!(
                mode,
                HostIntegrationMode::ManualSkill | HostIntegrationMode::LauncherWrapper
            )
        }
        ManagedHost::Hermes | ManagedHost::OpenClaw => {
            mode == HostIntegrationMode::ReferenceAdapter
        }
    }
}

fn supported_modes_label(host: ManagedHost) -> &'static str {
    match host {
        ManagedHost::Claude => "`native_hook`",
        ManagedHost::Codex => "`manual_skill`, `launcher_wrapper`",
        ManagedHost::Hermes | ManagedHost::OpenClaw => "`reference_adapter`",
    }
}

fn ensure_supported_mode(host: ManagedHost, mode: HostIntegrationMode) -> Result<()> {
    if supports_mode(host, mode) {
        return Ok(());
    }

    bail!(
        "unsupported mode `{}` for {}; supported modes: {}",
        mode.as_str(),
        host.as_str(),
        supported_modes_label(host)
    );
}

fn effective_install_mode(
    layout: &StateLayout,
    locality_id: &str,
    host: ManagedHost,
    with_launcher: bool,
) -> Result<HostIntegrationMode> {
    if host == ManagedHost::Codex {
        if with_launcher {
            return Ok(HostIntegrationMode::LauncherWrapper);
        }

        let existing_mode = expected_integrations(layout, locality_id)?
            .into_iter()
            .find_map(|(expected_host, mode)| (expected_host == host).then_some(mode));

        return Ok(match existing_mode {
            Some(HostIntegrationMode::LauncherWrapper) => HostIntegrationMode::LauncherWrapper,
            _ => HostIntegrationMode::ManualSkill,
        });
    }

    Ok(expected_integrations(layout, locality_id)?
        .into_iter()
        .find_map(|(expected_host, mode)| (expected_host == host).then_some(mode))
        .unwrap_or_else(|| host.default_mode()))
}

fn scaffold_host(
    repo_root: &Path,
    _layout: &StateLayout,
    _locality_id: &str,
    host: ManagedHost,
    mode: HostIntegrationMode,
    force: bool,
) -> Result<HostMutationView> {
    let assets = render_source_assets(host);
    let mut action = FileAction::AlreadyPresent;
    let mut source_paths = Vec::new();
    for asset in &assets {
        let file_action = sync_asset(repo_root, asset, force)?;
        action = combine_actions(action, file_action);
        source_paths.push(repo_root.join(asset.relative_path).display().to_string());
    }
    // Scaffolding does NOT record a host declaration in the profile overlay
    // anymore (§4.1): only `apply_host_integration` upserts the expectation.

    Ok(HostMutationView {
        host: host.as_str().to_owned(),
        mode: mode.as_str().to_owned(),
        action,
        source_paths,
    })
}

fn combine_actions(current: FileAction, next: FileAction) -> FileAction {
    match (current, next) {
        (FileAction::Updated, _) | (_, FileAction::Updated) => FileAction::Updated,
        (FileAction::Installed, _) | (_, FileAction::Installed) => FileAction::Installed,
        _ => FileAction::AlreadyPresent,
    }
}

fn inspect_assets(repo_root: &Path, assets: &[RenderedAsset]) -> IntegrationAssetStatus {
    if assets.is_empty() {
        return IntegrationAssetStatus::NotApplicable;
    }

    let mut saw_missing = false;
    let mut saw_drift = false;
    for asset in assets {
        match asset_status(repo_root, asset) {
            IntegrationAssetStatus::Present => {}
            IntegrationAssetStatus::Missing => saw_missing = true,
            IntegrationAssetStatus::Drifted => saw_drift = true,
            IntegrationAssetStatus::InvalidMode => unreachable!(),
            IntegrationAssetStatus::NotApplicable => {}
        }
    }

    if saw_drift {
        IntegrationAssetStatus::Drifted
    } else if saw_missing {
        IntegrationAssetStatus::Missing
    } else {
        IntegrationAssetStatus::Present
    }
}

fn asset_status(repo_root: &Path, asset: &RenderedAsset) -> IntegrationAssetStatus {
    let path = repo_root.join(asset.relative_path);
    if asset.relative_path == CLAUDE_TARGET_SETTINGS {
        return claude_settings_status(&path);
    }
    let contents = match fs::read_to_string(&path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return IntegrationAssetStatus::Missing
        }
        Err(_) => return IntegrationAssetStatus::Drifted,
    };

    if contents != asset.contents {
        return IntegrationAssetStatus::Drifted;
    }

    if let Some(expected_mode) = asset.mode {
        let current_mode = match fs::metadata(&path) {
            Ok(metadata) => metadata.permissions().mode() & 0o777,
            Err(_) => return IntegrationAssetStatus::Drifted,
        };
        if current_mode != expected_mode {
            return IntegrationAssetStatus::Drifted;
        }
    }

    IntegrationAssetStatus::Present
}

fn claude_settings_status(path: &Path) -> IntegrationAssetStatus {
    let current = match fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return IntegrationAssetStatus::Missing
        }
        Err(_) => return IntegrationAssetStatus::Drifted,
    };

    match settings_merge::merge_settings_file(path, false) {
        Ok(Some(rendered)) if rendered == current => IntegrationAssetStatus::Present,
        Ok(Some(_)) | Ok(None) | Err(_) => IntegrationAssetStatus::Drifted,
    }
}

fn ensure_assets_present(repo_root: &Path, assets: &[RenderedAsset], label: &str) -> Result<()> {
    for asset in assets {
        match asset_status(repo_root, asset) {
            IntegrationAssetStatus::Present => {}
            IntegrationAssetStatus::Missing => bail!(
                "{} is missing at {}; run `ccd init --path {}` first",
                label,
                repo_root.join(asset.relative_path).display(),
                repo_root.display()
            ),
            IntegrationAssetStatus::Drifted => bail!(
                "{} at {} has drifted from the CCD-managed content; review it or re-run with `--force`",
                label,
                repo_root.join(asset.relative_path).display()
            ),
            IntegrationAssetStatus::InvalidMode => unreachable!(),
            IntegrationAssetStatus::NotApplicable => {}
        }
    }
    Ok(())
}

fn sync_asset(repo_root: &Path, asset: &RenderedAsset, force: bool) -> Result<FileAction> {
    let path = repo_root.join(asset.relative_path);
    let current = fs::read_to_string(&path);
    match current {
        Ok(existing) => {
            let mode_matches = asset.mode.is_none_or(|expected| {
                fs::metadata(&path)
                    .map(|metadata| metadata.permissions().mode() & 0o777 == expected)
                    .unwrap_or(false)
            });
            if existing == asset.contents && mode_matches {
                return Ok(FileAction::AlreadyPresent);
            }
            if !force {
                bail!(
                    "{} already exists and differs from the CCD-managed content; re-run with `--force` to replace it",
                    path.display()
                );
            }
            write::replace_text(&path, &asset.contents, asset.mode)
                .with_context(|| format!("failed to write {}", path.display()))?;
            Ok(FileAction::Updated)
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            write::create_text(&path, &asset.contents, asset.mode)
                .with_context(|| format!("failed to write {}", path.display()))?;
            Ok(FileAction::Installed)
        }
        Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
    }
}

/// Result of the legacy `ccd-hook.py` migration. Inspectable by callers so they can
/// surface user-visible messaging.
#[derive(Debug)]
pub(crate) enum LegacyHookMigration {
    NoLegacyFile,
    DeletedSentinelFile { path: String },
    PreservedUserFile { warning: String },
}

/// Delete `.claude/hooks/ccd-hook.py` when it carries the CCD-MANAGED sentinel;
/// preserve user-customized copies with a warning. If the hooks dir becomes empty
/// after deletion, remove the directory too (§4.3).
pub(crate) fn migrate_legacy_claude_hook(repo_root: &Path) -> Result<LegacyHookMigration> {
    let hook_path = repo_root.join(".claude/hooks/ccd-hook.py");
    if !hook_path.exists() {
        return Ok(LegacyHookMigration::NoLegacyFile);
    }
    let contents = std::fs::read_to_string(&hook_path)
        .with_context(|| format!("reading {}", hook_path.display()))?;
    let first_non_shebang = contents
        .lines()
        .find(|line| !line.starts_with("#!"))
        .unwrap_or("");
    let is_ccd_managed = first_non_shebang.trim_start().starts_with("# CCD-MANAGED");

    if !is_ccd_managed {
        let warning = format!(
            "a non-CCD ccd-hook.py was found at {}; remove it manually if it is obsolete.",
            hook_path.display()
        );
        return Ok(LegacyHookMigration::PreservedUserFile { warning });
    }

    std::fs::remove_file(&hook_path)
        .with_context(|| format!("removing legacy hook {}", hook_path.display()))?;

    // Remove the directory if it is now empty.
    let hooks_dir = hook_path.parent().expect("hook has parent dir");
    if std::fs::read_dir(hooks_dir)
        .map(|mut iter| iter.next().is_none())
        .unwrap_or(false)
    {
        let _ = std::fs::remove_dir(hooks_dir); // best-effort
    }

    Ok(LegacyHookMigration::DeletedSentinelFile {
        path: hook_path.display().to_string(),
    })
}

fn expected_integrations(
    layout: &StateLayout,
    locality_id: &str,
) -> Result<Vec<(ManagedHost, HostIntegrationMode)>> {
    let Some(config) = layout.load_repo_overlay_config(locality_id)? else {
        return Ok(Vec::new());
    };

    let mut expected = std::collections::BTreeMap::new();
    for (key, host_config) in config.hosts {
        let Some(host) = ManagedHost::from_config_key(&key) else {
            continue;
        };
        let mode = HostIntegrationMode::from_config(host_config.mode.as_deref(), host);
        let is_canonical_key = key == host.as_str();

        match expected.entry(host) {
            std::collections::btree_map::Entry::Vacant(entry) => {
                entry.insert((mode, is_canonical_key));
            }
            std::collections::btree_map::Entry::Occupied(mut entry) => {
                if is_canonical_key || !entry.get().1 {
                    entry.insert((mode, is_canonical_key));
                }
            }
        }
    }

    Ok(expected
        .into_iter()
        .map(|(host, (mode, _))| (host, mode))
        .collect())
}

fn upsert_host_expectation(
    layout: &StateLayout,
    locality_id: &str,
    host: ManagedHost,
    mode: HostIntegrationMode,
) -> Result<()> {
    let config_path = layout.repo_overlay_config_path(locality_id)?;
    let mut root = load_repo_overlay_config_table(&config_path)?;
    let hosts = root
        .entry("hosts".to_owned())
        .or_insert_with(|| toml::Value::Table(toml::Table::new()))
        .as_table_mut()
        .ok_or_else(|| anyhow::anyhow!("{}: [hosts] must be a table", config_path.display()))?;
    let mut host_table = toml::Table::new();
    host_table.insert(
        "mode".to_owned(),
        toml::Value::String(mode.as_str().to_owned()),
    );
    hosts.insert(host.as_str().to_owned(), toml::Value::Table(host_table));

    let rendered = toml::to_string_pretty(&root)
        .with_context(|| format!("failed to serialize {}", config_path.display()))?;
    write::replace_text(&config_path, &rendered, None)
        .with_context(|| format!("failed to write {}", config_path.display()))?;
    Ok(())
}

fn load_repo_overlay_config_table(path: &Path) -> Result<toml::Table> {
    let raw = match fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(toml::Table::new());
        }
        Err(error) => {
            return Err(error).with_context(|| format!("failed to read {}", path.display()));
        }
    };
    if raw.trim().is_empty() {
        return Ok(toml::Table::new());
    }

    let value: toml::Value =
        toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
    value
        .as_table()
        .cloned()
        .ok_or_else(|| anyhow::anyhow!("{} must be a TOML table", path.display()))
}

fn render_source_assets(host: ManagedHost) -> Vec<RenderedAsset> {
    match host {
        ManagedHost::Claude => vec![RenderedAsset {
            relative_path: CLAUDE_SOURCE_SETTINGS,
            contents: claude_settings_json(),
            mode: None,
        }],
        ManagedHost::Codex => vec![
            RenderedAsset {
                relative_path: CODEX_SOURCE_README,
                contents: codex_guidance_readme(),
                mode: None,
            },
            RenderedAsset {
                relative_path: CODEX_SOURCE_LAUNCHER,
                contents: codex_launcher_script(),
                mode: Some(0o755),
            },
        ],
        ManagedHost::Hermes => vec![RenderedAsset {
            relative_path: HERMES_SOURCE_ADAPTER,
            contents: hermes_adapter_json(),
            mode: None,
        }],
        ManagedHost::OpenClaw => vec![RenderedAsset {
            relative_path: OPENCLAW_SOURCE_ADAPTER,
            contents: openclaw_adapter_json(),
            mode: None,
        }],
    }
}

fn render_applied_assets(host: ManagedHost, mode: HostIntegrationMode) -> Vec<RenderedAsset> {
    match (host, mode) {
        (ManagedHost::Claude, HostIntegrationMode::NativeHook) => vec![RenderedAsset {
            relative_path: CLAUDE_TARGET_SETTINGS,
            contents: claude_settings_json(),
            mode: None,
        }],
        (ManagedHost::Codex, HostIntegrationMode::LauncherWrapper) => vec![RenderedAsset {
            relative_path: CODEX_TARGET_LAUNCHER,
            contents: codex_launcher_script(),
            mode: Some(0o755),
        }],
        (ManagedHost::Codex, HostIntegrationMode::ManualSkill) => Vec::new(),
        (ManagedHost::Hermes, HostIntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
            relative_path: HERMES_TARGET_ADAPTER,
            contents: hermes_adapter_json(),
            mode: None,
        }],
        (ManagedHost::OpenClaw, HostIntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
            relative_path: OPENCLAW_TARGET_ADAPTER,
            contents: openclaw_adapter_json(),
            mode: None,
        }],
        _ => Vec::new(),
    }
}

/// The CCD-managed command prefix emitted into `.claude/settings.json`. Any entry whose
/// `command` string starts with this prefix is owned by CCD and is safe to replace on
/// additive merge (§4.2). Changes here require a migration plan — see §1.4.
///
/// Ends with a trailing space: `ccd_claude_command()` appends the hook name directly.
pub(crate) const CCD_CLAUDE_COMMAND_PREFIX: &str =
    "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ";

fn ccd_claude_command(hook_name: &str) -> String {
    format!("{CCD_CLAUDE_COMMAND_PREFIX}{hook_name}")
}

// TaskCompleted intentionally omitted — not a stable stdout-directive hook in the Claude
// protocol; only `Stop` fires reliably at end-of-turn. See spec §1.2, §1.6.
fn claude_settings_json() -> String {
    serde_json::to_string_pretty(&json!({
        "hooks": {
            "SessionStart": [{
                "matcher": "startup|resume|clear|compact",
                "hooks": [{
                    "type": "command",
                    "command": ccd_claude_command("on-session-start"),
                }]
            }],
            "UserPromptSubmit": [{
                "matcher": "*",
                "hooks": [{
                    "type": "command",
                    "command": ccd_claude_command("before-prompt-build"),
                }]
            }],
            "PreCompact": [{
                "matcher": "*",
                "hooks": [{
                    "type": "command",
                    "command": ccd_claude_command("on-compaction-notice"),
                }]
            }],
            "Stop": [{
                "matcher": "*",
                "hooks": [{
                    "type": "command",
                    "command": ccd_claude_command("on-agent-end"),
                }]
            }],
            "SessionEnd": [{
                "matcher": "*",
                "hooks": [{
                    "type": "command",
                    "command": ccd_claude_command("on-session-end"),
                }]
            }]
        }
    }))
    .expect("claude settings json")
}

fn codex_guidance_readme() -> String {
    format!(
        r#"<!-- CCD-MANAGED -->
# Codex Host Guidance

Human-driven Codex is allowed to use the manual CCD startup path:

- `/ccd-start`
- `ccd start --activate --path .`

That is the supported baseline for terminal Codex today. It is tracked as
`manual_skill`, not as a product failure.

If you want the optional zero-ritual launcher/eval harness instead, run:

```bash
ccd host apply --host codex --with-launcher --path .
```

That applies the launcher wrapper at `./{CODEX_TARGET_LAUNCHER}`.
"#
    )
}

fn codex_launcher_script() -> String {
    r#"#!/bin/sh
# CCD-MANAGED
# Optional Codex launcher/eval harness.

set -e

if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
    CCD="$CCD_BIN"
elif command -v ccd >/dev/null 2>&1; then
    CCD=ccd
elif [ -x "$HOME/.ccd/bin/ccd" ]; then
    CCD="$HOME/.ccd/bin/ccd"
elif [ -x "$HOME/.cargo/bin/ccd" ]; then
    CCD="$HOME/.cargo/bin/ccd"
else
    CCD=""
fi

if [ -n "$CCD" ]; then
    "$CCD" host-hook --output json --path . --host codex --hook on-session-start >/dev/null 2>&1 || true
fi

exec codex "$@"
"#
        .to_owned()
}

fn openclaw_adapter_json() -> String {
    serde_json::to_string_pretty(&json!({
        "host": "openclaw",
        "integration_mode": "reference_adapter",
        "commands": {
            "session_start": "ccd host-hook --output json --path . --host openclaw --hook on-session-start --mode implement --lifecycle autonomous --actor-id runtime/openclaw-agent-1 --lease-seconds 900 --host-session-id acp-session-42 --host-run-id acp-run-42",
            "before_prompt_build": "ccd host-hook --path . --host openclaw --hook before-prompt-build",
            "on_compaction_notice": "ccd host-hook --path . --host openclaw --hook on-compaction-notice",
            "on_agent_end": "ccd host-hook --path . --host openclaw --hook on-agent-end",
            "on_session_end": "ccd host-hook --path . --host openclaw --hook on-session-end"
        },
        "notes": [
            "Inject only the top-level context payload into prompt-build.",
            "Keep runtime transcript history outside CCD durable state.",
            "Use separate worktrees for parallel writers."
        ]
    }))
    .expect("openclaw adapter json")
}

fn hermes_adapter_json() -> String {
    serde_json::to_string_pretty(&json!({
        "host": "hermes",
        "integration_mode": "reference_adapter",
        "commands": {
            "session_start": "ccd host-hook --output json --path . --host hermes --hook on-session-start --mode implement --lifecycle autonomous --actor-id runtime/hermes-worker-1 --supervisor-id runtime/hermes-supervisor-1 --lease-seconds 900 --host-session-id hermes-channel-42 --host-run-id hermes-run-42 --host-task-id hermes-task-42",
            "before_prompt_build": "ccd host-hook --path . --host hermes --hook before-prompt-build",
            "on_compaction_notice": "ccd host-hook --path . --host hermes --hook on-compaction-notice",
            "on_agent_end": "ccd host-hook --path . --host hermes --hook on-agent-end",
            "on_session_end": "ccd host-hook --path . --host hermes --hook on-session-end",
            "supervisor_tick": "ccd host-hook --path . --host hermes --hook supervisor-tick"
        },
        "notes": [
            "Honor the top-level session_boundary before unattended continuation.",
            "Use supervisor_tick when the runtime can refresh lease ownership.",
            "Treat CCD outputs as control-plane truth rather than prompt folklore."
        ]
    }))
    .expect("hermes adapter json")
}

#[cfg(test)]
mod legacy_hook_tests {
    use super::*;
    use tempfile::TempDir;

    fn write(path: &Path, body: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, body).unwrap();
    }

    #[test]
    fn no_legacy_file_returns_no_op() {
        let tmp = TempDir::new().unwrap();
        match migrate_legacy_claude_hook(tmp.path()).unwrap() {
            LegacyHookMigration::NoLegacyFile => {}
            _ => panic!("expected NoLegacyFile"),
        }
    }

    #[test]
    fn sentinel_file_is_deleted_and_empty_dir_removed() {
        let tmp = TempDir::new().unwrap();
        let hook = tmp.path().join(".claude/hooks/ccd-hook.py");
        write(
            &hook,
            "#!/usr/bin/env python3\n# CCD-MANAGED\nprint('hi')\n",
        );

        let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
        assert!(matches!(
            result,
            LegacyHookMigration::DeletedSentinelFile { .. }
        ));
        assert!(!hook.exists());
        assert!(
            !hook.parent().unwrap().exists(),
            "empty hooks dir should be removed"
        );
    }

    #[test]
    fn user_file_without_sentinel_is_preserved() {
        let tmp = TempDir::new().unwrap();
        let hook = tmp.path().join(".claude/hooks/ccd-hook.py");
        write(&hook, "#!/usr/bin/env python3\nprint('user script')\n");

        let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
        match result {
            LegacyHookMigration::PreservedUserFile { warning, .. } => {
                assert!(warning.contains("non-CCD"), "warning: {warning}");
            }
            _ => panic!("expected PreservedUserFile"),
        }
        assert!(hook.exists(), "user hook must be preserved");
    }

    #[test]
    fn sentinel_recognized_after_multiple_shebang_prefixed_lines() {
        let tmp = TempDir::new().unwrap();
        let hook = tmp.path().join(".claude/hooks/ccd-hook.py");
        // Multiple `#!`-prefixed lines at the top (pathological but allowed by the scan):
        write(
            &hook,
            "#!/usr/bin/env python3\n#!coding:utf-8\n# CCD-MANAGED\nimport json\n",
        );

        let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
        assert!(
            matches!(result, LegacyHookMigration::DeletedSentinelFile { .. }),
            "got: {:?}",
            result
        );
        assert!(!hook.exists());
    }

    #[test]
    fn sentinel_deletion_preserves_dir_when_sibling_exists() {
        let tmp = TempDir::new().unwrap();
        let hooks_dir = tmp.path().join(".claude/hooks");
        std::fs::create_dir_all(&hooks_dir).unwrap();
        let hook = hooks_dir.join("ccd-hook.py");
        let sibling = hooks_dir.join("helper.sh");
        std::fs::write(
            &hook,
            "#!/usr/bin/env python3\n# CCD-MANAGED\nprint('hi')\n",
        )
        .unwrap();
        std::fs::write(&sibling, "#!/bin/bash\necho hi\n").unwrap();

        let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
        assert!(matches!(
            result,
            LegacyHookMigration::DeletedSentinelFile { .. }
        ));
        assert!(!hook.exists(), "hook itself must be deleted");
        assert!(hooks_dir.exists(), "non-empty hooks dir must be preserved");
        assert!(sibling.exists(), "sibling file must not be touched");
    }
}
