use std::path::PathBuf;

use clap::{ArgGroup, Parser, Subcommand};

#[derive(Debug, Parser)]
#[command(
    name = "render-session",
    about = "render-session: MCP server + HTTP viewer for AI session output",
    version
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    /// Run the MCP stdio server (default entry point from .mcp.json).
    /// Delegates to the render-session-mcp binary found next to this executable.
    Mcp,

    /// Start the HTTP viewer server
    Serve {
        /// Port to listen on
        #[arg(long, default_value = "8000")]
        port: u16,

        /// Project directory to serve content from
        #[arg(long)]
        dir: String,

        /// Phase 4 (c): viewer-internal watcher tick (seconds). When set, the viewer
        /// runs `auto_capture_once` every <tick> seconds in the background. None
        /// disables the watcher (Phase 1+ default behavior preserved).
        #[arg(long)]
        watch_tick: Option<u64>,

        /// Viewer lifecycle mode: detached | session | launchd.
        ///
        /// When omitted, the value is taken from `RENDER_SESSION_VIEWER__LIFECYCLE`
        /// (env), then from the project config file, then defaults to `detached`.
        #[arg(long, value_enum)]
        lifecycle: Option<ViewerLifecycleCli>,
    },

    /// Generate list.json and render-lanes.md from config
    Gen {
        /// Project directory containing .render-session.yaml
        #[arg(long)]
        project: String,
    },

    /// Write a report from stdin
    Report {
        /// Directory to write the report into
        #[arg(long)]
        dir: String,

        /// Report title
        #[arg(long)]
        title: String,
    },

    /// Capture session jsonl from Claude Code projects and emit to render-site/.
    Capture {
        /// Project directory (where render-site/ lives)
        #[arg(long)]
        project: std::path::PathBuf,

        /// Capture mode: session | recent | both (default: both)
        #[arg(long, default_value = "both")]
        mode: String,

        /// Claude Code project slug (default: derived from project path via canonicalize + replace('/', "-"))
        #[arg(long)]
        slug: Option<String>,

        /// Recent mode: number of turns to emit (default: 5)
        #[arg(long, default_value_t = 5)]
        n: usize,

        /// Recent mode: include turns without visual artifact (default: false)
        #[arg(long, default_value_t = false)]
        all: bool,
    },

    /// Show and diagnose the effective configuration.
    Config {
        #[command(subcommand)]
        subcommand: ConfigSubcommand,
    },

    /// Show effective config layers, mounts, and viewer status (one-call diagnostic).
    ///
    /// Note: For layer-only listing see `render-session config info`
    Info {
        /// Project directory to inspect (defaults to current working directory)
        #[arg(long)]
        project: Option<PathBuf>,

        /// Output format: "json" | "table" (default: "table")
        #[arg(long, default_value = "table")]
        format: Option<String>,
    },

    /// Manage running viewer processes
    Viewer {
        #[command(subcommand)]
        subcommand: ViewerSubcommand,
    },
}

// ---------------------------------------------------------------------------
// CLI enum for ViewerLifecycle (clap ValueEnum wrapper)
// ---------------------------------------------------------------------------

/// Viewer lifecycle mode accepted by the `--lifecycle` CLI flag.
///
/// Maps 1:1 to `render_session_core::config::ViewerLifecycle`.
#[derive(Debug, Clone, clap::ValueEnum)]
enum ViewerLifecycleCli {
    /// Detached process (double-fork, survives MCP session death).
    Detached,
    /// Session-bound process (terminated when MCP session ends).
    Session,
    /// macOS launchd-managed (plist generation, no direct spawn).
    Launchd,
}

impl From<ViewerLifecycleCli> for render_session_core::config::ViewerLifecycle {
    fn from(cli: ViewerLifecycleCli) -> Self {
        match cli {
            ViewerLifecycleCli::Detached => render_session_core::config::ViewerLifecycle::Detached,
            ViewerLifecycleCli::Session => render_session_core::config::ViewerLifecycle::Session,
            ViewerLifecycleCli::Launchd => render_session_core::config::ViewerLifecycle::Launchd,
        }
    }
}

// ---------------------------------------------------------------------------
// Viewer subcommand definitions
// ---------------------------------------------------------------------------

/// Subcommands for `render-session viewer`.
#[derive(Debug, Subcommand)]
enum ViewerSubcommand {
    /// List all active viewer leases from the lease store.
    List,

    /// Kill one or more viewer processes.
    #[command(group(
        ArgGroup::new("target")
            .required(true)
            .args(["pid", "all", "dir"])
    ))]
    Kill {
        /// PID of the viewer process to kill.
        #[arg(long)]
        pid: Option<u32>,

        /// Kill all active viewer processes.
        #[arg(long, default_value_t = false)]
        all: bool,

        /// Kill viewer(s) serving the given directory.
        #[arg(long)]
        dir: Option<PathBuf>,
    },

    /// Generate a macOS launchd plist for the viewer.
    ///
    /// Only available on macOS. On other platforms this subcommand exits with
    /// code 2 and a "macOS-only" error message.
    Plist {
        /// Project directory to serve.
        #[arg(long)]
        dir: PathBuf,
    },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::Subscriber::builder()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();
    let cli = Cli::parse();
    match cli.command {
        Commands::Mcp => run_mcp().await,
        Commands::Serve {
            port,
            dir,
            watch_tick,
            lifecycle,
        } => {
            // Resolve lifecycle from CLI flag → ENV → project config → default (Detached).
            let resolved_lifecycle = resolve_lifecycle(lifecycle, &dir);
            run_serve(port, dir, watch_tick, resolved_lifecycle).await
        }
        Commands::Gen { project } => run_gen(project).await,
        Commands::Report { dir, title } => run_report(dir, title).await,
        Commands::Capture {
            project,
            mode,
            slug,
            n,
            all,
        } => run_capture(project, mode, slug, n, all).await,
        Commands::Config { subcommand } => run_config(subcommand).await,
        Commands::Info { project, format } => run_info(project, format).await,
        Commands::Viewer { subcommand } => run_viewer(subcommand).await,
    }
}

async fn run_mcp() -> anyhow::Result<()> {
    // B-plan: delegate to render-session-mcp binary.
    // Prefer the sibling binary in the same install directory; fall back to PATH.
    let exe_dir = std::env::current_exe()
        .ok()
        .and_then(|p| p.parent().map(|d| d.to_path_buf()));
    let mcp_path = exe_dir
        .map(|d| d.join("render-session-mcp"))
        .filter(|p| p.exists())
        .unwrap_or_else(|| std::path::PathBuf::from("render-session-mcp"));

    let status = tokio::process::Command::new(&mcp_path)
        .stdin(std::process::Stdio::inherit())
        .stdout(std::process::Stdio::inherit())
        .stderr(std::process::Stdio::inherit())
        .status()
        .await
        .map_err(|e| {
            tracing::error!(
                error = %e,
                path = %mcp_path.display(),
                "failed to spawn render-session-mcp"
            );
            e
        })?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        tracing::error!(exit_code = code, "render-session-mcp exited non-zero");
        anyhow::bail!("render-session-mcp exited with {}", status);
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Lifecycle resolution helper
// ---------------------------------------------------------------------------

/// Resolve the effective viewer lifecycle mode.
///
/// Priority (highest to lowest):
/// 1. CLI `--lifecycle <mode>` flag.
/// 2. `RENDER_SESSION_VIEWER__LIFECYCLE` environment variable (via config load).
/// 3. Project config file (`viewer.lifecycle` field).
/// 4. Default: `Detached`.
///
/// # Arguments
///
/// * `cli_flag` – Value from the `--lifecycle` CLI flag, if provided.
/// * `dir`       – Project directory string used for config resolution.
///
/// # Returns
///
/// The resolved [`render_session_core::config::ViewerLifecycle`].
fn resolve_lifecycle(
    cli_flag: Option<ViewerLifecycleCli>,
    dir: &str,
) -> render_session_core::config::ViewerLifecycle {
    use render_session_core::config::ViewerLifecycle;

    // CLI flag takes priority.
    if let Some(flag) = cli_flag {
        return flag.into();
    }

    // Load config (includes ENV overrides) for the project dir.
    let project_root = std::path::Path::new(dir);
    match render_session_core::config::load(project_root) {
        Ok(loaded) => loaded.config.viewer.lifecycle,
        Err(e) => {
            tracing::warn!(
                error = %e,
                dir = %dir,
                "resolve_lifecycle: config load failed, using default (Detached)"
            );
            ViewerLifecycle::default()
        }
    }
}

// ---------------------------------------------------------------------------
// viewer subcommand handler
// ---------------------------------------------------------------------------

/// Dispatch `viewer <subcommand>` to the appropriate handler.
///
/// # Arguments
///
/// * `sub` – The `ViewerSubcommand` to execute.
///
/// # Returns
///
/// `Ok(())` on success, or an `anyhow::Error` on failure.
async fn run_viewer(sub: ViewerSubcommand) -> anyhow::Result<()> {
    match sub {
        ViewerSubcommand::List => run_viewer_list().await,
        ViewerSubcommand::Kill { pid, all, dir } => run_viewer_kill(pid, all, dir).await,
        ViewerSubcommand::Plist { dir } => run_viewer_plist(dir).await,
    }
}

/// List all active viewer leases from the lease store (cross-project registry).
///
/// Prints a table with columns: `pid | port | dir | started_at | tick`.
/// The SSOT for `viewer list` is `LeaseStore` (XDG state dir, leases.json),
/// not the per-project `viewer.json`.
///
/// # Errors
///
/// Returns an error if the lease store cannot be resolved or loaded.
async fn run_viewer_list() -> anyhow::Result<()> {
    use anyhow::Context as _;
    use render_session_core::lease::LeaseStore;

    let store_path = render_session_core::lease::store_path()
        .context("viewer list: failed to resolve lease store path")?;
    let store = LeaseStore::load(store_path)
        .await
        .context("viewer list: failed to load lease store")?;

    let entries = store.list();
    if entries.is_empty() {
        println!("(no active viewer leases)");
        return Ok(());
    }

    // Table header.
    println!(
        "{:<8} {:<6} {:<50} {:<22} TICK",
        "PID", "PORT", "DIR", "STARTED_AT"
    );
    println!("{}", "-".repeat(100));

    for e in entries {
        let tick_str = e
            .tick
            .map(|t| t.to_string())
            .unwrap_or_else(|| "-".to_string());
        println!(
            "{:<8} {:<6} {:<50} {:<22} {}",
            e.pid,
            e.port,
            e.dir.display(),
            e.started_at,
            tick_str
        );
    }

    Ok(())
}

/// Kill one or more viewer processes.
///
/// Exactly one of `pid`, `all`, or `dir` must be specified (enforced by clap's
/// `ArgGroup`).  The kill is performed by sending `SIGTERM` via the existing
/// `LeaseStore::release(port)` path (port resolved from the lease entry).
///
/// # Arguments
///
/// * `pid` – Kill a specific viewer by PID.
/// * `all` – Kill all active viewers.
/// * `dir` – Kill viewer(s) serving a specific directory.
///
/// # Errors
///
/// Returns an error if the lease store cannot be loaded or if SIGTERM fails.
async fn run_viewer_kill(pid: Option<u32>, all: bool, dir: Option<PathBuf>) -> anyhow::Result<()> {
    use anyhow::Context as _;
    use render_session_core::lease::LeaseStore;

    let store_path = render_session_core::lease::store_path()
        .context("viewer kill: failed to resolve lease store path")?;
    let mut store = LeaseStore::load(store_path)
        .await
        .context("viewer kill: failed to load lease store")?;

    if all {
        // Collect all ports first, then release one by one.
        let ports: Vec<u16> = store.list().iter().map(|e| e.port).collect();
        if ports.is_empty() {
            println!("(no active viewer leases to kill)");
            return Ok(());
        }
        for port in ports {
            store.release(port).await.map_err(|e| {
                tracing::error!(error = %e, port, "viewer kill --all: release failed");
                e
            })?;
            println!("killed viewer on port {port}");
        }
        // Prune any stale entries that survived.
        store.prune_dead();
    } else if let Some(target_pid) = pid {
        // Find entry by PID and release via port.
        let port = store
            .list()
            .iter()
            .find(|e| e.pid == target_pid)
            .map(|e| e.port)
            .ok_or_else(|| {
                tracing::warn!(target_pid, "viewer kill: no lease entry found for pid");
                anyhow::anyhow!("no active lease found for pid {target_pid}")
            })?;
        store.release(port).await.map_err(|e| {
            tracing::error!(error = %e, port, target_pid, "viewer kill: release failed");
            anyhow::anyhow!("kill pid {target_pid}: {e}")
        })?;
        println!("killed viewer pid={target_pid} port={port}");
    } else if let Some(target_dir) = dir {
        // Find all entries for the given directory.
        let ports: Vec<u16> = store
            .list()
            .iter()
            .filter(|e| e.dir == target_dir)
            .map(|e| e.port)
            .collect();
        if ports.is_empty() {
            println!(
                "(no active viewer lease found for {})",
                target_dir.display()
            );
            return Ok(());
        }
        for port in ports {
            store.release(port).await.map_err(|e| {
                tracing::error!(error = %e, port, dir = %target_dir.display(), "viewer kill --dir: release failed");
                anyhow::anyhow!("kill --dir {}: {e}", target_dir.display())
            })?;
            println!("killed viewer on port {port}");
        }
    }

    Ok(())
}

/// Generate a macOS launchd plist for the viewer.
///
/// On non-macOS platforms, prints an error message and exits with code 2.
///
/// # Arguments
///
/// * `dir` – Project directory to serve.
///
/// # Returns
///
/// `Ok(())` on success (plist printed to stdout).
async fn run_viewer_plist(dir: PathBuf) -> anyhow::Result<()> {
    #[cfg(not(target_os = "macos"))]
    {
        let _ = dir;
        eprintln!("viewer plist subcommand is macOS-only");
        std::process::exit(2);
    }

    #[cfg(target_os = "macos")]
    {
        use anyhow::Context as _;

        // Resolve the viewer binary path (sibling to this binary).
        let exe_dir = std::env::current_exe()
            .ok()
            .and_then(|p| p.parent().map(|d| d.to_path_buf()));
        let exe = exe_dir
            .map(|d| d.join("render-session"))
            .unwrap_or_else(|| std::path::PathBuf::from("render-session"));

        // Load config to resolve port (default 8000) and data.root.
        let loaded = render_session_core::config::load(&dir)
            .context("viewer plist: failed to load config")?;

        let data_root = render_session_core::config::data_root(&loaded, &dir);
        let stdout_path = data_root.join("viewer.stdout.log");
        let stderr_path = data_root.join("viewer.stderr.log");

        // Derive a unique launchd label from the project directory.
        let label = format!(
            "com.render-session.viewer.{}",
            dir.file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("project")
        );

        let args = vec![
            "serve".to_string(),
            "--lifecycle".to_string(),
            "launchd".to_string(),
            "--dir".to_string(),
            dir.to_string_lossy().to_string(),
        ];

        let plist = render_session_core::launchd::render_plist(
            &label,
            &exe,
            &args,
            &stdout_path,
            &stderr_path,
        );

        print!("{plist}");
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// HTTP viewer server
// ---------------------------------------------------------------------------

/// Start the HTTP viewer server.
///
/// Spawns the parent-death watcher unless `lifecycle` is `Detached` (in which
/// case the viewer is expected to outlive its spawner).  When `Detached`, writes
/// the current process PID to `{data_root}/viewer.json` so that `viewer_lease`
/// (subtask-4) can rediscover the running viewer across MCP sessions.
///
/// # Arguments
///
/// * `port`      – TCP port to bind.
/// * `dir`       – Project directory (`--dir` flag value).
/// * `watch_tick` – Optional background-watcher tick in seconds.
/// * `lifecycle` – Viewer lifecycle mode; controls whether `watch_parent_death`
///   is spawned and whether `viewer.json` self-write occurs.
///
/// # Errors
///
/// Returns `anyhow::Error` if the HTTP server fails to start.
async fn run_serve(
    port: u16,
    dir: String,
    watch_tick: Option<u64>,
    lifecycle: render_session_core::config::ViewerLifecycle,
) -> anyhow::Result<()> {
    use anyhow::Context as _;
    use render_session_core::config::ViewerLifecycle;

    // In Session mode only, watch the parent and self-exit when the parent (MCP server) dies.
    // Detached mode: viewer must outlive the MCP session — skip the watcher.
    // Launchd mode: process is started by launchd (PID 1); getppid() == 1 always, so the
    // watcher would fire within one 5-second tick and self-exit. Skip the watcher.
    if lifecycle == ViewerLifecycle::Session {
        tokio::spawn(render_session_core::child::watch_parent_death());
    }

    // Detached mode: write viewer.json so that viewer_lease can rediscover this
    // process across MCP sessions without re-spawning.
    if lifecycle == ViewerLifecycle::Detached {
        let project_root = std::path::PathBuf::from(&dir);
        let pid = std::process::id();

        // Load config for the project so we can resolve the correct viewer.json path.
        // On config-load failure, fall back to <dir>/.claude/site/viewer.json.
        let pid_file_path = match render_session_core::config::load(&project_root) {
            Ok(loaded) => render_session_core::config::viewer_pid_file_path(&loaded, &project_root),
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    project = %project_root.display(),
                    "run_serve: config load failed; using default viewer.json path"
                );
                project_root.join(".claude/site").join("viewer.json")
            }
        };

        let entry = render_session_core::viewer::PidFileEntry {
            pid,
            port,
            started_at: {
                // RFC 3339 timestamp using std (no chrono dep needed here).
                let secs = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_secs();
                // Format as a basic ISO-8601 UTC string sufficient for diagnostics.
                format_unix_utc(secs)
            },
            tick: watch_tick,
        };

        tokio::task::spawn_blocking(move || {
            render_session_core::viewer::write_atomic(&pid_file_path, &entry)
        })
        .await
        .context("run_serve: viewer.json self-write task panicked")?
        .context("run_serve: viewer.json self-write failed")?;

        tracing::info!(
            pid,
            port,
            "run_serve: wrote viewer.json (detached lifecycle)"
        );
    }

    let watch_tick_dur = watch_tick.map(|t| {
        let clamped = if t == 0 {
            tracing::warn!("watch_tick=0 not allowed, clamping to 1");
            1
        } else {
            t
        };
        std::time::Duration::from_secs(clamped)
    });
    render_session_core::serve::run(port, std::path::PathBuf::from(dir), watch_tick_dur).await?;
    Ok(())
}

/// Format a Unix timestamp (seconds since epoch) as a minimal UTC ISO-8601 string.
///
/// Produces `"YYYY-MM-DDTHH:MM:SSZ"` suitable for `PidFileEntry.started_at`.
/// Uses integer arithmetic only (no external time crate dependency in this
/// binary crate).
///
/// # Arguments
///
/// * `secs` – Seconds since the Unix epoch (1970-01-01T00:00:00Z).
///
/// # Returns
///
/// A `String` in `YYYY-MM-DDTHH:MM:SSZ` format.
fn format_unix_utc(secs: u64) -> String {
    // Time-of-day components.
    let sec_of_day = secs % 86400;
    let s = sec_of_day % 60;
    let m = (sec_of_day / 60) % 60;
    let h = sec_of_day / 3600;

    // Day number since 1970-01-01.
    let z = (secs / 86400) as i64;

    // Howard Hinnant civil_from_days algorithm (public domain).
    // Reference: https://howardhinnant.github.io/date_algorithms.html
    let z = z + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = (z - era * 146097) as u64; // day of era [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era [0, 399]
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
    let mp = (5 * doy + 2) / 153; // month [0, 11]
    let day = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
    let month = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
    let year = y + if month <= 2 { 1 } else { 0 };

    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        year, month, day, h, m, s
    )
}

async fn run_gen(project: String) -> anyhow::Result<()> {
    use anyhow::Context as _;
    let project_path = std::path::PathBuf::from(&project);

    let loaded =
        render_session_core::config::load(&project_path).context("gen: failed to load config")?;

    let site_shell = render_session_core::config::site_shell(&loaded, &project_path);

    let list_path =
        render_session_core::gen::emit_list_json(&loaded.config, &project_path, &site_shell)
            .map_err(|e| {
                tracing::error!(error = %e, project = %project, "emit_list_json failed");
                e
            })?;
    tracing::info!(file = %list_path.display(), "list.json emitted");
    println!("{}", list_path.display());

    let rules_path =
        render_session_core::gen::emit_rules_md(&loaded.config, &project_path, &site_shell)
            .map_err(|e| {
                tracing::error!(error = %e, project = %project, "emit_rules_md failed");
                e
            })?;
    tracing::info!(file = %rules_path.display(), "render-lanes.md emitted");
    println!("{}", rules_path.display());
    Ok(())
}

async fn run_report(dir: String, title: String) -> anyhow::Result<()> {
    use tokio::io::AsyncReadExt as _;
    let mut body = String::new();
    tokio::io::stdin()
        .read_to_string(&mut body)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "failed to read stdin for report");
            e
        })?;
    let project_dir = std::path::Path::new(&dir);
    // Resolve target dir via SSOT (config::category_dir_or_default).
    // Warn + new default `.claude/site/reports` on config load failure.
    let target_dir = render_session_core::config::category_dir_or_default(project_dir, "reports");
    let path = render_session_core::writer::write_report(&target_dir, &title, &body)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, dir = %dir, title = %title, "write_report failed");
            e
        })?;
    println!("{}", path.display());
    Ok(())
}

async fn run_capture(
    project: std::path::PathBuf,
    mode: String,
    slug: Option<String>,
    n: usize,
    all: bool,
) -> anyhow::Result<()> {
    let claude_slug = match slug {
        Some(s) => s,
        None => derive_slug(&project).map_err(|e| {
            tracing::error!(project = %project.display(), error = %e, "derive_slug failed");
            e
        })?,
    };
    // Phase 4d: load config via unified figment stack; yaml absent or parse error →
    // warn + Config::default() fallback so capture always continues (invariant #16).
    let config = match render_session_core::config::load(&project) {
        Ok(loaded) => loaded.config,
        Err(e) => {
            tracing::warn!(
                project = %project.display(),
                error = %e,
                "capture: config load failed, using default"
            );
            render_session_core::config::Config::default()
        }
    };
    let filter_chain_cfg = config
        .categories
        .get("recent")
        .and_then(|c| c.filter.as_ref());
    let filter_registry: Option<render_session_core::filters::FilterRegistry> =
        filter_chain_cfg.map(|_| render_session_core::filters::FilterRegistry::with_builtins());
    // Base filter_arg from config (None when no config chain is set).
    let config_filter_arg: Option<(
        &render_session_core::filters::FilterRegistry,
        &render_session_core::filters::FilterChainConfig,
    )> = match (filter_registry.as_ref(), filter_chain_cfg) {
        (Some(reg), Some(cfg)) => Some((reg, cfg)),
        _ => None,
    };

    // When --all is set, bypass visual filtering entirely by passing an override
    // chain with empty steps and FilterMode::All.  Empty chain + All evaluates to
    // vacuous true (all() over empty iterator = true), so every turn passes.
    let override_registry;
    let override_chain;
    let filter_arg: Option<(
        &render_session_core::filters::FilterRegistry,
        &render_session_core::filters::FilterChainConfig,
    )> = if all {
        // Empty chain + FilterMode::All evaluates to vacuous true (pass-all bypass).
        override_registry = render_session_core::filters::FilterRegistry::with_builtins();
        override_chain = render_session_core::filters::FilterChainConfig {
            steps: vec![],
            mode: render_session_core::filters::FilterMode::All,
        };
        Some((&override_registry, &override_chain))
    } else {
        config_filter_arg
    };

    match mode.as_str() {
        "session" => {
            // session mode: filter not applied (single-item capture).
            if let Some(p) =
                render_session_core::sources::session::capture_session(&project, &claude_slug)
                    .map_err(|e| {
                        tracing::error!(error = %e, "capture_session failed");
                        e
                    })?
            {
                println!("{}", p.display());
            }
        }
        "recent" => {
            for p in render_session_core::sources::session::capture_recent(
                &project,
                &claude_slug,
                n,
                filter_arg,
            )
            .map_err(|e| {
                tracing::error!(error = %e, "capture_recent failed");
                e
            })? {
                println!("{}", p.display());
            }
        }
        "both" => {
            // session: filter not applied
            if let Some(p) =
                render_session_core::sources::session::capture_session(&project, &claude_slug)
                    .map_err(|e| {
                        tracing::error!(error = %e, "capture_session failed");
                        e
                    })?
            {
                println!("{}", p.display());
            }
            // recent: filter applied if configured (or override chain if --all)
            for p in render_session_core::sources::session::capture_recent(
                &project,
                &claude_slug,
                n,
                filter_arg,
            )
            .map_err(|e| {
                tracing::error!(error = %e, "capture_recent failed");
                e
            })? {
                println!("{}", p.display());
            }
        }
        other => {
            anyhow::bail!("invalid mode {:?}, expected session|recent|both", other);
        }
    }
    Ok(())
}

async fn run_info(project: Option<PathBuf>, format: Option<String>) -> anyhow::Result<()> {
    use anyhow::Context as _;
    use render_session_core::info::build_info_report;
    use render_session_core::lease::LeaseStore;

    let fmt = format.as_deref().unwrap_or("table");
    if fmt != "json" && fmt != "table" {
        anyhow::bail!("invalid format {:?}, expected json|table", fmt);
    }

    let project_root = resolve_project_dir(project);
    let store_path = render_session_core::lease::store_path()
        .context("info: failed to resolve lease store path")?;
    let store = LeaseStore::load(store_path)
        .await
        .context("info: failed to load lease store")?;

    let report = build_info_report(&project_root, env!("CARGO_PKG_VERSION"), &store)
        .context("info: failed to build report")?;

    if fmt == "json" {
        println!(
            "{}",
            serde_json::to_string_pretty(&report).context("info: JSON serialize")?
        );
    } else {
        print!("{}", format_info_table(&report));
    }
    Ok(())
}

/// Format an [`render_session_core::info::InfoReport`] as a human-readable table string.
///
/// Produces 5 sections separated by blank lines:
/// `Project` / `Version` / `Mounts` / `Viewer`.
fn format_info_table(report: &render_session_core::info::InfoReport) -> String {
    let mut out = String::new();

    out.push_str(&format!("Project: {}", report.project_root.display()));
    out.push('\n');

    out.push('\n');
    out.push_str(&format!("Version: {}", report.binary_version));
    out.push('\n');

    out.push('\n');
    out.push_str("Mounts:\n");
    for m in &report.mounts {
        out.push_str(&format!(
            "  {}: {} (source: {}, exists: {}, files: {})\n",
            m.name,
            m.dir.display(),
            m.source_layer,
            m.exists,
            m.files_count
        ));
    }

    out.push('\n');
    out.push_str("Viewer:\n");
    match &report.viewer {
        Some(v) => {
            out.push_str(&format!(
                "  pid={} port={} dir={} started_at={}\n",
                v.pid,
                v.port,
                v.dir.display(),
                v.started_at
            ));
        }
        None => {
            out.push_str("  (none)\n");
        }
    }

    out
}

/// Derive the Claude Code project slug from a project directory path.
///
/// Canonicalizes the path and replaces every `/` with `-`, matching legacy
/// `render-session.py:41`.  Intended for macOS/Linux only; Windows path
/// separator handling is deferred to Phase 6+.
fn derive_slug(project: &std::path::Path) -> anyhow::Result<String> {
    let canonical = project.canonicalize().map_err(|e| {
        tracing::error!(project = %project.display(), error = %e, "canonicalize failed");
        e
    })?;
    Ok(canonical.to_string_lossy().replace('/', "-"))
}

// ---------------------------------------------------------------------------
// Commands::Config subcommand definitions
// ---------------------------------------------------------------------------

/// Subcommands for `render-session config`.
#[derive(Debug, Subcommand)]
enum ConfigSubcommand {
    /// Print the effective merged configuration as YAML.
    ///
    /// With `--origin`, each leaf value is annotated with the config layer
    /// (file path or env var) that last set it.
    Show {
        /// Project directory whose config should be resolved.
        #[arg(long)]
        project: PathBuf,

        /// Annotate each value with its provenance (origin layer).
        #[arg(long, default_value_t = false)]
        origin: bool,
    },

    /// List the config layer paths and active env overrides.
    ///
    /// Shows which config files were found (Loaded/Absent) and which
    /// `RENDER_SESSION_*` environment variables are active.
    Info {
        /// Project directory whose config should be resolved.
        /// Defaults to the current working directory.
        #[arg(long)]
        project: Option<PathBuf>,
    },

    /// Run health checks on the config stack.
    ///
    /// Checks that layer files exist, that `RENDER_SESSION_CONFIG` (if set)
    /// points to a readable file, and that env overrides parse successfully.
    /// Exits with code 0 when all checks pass, 1 otherwise.
    Doctor {
        /// Project directory whose config should be diagnosed.
        /// Defaults to the current working directory.
        #[arg(long)]
        project: Option<PathBuf>,
    },
}

// ---------------------------------------------------------------------------
// DoctorCheck — structured diagnostic result
// ---------------------------------------------------------------------------

/// A single named check result produced by `config doctor`.
struct DoctorCheck {
    /// Short name identifying this check.
    name: String,
    /// Whether the check passed.
    ok: bool,
    /// Human-readable detail message (present for both pass and fail).
    message: Option<String>,
}

// ---------------------------------------------------------------------------
// Config handler helpers
// ---------------------------------------------------------------------------

/// Dispatch `config <subcommand>` to the appropriate handler.
///
/// # Arguments
/// - `sub`: which `ConfigSubcommand` to execute.
///
/// # Returns
/// `Ok(())` on success, or an `anyhow::Error` on failure.
async fn run_config(sub: ConfigSubcommand) -> anyhow::Result<()> {
    match sub {
        ConfigSubcommand::Show { project, origin } => handle_config_show(project, origin).await,
        ConfigSubcommand::Info { project } => handle_config_info(project).await,
        ConfigSubcommand::Doctor { project } => handle_config_doctor(project).await,
    }
}

/// Print the effective merged configuration as YAML.
///
/// When `origin` is `true`, each leaf value is annotated with a `# origin:`
/// comment derived from `LoadedConfig::origin_of`.  The `flatten_keys` helper
/// walks the `serde_yaml::Value` tree to enumerate all dotted keys so that
/// provenance can be queried per-key.
///
/// # Arguments
/// - `project`: project root directory.
/// - `origin`: if `true`, annotate each value line with its origin layer.
///
/// # Errors
/// Returns an error if the config fails to load or if YAML serialization
/// of the effective config fails.
async fn handle_config_show(project: PathBuf, origin: bool) -> anyhow::Result<()> {
    use anyhow::Context as _;

    let loaded = render_session_core::config::load(&project)
        .context("config show: failed to load config")?;

    let yaml = serde_yaml::to_string(&loaded.config)
        .context("config show: failed to serialize config as YAML")?;

    if !origin {
        print!("{yaml}");
        return Ok(());
    }

    // Build dotted-key → origin map for annotation.
    let value: serde_yaml::Value =
        serde_yaml::from_str(&yaml).context("config show: failed to re-parse YAML for origin")?;
    let keys = flatten_keys(&value, "");

    // Print YAML with inline origin comments.
    // Strategy: re-serialize each line with the associated key's origin appended.
    // We walk line-by-line matching known keys to keep output readable.
    // For simplicity we append origins as a trailing comment block.
    println!("# Effective configuration (with provenance)");
    print!("{yaml}");
    if !keys.is_empty() {
        println!("# --- origin annotations ---");
        for key in &keys {
            // `figment::Source` implements `Display`; format it without importing
            // the figment crate directly in the bin layer.
            let origin_label = match loaded.origin_of(key) {
                Some(src) => format!("{src}"),
                None => "unknown".to_string(),
            };
            println!("# {key}: {origin_label}");
        }
    }
    Ok(())
}

/// List the config layer paths and active env overrides.
///
/// Prints each layer (UserGlobal / Project / Lane / EnvOverride) with its
/// file path and load status.  Env override values are masked as
/// `***** (N chars)` to prevent secret leakage.
///
/// # Arguments
/// - `project`: project root directory. Falls back to `$PWD` when `None`.
///
/// # Errors
/// Returns an error if config loading fails.
async fn handle_config_info(project: Option<PathBuf>) -> anyhow::Result<()> {
    use anyhow::Context as _;

    let project = resolve_project_dir(project);
    let loaded = render_session_core::config::load(&project)
        .context("config info: failed to load config")?;

    println!("Layer paths:");
    for (kind, path, status) in loaded.layer_paths() {
        println!("  {kind:?}: {} [{status:?}]", path.display());
    }

    println!("\nEnv overrides:");
    let overrides = loaded.env_overrides();
    if overrides.is_empty() {
        println!("  (none)");
    } else {
        for (name, value) in overrides {
            println!("  {name} = ***** ({} chars)", value.len());
        }
    }

    Ok(())
}

/// Run health checks on the config stack and report results.
///
/// Checks performed:
/// 1. User-global config file exists (Loaded status).
/// 2. Project config file exists (Loaded status).
/// 3. Lane config file exists (Loaded status).
/// 4. `RENDER_SESSION_CONFIG` path is readable when the env var is set.
/// 5. Active `RENDER_SESSION_*` env overrides parse cleanly through figment.
///
/// All checks are collected before printing; no check panics on failure.
/// Exits with `process::exit(1)` when one or more checks fail.
///
/// # Arguments
/// - `project`: project root directory. Falls back to `$PWD` when `None`.
///
/// # Errors
/// Returns an error if the config itself cannot be loaded (before checks run).
async fn handle_config_doctor(project: Option<PathBuf>) -> anyhow::Result<()> {
    use anyhow::Context as _;
    use render_session_core::config::LayerStatus;

    let project = resolve_project_dir(project);
    let loaded = render_session_core::config::load(&project)
        .context("config doctor: failed to load config")?;

    let mut checks: Vec<DoctorCheck> = Vec::new();

    // Checks 1–3: layer file existence derived from layer_paths accessor.
    for (kind, path, status) in loaded.layer_paths() {
        let name = format!("{kind:?} layer");
        let (ok, message) = match status {
            LayerStatus::Loaded => (true, format!("found: {}", path.display())),
            LayerStatus::Absent => {
                // Absent is not a hard failure; the layer is optional.
                (true, format!("absent (optional): {}", path.display()))
            }
            LayerStatus::Failed(err) => (false, format!("parse error: {err}")),
        };
        checks.push(DoctorCheck {
            name,
            ok,
            message: Some(message),
        });
    }

    // Check 4: RENDER_SESSION_CONFIG path exists when set.
    if let Ok(cfg_path) = std::env::var("RENDER_SESSION_CONFIG") {
        if !cfg_path.is_empty() {
            let p = std::path::Path::new(&cfg_path);
            let (ok, msg) = if p.exists() {
                (
                    true,
                    format!("RENDER_SESSION_CONFIG path exists: {cfg_path}"),
                )
            } else {
                (
                    false,
                    format!("RENDER_SESSION_CONFIG path not found: {cfg_path}"),
                )
            };
            checks.push(DoctorCheck {
                name: "RENDER_SESSION_CONFIG path".to_string(),
                ok,
                message: Some(msg),
            });
        }
    }

    // Check 5: env overrides parse cleanly (re-extract with figment to surface
    // type-mismatch errors, e.g. RENDER_SESSION_CATEGORIES__X__ENABLED=notabool).
    let override_count = loaded.env_overrides().len();
    if override_count > 0 {
        // The load() call already ran figment.extract::<Config>() successfully,
        // so if we reached here the overrides parsed. Report as passing.
        checks.push(DoctorCheck {
            name: "env overrides parse".to_string(),
            ok: true,
            message: Some(format!(
                "{override_count} active override(s) parsed successfully"
            )),
        });
    }

    // Print results.
    let mut fail_count = 0usize;
    for check in &checks {
        let symbol = if check.ok { "OK" } else { "FAIL" };
        let detail = check.message.as_deref().unwrap_or("no detail");
        println!("[{symbol}] {}: {detail}", check.name);
        if !check.ok {
            fail_count += 1;
        }
    }

    if fail_count > 0 {
        tracing::warn!(fail_count, "config doctor found issue(s)");
        println!("\ndoctor: {fail_count} issue(s) found");
        // Use process::exit to guarantee exit code 1 (anyhow::Error propagates
        // as exit code 1 through tokio::main, but process::exit is explicit).
        std::process::exit(1);
    } else {
        println!("\ndoctor: all checks passed");
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Config helper utilities
// ---------------------------------------------------------------------------

/// Resolve the project directory: use the provided path or fall back to `$PWD`.
///
/// # Arguments
/// - `project`: optional explicit project path.
///
/// # Returns
/// The resolved `PathBuf`. Falls back to `PathBuf::from(".")` if `current_dir`
/// is unavailable.
fn resolve_project_dir(project: Option<PathBuf>) -> PathBuf {
    project.unwrap_or_else(|| {
        std::env::current_dir().unwrap_or_else(|e| {
            tracing::warn!(error = %e, "current_dir() failed, falling back to '.'");
            PathBuf::from(".")
        })
    })
}

/// Flatten a `serde_yaml::Value` into dotted-path key strings.
///
/// Recursively walks mappings and concatenates key segments with `.`.
/// Scalar (non-mapping) leaves are emitted as fully-qualified dotted keys.
/// Sequence values are emitted at the parent key level (sequences are not
/// further descended into).
///
/// # Arguments
/// - `v`: the YAML value to walk.
/// - `prefix`: accumulated key prefix (empty string at the top level).
///
/// # Returns
/// A `Vec<String>` of all leaf dotted-key paths in document order.
fn flatten_keys(v: &serde_yaml::Value, prefix: &str) -> Vec<String> {
    let mut out = Vec::new();
    if let serde_yaml::Value::Mapping(map) = v {
        for (k, val) in map {
            if let Some(key) = k.as_str() {
                let full = if prefix.is_empty() {
                    key.to_string()
                } else {
                    format!("{prefix}.{key}")
                };
                match val {
                    serde_yaml::Value::Mapping(_) => out.extend(flatten_keys(val, &full)),
                    _ => out.push(full),
                }
            }
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use render_session_core::info::{InfoReport, MountInfo, ViewerStatus};
    use std::path::PathBuf;

    /// Verify that format_info_table produces all expected sections.
    #[test]
    fn test_format_info_table_sections() {
        let report = InfoReport {
            binary_version: "0.3.0".to_string(),
            project_root: PathBuf::from("/tmp/test-project"),
            data_root: PathBuf::from("/tmp/test-project/.claude/site"),
            data_root_source_layer: "default".to_string(),
            mounts: vec![MountInfo {
                name: "recent".to_string(),
                enabled: true,
                dir: PathBuf::from("/tmp/test-project/render-site/recent"),
                exists: true,
                files_count: 3,
                source_layer: "default".to_string(),
            }],
            viewer: Some(ViewerStatus {
                pid: 12345,
                port: 8000,
                dir: PathBuf::from("/tmp/test-project"),
                started_at: "2026-01-01T00:00:00Z".to_string(),
            }),
        };

        let output = super::format_info_table(&report);
        assert!(
            output.contains("Project: /tmp/test-project"),
            "missing Project section"
        );
        assert!(output.contains("Version: 0.3.0"), "missing Version section");
        assert!(output.contains("Mounts:"), "missing Mounts section");
        assert!(output.contains("recent:"), "missing recent mount");
        assert!(output.contains("source: default"), "missing source_layer");
        assert!(output.contains("files: 3"), "missing files_count");
        assert!(output.contains("Viewer:"), "missing Viewer section");
        assert!(output.contains("pid=12345"), "missing pid");
        assert!(output.contains("port=8000"), "missing port");
    }

    /// Verify that format_info_table shows (none) when viewer is absent.
    #[test]
    fn test_format_info_table_no_viewer() {
        let report = InfoReport {
            binary_version: "0.3.0".to_string(),
            project_root: PathBuf::from("/tmp/no-viewer"),
            data_root: PathBuf::from("/tmp/no-viewer/.claude/site"),
            data_root_source_layer: "default".to_string(),
            mounts: vec![],
            viewer: None,
        };

        let output = super::format_info_table(&report);
        assert!(
            output.contains("(none)"),
            "expected (none) for absent viewer"
        );
    }

    /// Verify that spawning a short-lived process and awaiting its status works correctly.
    ///
    /// We spawn `true` (exits 0) and verify status.success() == true.
    /// We also verify that dropping the Future before poll does NOT kill the child
    /// (kill_on_drop defaults to false for tokio::process::Command).
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_mcp_commands_status_await() {
        // Spawn a process that exits immediately with success.
        // Use `/bin/true` on Unix (always exits 0).
        let true_bin = if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
            "true"
        } else {
            "cmd"
        };

        let status = tokio::process::Command::new(true_bin)
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .await
            .expect("spawn `true`");

        assert!(status.success(), "`true` must exit with success");

        // Now verify kill_on_drop=false behaviour: spawn a long-running process,
        // drop the Child handle before it exits, then verify the process is still alive.
        let child = tokio::process::Command::new("sleep")
            .arg("30")
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .kill_on_drop(false)
            .spawn()
            .expect("spawn `sleep 30`");

        let pid = child.id().expect("child has pid") as libc::pid_t;

        // Drop the Child handle — with kill_on_drop=false the process keeps running.
        drop(child);

        // Give the OS a moment to process.
        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;

        // kill(pid, 0) == 0 means process exists.
        #[allow(unsafe_code)]
        let probe = unsafe { libc::kill(pid, 0) };
        assert_eq!(
            probe, 0,
            "child should still be alive after handle drop (kill_on_drop=false)"
        );

        // Clean up.
        #[allow(unsafe_code)]
        unsafe {
            libc::kill(pid, libc::SIGTERM);
        }
    }

    // -------------------------------------------------------------------------
    // Tests for format_unix_utc
    // -------------------------------------------------------------------------

    /// T1 (happy-path): format_unix_utc(0) must produce the Unix epoch string.
    #[test]
    fn test_format_unix_utc_epoch() {
        let s = super::format_unix_utc(0);
        assert_eq!(
            s, "1970-01-01T00:00:00Z",
            "epoch timestamp must format correctly"
        );
    }

    /// T1 (happy-path): format_unix_utc at a known modern timestamp.
    /// 2024-01-01T00:00:00Z = 1704067200 seconds since epoch.
    #[test]
    fn test_format_unix_utc_known_date() {
        let s = super::format_unix_utc(1_704_067_200);
        assert_eq!(
            s, "2024-01-01T00:00:00Z",
            "known timestamp 1704067200 must format as 2024-01-01T00:00:00Z"
        );
    }

    /// T2 (boundary): format_unix_utc with a leap-year date.
    /// 2000-02-29T00:00:00Z = 951782400 seconds since epoch.
    #[test]
    fn test_format_unix_utc_leap_year() {
        let s = super::format_unix_utc(951_782_400);
        assert_eq!(
            s, "2000-02-29T00:00:00Z",
            "leap day 2000-02-29 must format correctly"
        );
    }

    // -------------------------------------------------------------------------
    // Tests for run_serve lifecycle dispatch
    // -------------------------------------------------------------------------

    /// T1 (happy-path): Verify that `run_serve` with `Detached` lifecycle writes
    /// `viewer.json` and then attempts to start the serve loop.  This test uses a
    /// temp dir and verifies that `viewer.json` is created with a valid PID entry
    /// before the serve attempt.
    ///
    /// We cannot run the full serve (port may conflict) so we test the self-write
    /// side-effect in isolation by inspecting the file system after calling the
    /// helper that mimics the self-write path.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn run_serve_writes_viewer_json_on_detached_start() {
        use render_session_core::config::ViewerLifecycle;
        use render_session_core::viewer::{read_alive, write_atomic, PidFileEntry};

        // Simulate the self-write that run_serve performs for Detached lifecycle.
        let tmp_dir =
            std::env::temp_dir().join(format!("render-session-test-{}", std::process::id()));
        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
        let pid_file_path = tmp_dir.join("viewer.json");
        let pid = std::process::id();

        let entry = PidFileEntry {
            pid,
            port: 18200,
            started_at: super::format_unix_utc(
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_secs(),
            ),
            tick: None,
        };

        tokio::task::spawn_blocking({
            let p = pid_file_path.clone();
            let e = entry.clone();
            move || write_atomic(&p, &e)
        })
        .await
        .expect("spawn_blocking join")
        .expect("write_atomic");

        // verify that we are in Detached lifecycle by checking the lifecycle type.
        assert_eq!(ViewerLifecycle::default(), ViewerLifecycle::Detached);

        let result = read_alive(&pid_file_path).expect("read_alive");
        assert_eq!(
            result,
            Some(entry),
            "viewer.json must contain the written entry with current PID"
        );
    }

    /// T1 (happy-path): Verify the lifecycle dispatch logic: `Detached` must NOT
    /// spawn `watch_parent_death`, while `Session` must spawn it.
    ///
    /// This test verifies the observable difference by checking that in detached
    /// mode the function does NOT immediately exit when ppid changes (because
    /// watch_parent_death is not spawned).  We test this as a unit by checking
    /// the `ViewerLifecycle` comparison directly used in run_serve.
    #[test]
    fn run_serve_skips_watch_parent_death_for_detached_lifecycle() {
        use render_session_core::config::ViewerLifecycle;

        // The guard in run_serve is: if lifecycle != ViewerLifecycle::Detached { spawn(...); }
        // Verify the enum comparison is correct.
        let detached = ViewerLifecycle::Detached;
        let session = ViewerLifecycle::Session;
        let launchd = ViewerLifecycle::Launchd;

        // Detached must NOT trigger watch_parent_death spawn.
        assert!(
            !(detached != ViewerLifecycle::Detached),
            "Detached lifecycle must skip watch_parent_death"
        );

        // Session must trigger watch_parent_death spawn.
        assert!(
            session == ViewerLifecycle::Session,
            "Session lifecycle must trigger watch_parent_death"
        );
        // Launchd must NOT trigger watch_parent_death (parent is always PID 1 on macOS).
        assert!(
            launchd != ViewerLifecycle::Session,
            "Launchd lifecycle must NOT trigger watch_parent_death"
        );
    }
}
