//! `adom-video-post list` and `adom-video-post ctrl` — AI control surface for
//! running webapp instances.
//!
//! Every `run_webapp` invocation writes a `~/.adom/adom-video-post/instances/
//! {pid}.json` file on startup and deletes it on shutdown. `list` reads
//! that directory, filters out stale PIDs (processes that died without
//! cleaning up), and prints a table. `ctrl` resolves a symbolic target
//! (`voiceover` / `storyboard` / `latest` / port / pid) to a concrete
//! port and dispatches one of a handful of actions against that
//! instance's HTTP surface.
//!
//! Supported `ctrl` actions:
//!   state           GET /state → print JSON
//!   console         GET /console[?since=N&wait=MS] → print messages
//!     --tail N      print only the last N
//!     --since SEQ   only messages whose seq > SEQ
//!     --follow      long-poll (blocking, exits on Ctrl-C)
//!   eval <code>     POST /eval + poll result (same as `adom-video-post eval`)
//!   shutdown        POST /shutdown
//!   call M PATH     raw HTTP call, optional --body JSON
//!
//! All HTTP calls are done via curl so there's no extra dep.

use crate::webapp::registry;
use std::fs;
use std::process::Command;

/// One live instance's registry record.
#[derive(Debug, Clone)]
pub struct Instance {
    pub pid: u32,
    pub port: u16,
    pub subcommand: String,
    pub title: String,
    pub started_at: u64,
}

/// Read every `*.json` in the instances directory, parse it, filter
/// out stale entries whose PID is dead, remove those stale files
/// from disk, and return the survivors sorted by started_at
/// ascending (oldest first).
pub fn list_instances() -> Result<Vec<Instance>, String> {
    let dir = registry::instances_dir()?;
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut out: Vec<Instance> = Vec::new();
    let entries = fs::read_dir(&dir)
        .map_err(|e| format!("read_dir {}: {}", dir.display(), e))?;
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        let body = match fs::read_to_string(&path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let parsed: serde_json::Value = match serde_json::from_str(&body) {
            Ok(v) => v,
            Err(_) => continue,
        };
        let pid = parsed.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
        if pid == 0 {
            continue;
        }
        if !pid_alive(pid) {
            // Stale entry — clean it up so the next list() is faster.
            let _ = fs::remove_file(&path);
            continue;
        }
        out.push(Instance {
            pid,
            port: parsed.get("port").and_then(|v| v.as_u64()).unwrap_or(0) as u16,
            subcommand: parsed
                .get("subcommand")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            title: parsed
                .get("title")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            started_at: parsed
                .get("startedAt")
                .and_then(|v| v.as_u64())
                .unwrap_or(0),
        });
    }
    out.sort_by_key(|i| i.started_at);
    Ok(out)
}

/// Check whether a process with the given PID is still alive.
/// Uses `kill -0 <pid>` which sends the null signal — it doesn't
/// actually signal the process, it just validates whether the
/// signal COULD be delivered (i.e., the PID exists and we have
/// permission).
fn pid_alive(pid: u32) -> bool {
    Command::new("kill")
        .args(["-0", &pid.to_string()])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Resolve a user-supplied target string to a concrete instance.
///
/// Resolution order:
///   1. "latest"           → most recently started instance
///   2. "<subcommand>"     → first instance whose subcommand matches
///   3. "<port>"           → instance listening on that port
///   4. "pid:<pid>"        → instance with that exact PID
///   5. numeric 0..=65535  → treated as a port (case 3)
///   6. numeric > 65535    → treated as a pid (case 4)
pub fn resolve_target(target: &str) -> Result<Instance, String> {
    let instances = list_instances()?;
    if instances.is_empty() {
        return Err(format!(
            "no running adom-video-post instances found. Hint: start one with `adom-video-post voiceover --input ...` or `adom-video-post storyboard <manifest>`."
        ));
    }

    if target == "latest" {
        return instances.last().cloned().ok_or_else(|| "no instances".to_string());
    }

    if let Some(pid_str) = target.strip_prefix("pid:") {
        let pid: u32 = pid_str
            .parse()
            .map_err(|_| format!("invalid pid in target {:?}", target))?;
        return instances
            .into_iter()
            .find(|i| i.pid == pid)
            .ok_or_else(|| format!("no running instance with pid {}", pid));
    }

    // Numeric: port (<=65535) or pid (>65535).
    if let Ok(n) = target.parse::<u64>() {
        if n <= 65535 {
            let port = n as u16;
            return instances
                .into_iter()
                .find(|i| i.port == port)
                .ok_or_else(|| format!("no running instance on port {}", port));
        }
        let pid = n as u32;
        return instances
            .into_iter()
            .find(|i| i.pid == pid)
            .ok_or_else(|| format!("no running instance with pid {}", pid));
    }

    // String: subcommand match (case-insensitive).
    let t = target.to_lowercase();
    instances
        .into_iter()
        .find(|i| i.subcommand.to_lowercase() == t)
        .ok_or_else(|| {
            format!(
                "no running instance matching {:?}. Hint: run `adom-video-post list` to see what is running.",
                target
            )
        })
}

/// `adom-video-post list` — print the registry as a human-readable table.
pub fn cmd_list() -> Result<(), String> {
    let instances = list_instances()?;
    if instances.is_empty() {
        println!("no adom-video-post instances running");
        return Ok(());
    }
    println!(
        "{:<7} {:<6} {:<12} {:<40} STARTED",
        "PID", "PORT", "SUBCOMMAND", "TITLE"
    );
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    for i in &instances {
        let age = now.saturating_sub(i.started_at);
        let age_str = human_duration(age);
        let title = if i.title.len() > 38 {
            format!("{}…", &i.title[..37])
        } else {
            i.title.clone()
        };
        println!(
            "{:<7} {:<6} {:<12} {:<40} {} ago",
            i.pid, i.port, i.subcommand, title, age_str
        );
    }
    Ok(())
}

fn human_duration(secs: u64) -> String {
    if secs < 60 {
        format!("{}s", secs)
    } else if secs < 3600 {
        format!("{}m", secs / 60)
    } else if secs < 86400 {
        format!("{}h", secs / 3600)
    } else {
        format!("{}d", secs / 86400)
    }
}

/// `adom-video-post ctrl <target> <action> [args...]` dispatcher.
pub fn cmd_ctrl(target: &str, action: &CtrlAction) -> Result<(), String> {
    let instance = resolve_target(target)?;
    let base = format!("http://127.0.0.1:{}", instance.port);
    eprintln!(
        "→ {} (pid {}, port {})",
        instance.subcommand, instance.pid, instance.port
    );
    match action {
        CtrlAction::State => ctrl_state(&base),
        CtrlAction::Console { tail, since, follow } => ctrl_console(&base, *tail, *since, *follow),
        CtrlAction::Eval { code, timeout } => ctrl_eval(&base, code, *timeout),
        CtrlAction::Shutdown => ctrl_shutdown(&base),
        CtrlAction::Call { method, path, body } => ctrl_call(&base, method, path, body.as_deref()),
    }
}

pub enum CtrlAction {
    State,
    Console {
        tail: Option<usize>,
        since: Option<u64>,
        follow: bool,
    },
    Eval {
        code: String,
        timeout: u64,
    },
    Shutdown,
    Call {
        method: String,
        path: String,
        body: Option<String>,
    },
}

fn ctrl_state(base: &str) -> Result<(), String> {
    let out = curl_text("GET", &format!("{}/state", base), None, 5)?;
    let parsed: serde_json::Value =
        serde_json::from_str(&out).map_err(|e| format!("parse /state: {} ({})", e, out))?;
    println!("{}", serde_json::to_string_pretty(&parsed).unwrap_or(out));
    Ok(())
}

fn ctrl_console(
    base: &str,
    tail: Option<usize>,
    since: Option<u64>,
    follow: bool,
) -> Result<(), String> {
    let mut since = since.unwrap_or(0);
    loop {
        let qs = if follow {
            format!("?since={}&wait=25000", since)
        } else {
            format!("?since={}", since)
        };
        let url = format!("{}/console{}", base, qs);
        // When following we allow the long-poll to block for ~25s.
        let timeout = if follow { 30 } else { 10 };
        let body = curl_text("GET", &url, None, timeout)?;
        let parsed: serde_json::Value =
            serde_json::from_str(&body).map_err(|e| format!("parse /console: {} ({})", e, body))?;
        let seq = parsed.get("seq").and_then(|v| v.as_u64()).unwrap_or(since);
        let msgs = parsed
            .get("messages")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();
        let start = match tail {
            Some(n) if msgs.len() > n && !follow => msgs.len() - n,
            _ => 0,
        };
        for m in &msgs[start..] {
            println!("{}", m);
        }
        if !follow {
            return Ok(());
        }
        // In follow mode, advance `since` past what we just printed so
        // the next long-poll only wakes on truly-new messages. If the
        // long-poll returned 204 (empty), curl_text returns "" and we
        // just loop.
        if seq > since {
            since = seq;
        }
    }
}

fn ctrl_eval(base: &str, code: &str, timeout: u64) -> Result<(), String> {
    // POST the snippet.
    let post_body = serde_json::json!({ "code": code }).to_string();
    let post = curl_text("POST", &format!("{}/eval", base), Some(&post_body), 5)?;
    let parsed: serde_json::Value =
        serde_json::from_str(&post).map_err(|e| format!("parse /eval response: {} ({})", e, post))?;
    let id = parsed
        .get("id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("/eval missing 'id': {}", post))?
        .to_string();

    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
    loop {
        let (body, status) = curl_text_with_status(
            "GET",
            &format!("{}/eval/{}/result", base, id),
            None,
            30,
        )?;
        if status == 200 {
            let result: serde_json::Value = serde_json::from_str(&body)
                .map_err(|e| format!("parse result: {} ({})", e, body))?;
            println!("{}", serde_json::to_string_pretty(&result).unwrap_or(body));
            let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
            if ok {
                return Ok(());
            }
            return Err(result
                .get("error")
                .and_then(|v| v.as_str())
                .unwrap_or("eval returned ok:false")
                .to_string());
        }
        if status != 204 {
            return Err(format!("unexpected status {}: {}", status, body));
        }
        if std::time::Instant::now() >= deadline {
            return Err(format!("timed out after {}s waiting for eval result", timeout));
        }
    }
}

fn ctrl_shutdown(base: &str) -> Result<(), String> {
    let body = curl_text("POST", &format!("{}/shutdown", base), None, 5)?;
    println!("{}", body.trim());
    Ok(())
}

fn ctrl_call(
    base: &str,
    method: &str,
    path: &str,
    body: Option<&str>,
) -> Result<(), String> {
    let full = if path.starts_with('/') {
        format!("{}{}", base, path)
    } else {
        format!("{}/{}", base, path)
    };
    let out = curl_text(method, &full, body, 30)?;
    println!("{}", out);
    Ok(())
}

/// Shell out to curl for a simple HTTP call. Returns the response
/// body on a 2xx, errors otherwise.
fn curl_text(
    method: &str,
    url: &str,
    body: Option<&str>,
    timeout_secs: u64,
) -> Result<String, String> {
    let (resp, status) = curl_text_with_status(method, url, body, timeout_secs)?;
    if !(200..300).contains(&status) && status != 204 {
        return Err(format!("HTTP {} from {}: {}", status, url, resp));
    }
    Ok(resp)
}

/// Like `curl_text` but returns (body, status_code) so the caller
/// can handle 204 vs 200 branching explicitly (used by /eval polling).
fn curl_text_with_status(
    method: &str,
    url: &str,
    body: Option<&str>,
    timeout_secs: u64,
) -> Result<(String, u16), String> {
    let mut cmd = Command::new("curl");
    cmd.args([
        "-s",
        "-X",
        method,
        "-w",
        "\n%{http_code}",
        "--max-time",
        &timeout_secs.to_string(),
    ]);
    if let Some(b) = body {
        cmd.args(["-H", "Content-Type: application/json", "-d", b]);
    }
    cmd.arg(url);
    let out = cmd
        .output()
        .map_err(|e| format!("curl spawn failed: {}", e))?;
    let raw = String::from_utf8_lossy(&out.stdout).to_string();
    let (body, status_line) = raw
        .rsplit_once('\n')
        .map(|(b, s)| (b.to_string(), s.to_string()))
        .unwrap_or_else(|| (raw.clone(), "000".to_string()));
    let status: u16 = status_line.trim().parse().unwrap_or(0);
    if status == 0 {
        return Err(format!(
            "curl {} {} failed: {}",
            method,
            url,
            String::from_utf8_lossy(&out.stderr)
        ));
    }
    Ok((body, status))
}