mod admin;
mod api;
mod config;
mod mcp;
mod nostr_transport;
mod persistence;
mod protocol;
mod router;
mod scheduler;
mod server;
mod state;
mod tmux;
mod transport;

use anyhow::Context;
use clap::{Parser, Subcommand};
use nostr_sdk::ToBech32;

#[derive(Parser)]
#[command(name = "ouija", about = "Cross-machine AI session daemon")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Start the daemon
    Start {
        #[arg(short, long, default_value = "7880")]
        port: u16,
        #[arg(short, long)]
        name: Option<String>,
        #[arg(long)]
        data: Option<String>,
        /// Connect to a node using an nprofile1 ticket
        #[arg(long)]
        ticket: Option<String>,
        /// Additional nostr relay URLs (repeatable)
        #[arg(long = "relay")]
        relays: Vec<String>,
    },
    /// Show daemon status
    Status,
    /// List connected and saved nodes
    Nodes,
    /// Print connection ticket for this daemon
    Ticket {
        /// Additional relay URLs for ticket generation (repeatable)
        #[arg(long = "relay")]
        relays: Vec<String>,
    },
    /// Regenerate the connection ticket (invalidates the old one)
    RegenerateTicket {
        /// Skip confirmation prompt
        #[arg(long)]
        yes: bool,
    },
    /// Connect to a node using a ticket
    Connect {
        ticket: String,
        /// Optional name to identify this node
        #[arg(long)]
        name: Option<String>,
    },
    /// Disconnect from a remote node
    Disconnect {
        /// Node name or daemon npub to disconnect
        node: String,
    },
    /// Register a session
    Register {
        id: String,
        pane: Option<String>,
        #[arg(long)]
        vim_mode: bool,
        #[arg(long)]
        project_dir: Option<String>,
        #[arg(long)]
        role: Option<String>,
    },
    /// Send a message through the daemon
    Send { to: String, message: String },
    /// Inject directly into a tmux pane
    Inject { pane: String, message: String },
    /// Rename a session
    Rename { old_id: String, new_id: String },
    /// Remove a session
    Remove { id: String },
    /// Stop the running daemon
    Stop,
    /// Print the message log file path
    LogPath {
        #[arg(long)]
        data: Option<String>,
    },
    /// Update ouija from crates.io and restart daemon
    Update,
    /// View or change daemon settings
    Config {
        #[command(subcommand)]
        action: Option<ConfigAction>,
    },
    /// Manage scheduled tasks
    Task {
        #[command(subcommand)]
        action: TaskAction,
    },
}

#[derive(Subcommand)]
enum ConfigAction {
    /// Set a config value (e.g. ouija config set auto_register false)
    Set { key: String, value: String },
    /// Add a human Nostr session
    AddHuman {
        /// The human's Nostr public key (npub1...)
        #[arg(long)]
        npub: String,
        /// Session name for this human
        #[arg(long)]
        name: String,
        /// Grant admin privileges
        #[arg(long)]
        admin: bool,
        /// Default session to route unprefixed messages to
        #[arg(long)]
        default_session: Option<String>,
    },
    /// Remove a human Nostr session
    RemoveHuman {
        /// Name of the human session to remove
        #[arg(long)]
        name: String,
    },
    /// List configured human sessions
    ListHumans,
    /// Configure the LLM router for human DMs
    SetRouter {
        /// Anthropic API key (falls back to ANTHROPIC_API_KEY env var if omitted)
        #[arg(long)]
        api_key: Option<String>,
        /// Model to use (default: claude-haiku-4-5-20251001)
        #[arg(long)]
        model: Option<String>,
        /// Base URL (default: https://api.anthropic.com)
        #[arg(long)]
        base_url: Option<String>,
    },
    /// Remove LLM router configuration
    RemoveRouter,
}

#[derive(Subcommand)]
enum TaskAction {
    /// List all scheduled tasks
    List,
    /// Add a new scheduled task (cron in UTC)
    Add {
        name: String,
        /// Cron expression (e.g. "*/5 * * * *"), evaluated in UTC
        cron: String,
        /// Target session ID
        target: String,
        /// Message to inject
        message: String,
        /// Override project dir for session revival
        #[arg(long)]
        project_dir: Option<String>,
        /// Fire once then auto-delete
        #[arg(long)]
        once: bool,
    },
    /// Remove a scheduled task
    Remove { id: String },
    /// Enable a disabled task
    Enable { id: String },
    /// Disable a task
    Disable { id: String },
    /// Show recent task executions
    Runs {
        /// Filter by task ID
        #[arg(long)]
        task: Option<String>,
    },
    /// Manually trigger a task now
    Trigger { id: String },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Install rustls CryptoProvider before any TLS connections (nostr, reqwest).
    rustls::crypto::ring::default_provider()
        .install_default()
        .expect("failed to install rustls CryptoProvider");

    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "ouija=info".parse().expect("valid default filter")),
        )
        .init();

    let cli = Cli::parse();

    match cli.command {
        Command::Start {
            port,
            name,
            data,
            ticket,
            relays,
        } => {
            let name = name.unwrap_or_else(|| {
                hostname::get()
                    .ok()
                    .and_then(|h| h.into_string().ok())
                    .unwrap_or_else(|| "ouija".to_string())
            });

            // Load nostr keys early — the npub serves as the daemon's universal identity.
            // Data dir must be created before loading keys.
            let data_dir = data
                .as_deref()
                .map(std::path::PathBuf::from)
                .unwrap_or_else(|| {
                    let base = std::env::var("XDG_DATA_HOME")
                        .map(std::path::PathBuf::from)
                        .unwrap_or_else(|_| {
                            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
                            std::path::PathBuf::from(home).join(".local/share")
                        });
                    base.join("ouija")
                });
            std::fs::create_dir_all(&data_dir)?;
            let nostr_keys = nostr_transport::load_or_create_keys(&data_dir)?;
            let npub = nostr_keys
                .public_key()
                .to_bech32()
                .unwrap_or_else(|_| "unknown".into());
            tracing::info!("daemon identity: {npub}");

            let config = config::OuijaConfig::new(name, port, data, npub)?;
            let state = state::AppState::new(config);

            // Setup nostr transport in the background so HTTP starts immediately.
            let bg_state = state.clone();
            tokio::spawn(async move {
                setup_nostr_transport(&bg_state, ticket.as_deref(), relays).await;
            });

            // Reap dead sessions, auto-register, and broadcast on change
            let reaper_state = state.clone();
            tokio::spawn(async move {
                let mut last_session_hash: u64 = 0;
                let mut first_run = true;

                loop {
                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;

                    // Reap dead local sessions and announce removals
                    let reaped = reaper_state.reap_dead_sessions().await;
                    if !reaped.is_empty() {
                        for id in &reaped {
                            let msg = crate::protocol::WireMessage::SessionRemove {
                                id: id.clone(),
                                daemon_id: reaper_state.config.npub.clone(),
                                daemon_name: reaper_state.config.name.clone(),
                            };
                            transport::broadcast(&reaper_state, &msg).await;
                        }
                    }

                    // Broadcast full session list only on startup or when it changes
                    let current_hash = reaper_state.local_session_hash().await;
                    if first_run || current_hash != last_session_hash {
                        transport::broadcast_local_sessions(&reaper_state).await;
                        last_session_hash = current_hash;
                        first_run = false;
                    }
                }
            });

            // Idle nudge + pairing evaluator
            let bb_state = state.clone();
            tokio::spawn(async move {
                bulletin_board_loop(bb_state).await;
            });

            // Run scheduler loop for periodic tasks
            let scheduler_state = state.clone();
            tokio::spawn(crate::scheduler::run_scheduler(scheduler_state));

            server::run(state).await?;
        }
        Command::Status => {
            cli_get("/api/status").await?;
        }
        Command::Nodes => {
            let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
            let url = format!("http://localhost:{port}/api/nodes");
            let resp: serde_json::Value = reqwest::get(&url).await?.json().await?;
            let nodes = resp["nodes"].as_array();
            match nodes {
                Some(list) if !list.is_empty() => {
                    println!("{:<12} {:<12} {:<10} SINCE", "NAME", "STATUS", "TRANSPORT");
                    for p in list {
                        let name = p["name"].as_str().unwrap_or("-");
                        let status = p["status"].as_str().unwrap_or("unknown");
                        let transport = p["transport"].as_str().unwrap_or("-");
                        let since = p["since"].as_str().unwrap_or("-");
                        println!("{:<12} {:<12} {:<10} {}", name, status, transport, since);
                    }
                }
                _ => println!("no nodes"),
            }
        }
        Command::Ticket { relays } => {
            let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
            let url = format!("http://localhost:{port}/api/ticket");
            let client = reqwest::Client::new();
            let mut req = client.get(&url);
            for r in &relays {
                req = req.query(&[("relay", r.as_str())]);
            }
            let resp: serde_json::Value = req.send().await?.json().await?;
            if let Some(ticket) = resp["ticket"].as_str() {
                println!("{ticket}");
            } else if let Some(err) = resp["error"].as_str() {
                eprintln!("error: {err}");
                std::process::exit(1);
            } else {
                println!("{}", serde_json::to_string_pretty(&resp)?);
            }
        }
        Command::RegenerateTicket { yes } => {
            if !yes {
                eprintln!(
                    "WARNING: This will destroy your nostr identity (nsec). All nodes must re-connect."
                );
                eprintln!("Run with --yes to confirm.");
                std::process::exit(1);
            }
            let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
            let url = format!("http://localhost:{port}/api/regenerate-ticket?confirm=true");
            let client = reqwest::Client::new();
            let resp: serde_json::Value = client.post(&url).send().await?.json().await?;
            if let Some(ticket) = resp["ticket"].as_str() {
                println!("{ticket}");
            } else if let Some(err) = resp["error"].as_str() {
                eprintln!("Error: {err}");
                std::process::exit(1);
            }
        }
        Command::Connect { ticket, name } => {
            let body = serde_json::json!({ "ticket": ticket, "name": name });
            cli_post("/api/connect", &body).await?;
        }
        Command::Disconnect { node } => {
            // Resolve node name to daemon_id (npub) via the nodes API
            let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
            let url = format!("http://localhost:{port}/api/status");
            let resp: serde_json::Value = reqwest::get(&url).await?.json().await?;
            let daemon_id = resp["nodes"].as_array().and_then(|nodes| {
                nodes.iter().find_map(|n| {
                    let name = n["name"].as_str().unwrap_or("");
                    let did = n["daemon_id"].as_str().unwrap_or("");
                    if name == node || did == node {
                        Some(did.to_string())
                    } else {
                        None
                    }
                })
            });
            match daemon_id {
                Some(id) => {
                    let body = serde_json::json!({ "daemon_id": id });
                    cli_post("/api/nodes/disconnect", &body).await?;
                }
                None => {
                    // Try as a raw daemon_id (npub) directly
                    let body = serde_json::json!({ "daemon_id": node });
                    cli_post("/api/nodes/disconnect", &body).await?;
                }
            }
        }
        Command::Register {
            id,
            pane,
            vim_mode,
            project_dir,
            role,
        } => {
            let pane = pane.or_else(|| std::env::var("TMUX_PANE").ok());
            let body = serde_json::json!({
                "id": id,
                "pane": pane,
                "vim_mode": vim_mode,
                "project_dir": project_dir,
                "role": role,
            });
            cli_post("/api/register", &body).await?;
        }
        Command::Send { to, message } => {
            let from = resolve_my_session_id().await;
            match from {
                Some(id) => {
                    let body = serde_json::json!({ "to": to, "message": message, "from": id });
                    cli_post("/api/send", &body).await?;
                }
                None => {
                    let pane = std::env::var("TMUX_PANE").unwrap_or_default();
                    anyhow::bail!(
                        "no session registered for this pane ({pane}).\n\
                         Run `ouija register <name>` first."
                    );
                }
            }
        }
        Command::Inject { pane, message } => {
            let body = serde_json::json!({ "pane": pane, "message": message });
            cli_post("/api/inject", &body).await?;
        }
        Command::Rename { old_id, new_id } => {
            let body = serde_json::json!({ "old_id": old_id, "new_id": new_id });
            cli_post("/api/rename", &body).await?;
        }
        Command::Remove { id } => {
            let body = serde_json::json!({ "id": id });
            cli_post("/api/remove", &body).await?;
        }
        Command::Stop => {
            stop_daemon()?;
        }
        Command::LogPath { data } => {
            let config = config::OuijaConfig::new("_".into(), 0, data, String::new())?;
            println!("{}", config.data_dir.join("messages.jsonl").display());
        }
        Command::Update => {
            update_and_restart()?;
        }
        Command::Config { action } => match action {
            None => cli_get("/api/settings").await?,
            Some(ConfigAction::Set { key, value }) => {
                let parsed: serde_json::Value = match value.as_str() {
                    "true" => serde_json::Value::Bool(true),
                    "false" => serde_json::Value::Bool(false),
                    v => serde_json::Value::String(v.to_string()),
                };
                let body = serde_json::json!({ key: parsed });
                cli_post("/api/settings", &body).await?;
            }
            Some(ConfigAction::AddHuman {
                npub,
                name,
                admin,
                default_session,
            }) => {
                if !npub.starts_with("npub1") {
                    anyhow::bail!("npub must start with 'npub1'");
                }
                let data_dir = config::OuijaConfig::default_data_dir();
                std::fs::create_dir_all(&data_dir)?;
                let mut settings = persistence::load_settings(&data_dir)?;
                if settings.human_sessions.iter().any(|h| h.name == name) {
                    anyhow::bail!("human session '{name}' already exists");
                }
                settings.human_sessions.push(persistence::HumanSession {
                    npub,
                    name: name.clone(),
                    admin,
                    default_session,
                    welcomed: false,
                });
                persistence::save_settings(&data_dir, &settings)?;
                println!("added human session '{name}'");
            }
            Some(ConfigAction::RemoveHuman { name }) => {
                let data_dir = config::OuijaConfig::default_data_dir();
                let mut settings = persistence::load_settings(&data_dir)?;
                let before = settings.human_sessions.len();
                settings.human_sessions.retain(|h| h.name != name);
                if settings.human_sessions.len() == before {
                    anyhow::bail!("human session '{name}' not found");
                }
                persistence::save_settings(&data_dir, &settings)?;
                println!("removed human session '{name}'");
            }
            Some(ConfigAction::ListHumans) => {
                let data_dir = config::OuijaConfig::default_data_dir();
                let settings = persistence::load_settings(&data_dir)?;
                if settings.human_sessions.is_empty() {
                    println!("no human sessions configured");
                } else {
                    println!("{:<12} {:<20} {:<8} DEFAULT", "NAME", "NPUB", "ADMIN");
                    for h in &settings.human_sessions {
                        let npub_short = if h.npub.len() > 16 {
                            format!("{}...", &h.npub[..16])
                        } else {
                            h.npub.clone()
                        };
                        let default = h.default_session.as_deref().unwrap_or("--");
                        println!(
                            "{:<12} {:<20} {:<8} {}",
                            h.name, npub_short, h.admin, default
                        );
                    }
                }
            }
            Some(ConfigAction::SetRouter {
                api_key,
                model,
                base_url,
            }) => {
                let data_dir = config::OuijaConfig::default_data_dir();
                std::fs::create_dir_all(&data_dir)?;
                let mut settings = persistence::load_settings(&data_dir)?;
                settings.router = Some(persistence::RouterConfig {
                    api_key,
                    model: model.unwrap_or_else(|| "gemini-2.5-flash".to_string()),
                    base_url: base_url.unwrap_or_else(|| {
                        "https://generativelanguage.googleapis.com/v1beta/openai".to_string()
                    }),
                });
                persistence::save_settings(&data_dir, &settings)?;
                println!("router configured");
            }
            Some(ConfigAction::RemoveRouter) => {
                let data_dir = config::OuijaConfig::default_data_dir();
                let mut settings = persistence::load_settings(&data_dir)?;
                settings.router = None;
                persistence::save_settings(&data_dir, &settings)?;
                println!("router removed");
            }
        },
        Command::Task { action } => match action {
            TaskAction::List => {
                let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
                let url = format!("http://localhost:{port}/api/tasks");
                let resp: serde_json::Value = reqwest::get(&url).await?.json().await?;
                let tasks = resp["tasks"].as_array();
                match tasks {
                    Some(list) if !list.is_empty() => {
                        println!(
                            "{:<10} {:<16} {:<16} {:<10} {:<8} {:<20} RUNS",
                            "ID", "NAME", "CRON", "TARGET", "ENABLED", "NEXT RUN"
                        );
                        for t in list {
                            let id = t["id"].as_str().unwrap_or("-");
                            let name = t["name"].as_str().unwrap_or("-");
                            let cron = t["cron"].as_str().unwrap_or("-");
                            let target = t["target_session"].as_str().unwrap_or("-");
                            let enabled = t["enabled"].as_bool().unwrap_or(false);
                            let next = t["next_run"].as_str().unwrap_or("-");
                            let runs = t["run_count"].as_u64().unwrap_or(0);
                            println!(
                                "{:<10} {:<16} {:<16} {:<10} {:<8} {:<20} {}",
                                id, name, cron, target, enabled, next, runs
                            );
                        }
                    }
                    _ => println!("no scheduled tasks"),
                }
            }
            TaskAction::Add {
                name,
                cron,
                target,
                message,
                project_dir,
                once,
            } => {
                let body = serde_json::json!({
                    "name": name,
                    "cron": cron,
                    "target_session": target,
                    "message": message,
                    "project_dir": project_dir,
                    "once": once,
                });
                cli_post("/api/tasks", &body).await?;
            }
            TaskAction::Remove { id } => {
                let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
                let url = format!("http://localhost:{port}/api/tasks");
                let client = reqwest::Client::new();
                let body = serde_json::json!({ "id": id });
                let resp = client.delete(&url).json(&body).send().await?;
                println!("{}", resp.text().await?);
            }
            TaskAction::Enable { id } => {
                let body = serde_json::json!({ "id": id });
                cli_post("/api/tasks/enable", &body).await?;
            }
            TaskAction::Disable { id } => {
                let body = serde_json::json!({ "id": id });
                cli_post("/api/tasks/disable", &body).await?;
            }
            TaskAction::Runs { task } => {
                let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
                let mut url = format!("http://localhost:{port}/api/task-runs");
                if let Some(id) = &task {
                    url.push_str(&format!("?task={id}"));
                }
                let resp: serde_json::Value = reqwest::get(&url).await?.json().await?;
                let runs = resp["runs"].as_array();
                match runs {
                    Some(list) if !list.is_empty() => {
                        println!(
                            "{:<22} {:<12} {:<10} {:<10} ERROR",
                            "TIME", "TASK", "TARGET", "STATUS"
                        );
                        for r in list {
                            let ts = r["timestamp"].as_str().unwrap_or("-");
                            let name = r["task_name"].as_str().unwrap_or("-");
                            let target = r["target_session"].as_str().unwrap_or("-");
                            let status = r["status"].as_str().unwrap_or("-");
                            let err = r["error"].as_str().unwrap_or("");
                            println!(
                                "{:<22} {:<12} {:<10} {:<10} {}",
                                ts, name, target, status, err
                            );
                        }
                    }
                    _ => println!("no task runs"),
                }
            }
            TaskAction::Trigger { id } => {
                let body = serde_json::json!({ "id": id });
                cli_post("/api/tasks/trigger", &body).await?;
            }
        },
    }

    Ok(())
}

/// Pairing evaluator loop.
///
/// Runs every 30s. Asks Gemini to find collaboration opportunities when
/// bulletins change. Stale metadata nudging is handled client-side by the
/// session-diff hook.
async fn bulletin_board_loop(state: state::SharedState) {
    let mut last_bulletin_hash: u64 = 0;

    loop {
        tokio::time::sleep(std::time::Duration::from_secs(30)).await;

        // --- Pairing evaluator ---
        let current_hash = state.bulletin_hash().await;
        if current_hash == last_bulletin_hash {
            continue;
        }
        last_bulletin_hash = current_hash;

        let config = state.settings.read().await.router.clone();
        let Some(config) = config else { continue };

        let (snapshots, _) = router::gather_context(&state, "").await;
        let with_bulletins = snapshots.iter().filter(|s| s.bulletin.is_some()).count();
        if with_bulletins < 2 {
            continue;
        }

        match router::evaluate_pairings(&config, &snapshots).await {
            Ok(pairings) => {
                let mut delivered = state.delivered_pairings.lock().await;
                let sessions = state.sessions.read().await;
                for p in pairings {
                    let key = if p.session_a < p.session_b {
                        (p.session_a.clone(), p.session_b.clone())
                    } else {
                        (p.session_b.clone(), p.session_a.clone())
                    };
                    if delivered.contains(&key) {
                        continue;
                    }

                    // Inject nudge into session A
                    let b_role = sessions
                        .get(&p.session_b)
                        .and_then(|s| s.metadata.role.as_deref())
                        .unwrap_or("unknown");
                    let b_bulletin = sessions
                        .get(&p.session_b)
                        .and_then(|s| s.metadata.bulletin.as_deref())
                        .unwrap_or("none");
                    if let Some(pane_a) = sessions.get(&p.session_a).and_then(|s| s.pane.clone()) {
                        let msg = format!(
                            "[ouija matchmaker]: '{}' ({}) may be relevant to you.\n\
                             Reason: {}\n\
                             Their bulletin: {}\n\
                             Use session_send to reach out if useful.",
                            p.session_b, b_role, p.reason, b_bulletin
                        );
                        let lock = state.pane_lock(&pane_a);
                        let _guard = lock.lock().await;
                        let _ = tokio::task::spawn_blocking(move || {
                            crate::tmux::inject(&pane_a, &msg, false)
                        })
                        .await;
                    }

                    // Inject nudge into session B
                    let a_role = sessions
                        .get(&p.session_a)
                        .and_then(|s| s.metadata.role.as_deref())
                        .unwrap_or("unknown");
                    let a_bulletin = sessions
                        .get(&p.session_a)
                        .and_then(|s| s.metadata.bulletin.as_deref())
                        .unwrap_or("none");
                    if let Some(pane_b) = sessions.get(&p.session_b).and_then(|s| s.pane.clone()) {
                        let msg = format!(
                            "[ouija matchmaker]: '{}' ({}) may be relevant to you.\n\
                             Reason: {}\n\
                             Their bulletin: {}\n\
                             Use session_send to reach out if useful.",
                            p.session_a, a_role, p.reason, a_bulletin
                        );
                        let lock = state.pane_lock(&pane_b);
                        let _guard = lock.lock().await;
                        let _ = tokio::task::spawn_blocking(move || {
                            crate::tmux::inject(&pane_b, &msg, false)
                        })
                        .await;
                    }

                    delivered.insert(key);
                }
            }
            Err(e) => tracing::warn!("pairing eval failed: {e}"),
        }
    }
}

async fn setup_nostr_transport(
    state: &state::SharedState,
    ticket: Option<&str>,
    cli_relays: Vec<String>,
) {
    let transport = match nostr_transport::ensure_active(state, cli_relays).await {
        Ok(t) => t,
        Err(e) => {
            tracing::error!("nostr transport setup failed: {e}");
            return;
        }
    };

    restore_persisted_sessions(state).await;
    register_human_sessions(state).await;

    if let Some(ticket) = ticket
        && let Err(e) = transport.connect(ticket, state.clone(), true).await
    {
        tracing::warn!("failed to connect to ticket node: {e}");
    }

    reconnect_persisted_nodes(state.clone()).await;
    transport::broadcast_local_sessions(state).await;
}

async fn restore_persisted_sessions(state: &state::AppState) {
    let sessions = match persistence::load_sessions(&state.config.data_dir) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!("failed to load persisted sessions: {e}");
            return;
        }
    };

    if sessions.is_empty() {
        return;
    }

    // Check pane liveness on blocking thread
    let alive = tokio::task::spawn_blocking(move || {
        sessions
            .into_iter()
            .filter(|ps| ps.pane.as_ref().is_some_and(|p| crate::tmux::pane_alive(p)))
            .collect::<Vec<_>>()
    })
    .await
    .unwrap_or_default();

    if alive.is_empty() {
        return;
    }

    let mut state_sessions = state.sessions.write().await;
    for ps in &alive {
        let session = state::Session {
            id: ps.id.clone(),
            pane: ps.pane.clone(),
            origin: state::SessionOrigin::Local,
            registered_at: ps.registered_at,
            metadata: ps.metadata.clone(),
            block_interactive: false,
            pending_replies: Vec::new(),
        };
        state_sessions.insert(ps.id.clone(), session);
    }
    tracing::info!("restored {} persisted sessions", alive.len());
}

async fn register_human_sessions(state: &state::AppState) {
    let humans = state.settings.read().await.human_sessions.clone();
    if humans.is_empty() {
        return;
    }

    let mut sessions = state.sessions.write().await;
    for h in &humans {
        if sessions.contains_key(&h.name) {
            tracing::debug!("human session '{}' already registered", h.name);
            continue;
        }
        let session = state::Session {
            id: h.name.clone(),
            pane: None,
            origin: state::SessionOrigin::Human(h.npub.clone()),
            registered_at: chrono::Utc::now(),
            metadata: state::SessionMetadata {
                role: Some("human".to_string()),
                networked: false,
                ..Default::default()
            },
            block_interactive: false,
            pending_replies: Vec::new(),
        };
        sessions.insert(h.name.clone(), session);
        tracing::info!("registered human session: {}", h.name);
    }
}

async fn reconnect_persisted_nodes(state: state::SharedState) {
    let conns = match persistence::load_connections(&state.config.data_dir) {
        Ok(c) => c,
        Err(e) => {
            tracing::warn!("failed to load persisted connections: {e}");
            return;
        }
    };

    if conns.is_empty() {
        return;
    }

    let Some(transport) = state.transport_by_name("nostr").await else {
        tracing::warn!("skipping node reconnection: nostr transport not active");
        return;
    };

    let mut reconnected = 0;
    for conn in &conns {
        // Skip legacy (non-nostr) connections
        if !conn.ticket.starts_with("nprofile1") {
            tracing::info!("skipping legacy non-nostr connection");
            continue;
        }

        let label = match &conn.node_name {
            Some(name) => name.clone(),
            None => "unnamed".to_string(),
        };

        // Skip duplicate connections to the same daemon
        let npub = conn
            .daemon_npub
            .clone()
            .or_else(|| crate::api::extract_npub(&conn.ticket));
        if let Some(ref npub) = npub {
            let node_name = conn
                .node_name
                .as_deref()
                .unwrap_or(&npub[..16.min(npub.len())]);
            if let Err(existing) = state.try_add_node(npub, node_name) {
                tracing::info!(
                    "skipping duplicate connection to {label} (already connected as '{existing}')"
                );
                continue;
            }
        }

        tracing::info!("reconnecting to {label}...");
        match transport.connect(&conn.ticket, state.clone(), false).await {
            Ok(()) => reconnected += 1,
            Err(e) => tracing::warn!("failed to reconnect to {label}: {e}"),
        }
    }

    if reconnected > 0 {
        tracing::info!("reconnected to {reconnected} persisted nodes");
    }
}

fn stop_daemon() -> anyhow::Result<()> {
    use std::process::Command as Cmd;

    // Kill the ouija-daemon tmux session if it exists
    let tmux_killed = Cmd::new("tmux")
        .args(["kill-session", "-t", "ouija-daemon"])
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);

    // Also kill any "ouija start" processes
    let pkill_killed = Cmd::new("pkill")
        .args(["-f", "ouija start"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false);

    if tmux_killed || pkill_killed {
        println!("ouija daemon stopped");
    } else {
        println!("no running daemon found");
    }
    Ok(())
}

fn update_and_restart() -> anyhow::Result<()> {
    use std::process::Command as Cmd;

    let latest = fetch_latest_crate_version("ouija")?;
    let current = env!("CARGO_PKG_VERSION");
    if latest == current {
        println!("already on latest version ({current})");
        return Ok(());
    }
    println!("updating ouija {current} -> {latest}...");

    let status = Cmd::new("cargo")
        .args(["install", "ouija", "--version", &latest])
        .status()
        .context("failed to run cargo install")?;
    if !status.success() {
        anyhow::bail!("cargo install ouija --version {latest} failed");
    }

    refresh_plugin_cache();

    println!("restarting daemon...");
    stop_daemon()?;
    std::thread::sleep(std::time::Duration::from_secs(1));

    // Replace the running binary with the new one. We can't fs::copy over a
    // running executable (ETXTBSY), but we can unlink it first — the kernel
    // keeps the old inode alive for this process while the path becomes free.
    let cargo_bin = std::env::var("HOME")
        .map(|h| std::path::PathBuf::from(h).join(".cargo/bin/ouija"))
        .unwrap_or_default();
    let current_exe = std::env::current_exe().unwrap_or_default();
    if cargo_bin.exists() && current_exe != cargo_bin && current_exe.exists() {
        let _ = std::fs::remove_file(&current_exe);
        if let Err(e) = std::fs::copy(&cargo_bin, &current_exe) {
            eprintln!("warning: could not update {}: {e}", current_exe.display());
        }
    }

    Cmd::new("ouija")
        .arg("start")
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .context("failed to spawn ouija start")?;

    let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
    let status_url = format!("http://localhost:{port}/api/status");
    for i in 0..20 {
        std::thread::sleep(std::time::Duration::from_millis(500));
        if Cmd::new("curl")
            .args(["-sf", &status_url])
            .stdout(std::process::Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
        {
            println!("ouija updated to {latest} and running");
            return Ok(());
        }
        if i == 19 {
            anyhow::bail!("daemon did not start within 10s");
        }
    }
    Ok(())
}

/// Refresh the Claude Code plugin cache from the source directory.
///
/// Reads `~/.claude/settings.json` to find the ouija plugin source path,
/// then copies plugin files (scripts, hooks, skills, .mcp.json) into the
/// cache so sessions pick up the latest hook scripts.
fn refresh_plugin_cache() {
    let home = match std::env::var("HOME") {
        Ok(h) => std::path::PathBuf::from(h),
        Err(_) => return,
    };

    let settings_path = home.join(".claude/settings.json");
    let settings_str = match std::fs::read_to_string(&settings_path) {
        Ok(s) => s,
        Err(_) => return,
    };
    let settings: serde_json::Value = match serde_json::from_str(&settings_str) {
        Ok(v) => v,
        Err(_) => return,
    };

    let source_dir = settings
        .pointer("/extraKnownMarketplaces/ouija/source/path")
        .and_then(|v| v.as_str())
        .map(std::path::PathBuf::from);

    let source_dir = match source_dir {
        Some(d) if d.exists() => d,
        _ => return,
    };

    let cache_base = home.join(".claude/plugins/cache/ouija/ouija");
    let cache_dir = match std::fs::read_dir(&cache_base)
        .ok()
        .and_then(|mut entries| entries.next())
        .and_then(|e| e.ok())
    {
        Some(entry) => entry.path(),
        None => return,
    };

    for dir in &["scripts", "hooks", "skills"] {
        let src = source_dir.join(dir);
        let dst = cache_dir.join(dir);
        if src.is_dir() {
            if let Err(e) = sync_dir(&src, &dst) {
                eprintln!("warning: failed to sync plugin {dir}: {e}");
            }
        }
    }

    let src = source_dir.join(".mcp.json");
    let dst = cache_dir.join(".mcp.json");
    if src.is_file() {
        if let Err(e) = std::fs::copy(&src, &dst) {
            eprintln!("warning: failed to copy plugin .mcp.json: {e}");
        }
    }

    // Stamp version so hooks can detect plugin/daemon mismatch
    let _ = std::fs::write(cache_dir.join(".version"), env!("CARGO_PKG_VERSION"));

    println!("plugin cache refreshed");
}

fn sync_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());
        if src_path.is_dir() {
            sync_dir(&src_path, &dst_path)?;
        } else {
            std::fs::copy(&src_path, &dst_path)?;
        }
    }
    Ok(())
}

/// Query crates.io for the latest version of a crate (including prereleases).
fn fetch_latest_crate_version(name: &str) -> anyhow::Result<String> {
    use std::process::Command as Cmd;

    let output = Cmd::new("curl")
        .args(["-sf", &format!("https://crates.io/api/v1/crates/{name}")])
        .output()
        .context("failed to query crates.io")?;
    if !output.status.success() {
        anyhow::bail!("crates.io query failed");
    }
    let json: serde_json::Value =
        serde_json::from_slice(&output.stdout).context("invalid JSON from crates.io")?;
    // versions are sorted newest-first; pick the first non-yanked
    json["versions"]
        .as_array()
        .and_then(|versions| {
            versions
                .iter()
                .find(|v| !v["yanked"].as_bool().unwrap_or(true))
                .and_then(|v| v["num"].as_str())
                .map(String::from)
        })
        .ok_or_else(|| anyhow::anyhow!("no versions found for {name} on crates.io"))
}

/// Look up the registered session ID for the current tmux pane.
async fn resolve_my_session_id() -> Option<String> {
    let pane = std::env::var("TMUX_PANE").ok()?;
    let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
    let url = format!("http://localhost:{port}/api/status");
    let resp = reqwest::get(&url).await.ok()?;
    let status: serde_json::Value = resp.json().await.ok()?;
    status["sessions"]
        .as_array()?
        .iter()
        .find(|s| s["pane"].as_str() == Some(&pane))
        .and_then(|s| s["id"].as_str().map(String::from))
}

async fn cli_get(path: &str) -> anyhow::Result<()> {
    let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
    let url = format!("http://localhost:{port}{path}");
    let resp = reqwest::get(&url).await?;
    let text = resp.text().await?;
    println!("{text}");
    Ok(())
}

async fn cli_post(path: &str, body: &serde_json::Value) -> anyhow::Result<()> {
    let port = std::env::var("OUIJA_PORT").unwrap_or_else(|_| "7880".to_string());
    let url = format!("http://localhost:{port}{path}");
    let client = reqwest::Client::new();
    let resp = client.post(&url).json(body).send().await?;
    let text = resp.text().await?;
    println!("{text}");
    Ok(())
}
