//! External conversation/session tracking for agent CLIs.
//!
//! Tenex persists a per-agent conversation id so it can respawn agents after a reboot/crash and
//! reconnect to the same Codex/Claude session instead of starting a new one.

use crate::command;
use chrono::{Datelike as _, Duration as ChronoDuration, Local, NaiveDate, Utc};
use serde::Deserialize;
use std::collections::HashSet;
use std::io::{BufRead as _, BufReader};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use anyhow::Result;

#[cfg(test)]
use std::sync::{Mutex, OnceLock};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Identifies which agent CLI a configured program string targets.
pub enum AgentCli {
    /// Anthropic's Claude Code CLI (`claude`).
    Claude,
    /// `OpenAI`'s Codex CLI (`codex`).
    Codex,
    /// Any other command (Tenex can't resume conversations automatically).
    Other,
}

/// Detect the agent CLI from a configured program string.
pub fn detect_agent_cli(program: &str) -> AgentCli {
    let Ok(argv) = command::parse_command_line(program) else {
        return AgentCli::Other;
    };
    let exe = argv.first().map_or("", String::as_str);

    let name = Path::new(exe)
        .file_name()
        .and_then(|file_name| file_name.to_str())
        .unwrap_or(exe);

    match name {
        "claude" => AgentCli::Claude,
        "codex" => AgentCli::Codex,
        _ => AgentCli::Other,
    }
}

/// Build argv for spawning an agent.
///
/// For Claude, Tenex can optionally force a stable session id (so it can be resumed later).
pub fn build_spawn_argv(
    program: &str,
    prompt: Option<&str>,
    claude_session_id: Option<&str>,
) -> Result<Vec<String>> {
    let mut argv = command::parse_command_line(program)?;

    if detect_agent_cli(program) == AgentCli::Claude
        && let Some(session_id) = claude_session_id
        && !argv
            .iter()
            .any(|arg| arg == "--session-id" || arg.starts_with("--session-id="))
        && !argv
            .iter()
            .any(|arg| arg == "--resume" || arg == "-r" || arg == "--continue" || arg == "-c")
    {
        argv.push("--session-id".to_string());
        argv.push(session_id.to_string());
    }

    if let Some(prompt) = prompt {
        argv.push(prompt.to_string());
    }

    Ok(argv)
}

/// Build argv for resuming an agent conversation by id.
pub fn build_resume_argv(program: &str, conversation_id: &str) -> Result<Vec<String>> {
    let mut argv = command::parse_command_line(program)?;

    match detect_agent_cli(program) {
        AgentCli::Claude => {
            argv.push("--resume".to_string());
            argv.push(conversation_id.to_string());
        }
        AgentCli::Codex => {
            argv.push("resume".to_string());
            argv.push(conversation_id.to_string());
        }
        AgentCli::Other => {}
    }

    Ok(argv)
}

/// Best-effort detection of the Codex session id created after spawning a `codex` process.
pub fn try_detect_codex_session_id(
    workdir: &Path,
    since: SystemTime,
    exclude_ids: &HashSet<String>,
    max_wait: Duration,
) -> Option<String> {
    let sessions_root = codex_sessions_root()?;
    try_detect_codex_session_id_in_root(&sessions_root, workdir, since, exclude_ids, max_wait)
}

fn try_detect_codex_session_id_in_root(
    sessions_root: &Path,
    workdir: &Path,
    since: SystemTime,
    exclude_ids: &HashSet<String>,
    max_wait: Duration,
) -> Option<String> {
    try_detect_codex_session_id_with_retry(
        max_wait,
        || detect_codex_session_id_once_in_root(sessions_root, workdir, since, exclude_ids),
        SystemTime::now,
        std::thread::sleep,
    )
}

fn try_detect_codex_session_id_with_retry<Detect, Now, Sleep>(
    max_wait: Duration,
    mut detect_once: Detect,
    mut now: Now,
    mut sleep: Sleep,
) -> Option<String>
where
    Detect: FnMut() -> Option<String>,
    Now: FnMut() -> SystemTime,
    Sleep: FnMut(Duration),
{
    let deadline = now().checked_add(max_wait)?;
    loop {
        if let Some(found) = detect_once() {
            return Some(found);
        }
        if now() >= deadline {
            return None;
        }
        sleep(Duration::from_millis(25));
    }
}

fn detect_codex_session_id_once_in_root(
    sessions_root: &Path,
    workdir: &Path,
    since: SystemTime,
    exclude_ids: &HashSet<String>,
) -> Option<String> {
    let date_dirs = codex_candidate_date_dirs(sessions_root);
    detect_codex_session_id_once_in_dirs(&date_dirs, workdir, since, exclude_ids)
}

fn detect_codex_session_id_once_in_dirs(
    date_dirs: &[PathBuf],
    workdir: &Path,
    since: SystemTime,
    exclude_ids: &HashSet<String>,
) -> Option<String> {
    let wanted_cwd = normalize_path(workdir);

    let mut best: Option<(String, SystemTime)> = None;
    for date_dir in date_dirs {
        if let Ok(entries) = std::fs::read_dir(date_dir) {
            let mut dir_entries: Vec<std::fs::DirEntry> = entries.flatten().collect();
            dir_entries.sort_by_key(std::fs::DirEntry::path);
            for entry in dir_entries {
                let path = entry.path();
                if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
                    continue;
                }

                let Ok(metadata) = std::fs::metadata(&path) else {
                    continue;
                };
                if !metadata.is_file() {
                    continue;
                }

                let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
                if modified < since {
                    continue;
                }

                let Some(meta) = read_codex_session_meta(&path) else {
                    continue;
                };

                if normalize_path(Path::new(&meta.cwd)) != wanted_cwd {
                    continue;
                }

                if exclude_ids.contains(&meta.id) {
                    continue;
                }

                match best {
                    None => best = Some((meta.id, modified)),
                    Some((_, best_mtime)) if modified > best_mtime => {
                        best = Some((meta.id, modified));
                    }
                    Some(_) => {}
                }
            }
        }
    }

    best.map(|(id, _mtime)| id)
}

#[derive(Debug, Deserialize)]
struct CodexSessionMetaLine {
    #[serde(rename = "type")]
    kind: String,
    payload: CodexSessionPayload,
}

#[derive(Debug, Deserialize)]
struct CodexSessionPayload {
    id: String,
    cwd: String,
}

struct CodexSessionMeta {
    id: String,
    cwd: String,
}

fn read_codex_session_meta(path: &Path) -> Option<CodexSessionMeta> {
    let file = std::fs::File::open(path).ok()?;
    let mut reader = BufReader::new(file);

    let mut line = String::new();
    let bytes = reader.read_line(&mut line).ok()?;
    if bytes == 0 {
        return None;
    }

    let parsed: CodexSessionMetaLine = serde_json::from_str(&line).ok()?;
    if parsed.kind != "session_meta" {
        return None;
    }

    Some(CodexSessionMeta {
        id: parsed.payload.id,
        cwd: parsed.payload.cwd,
    })
}

#[cfg(test)]
static CODEX_SESSIONS_ROOT_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();

#[cfg(test)]
fn codex_sessions_root_override() -> Option<PathBuf> {
    let mutex = CODEX_SESSIONS_ROOT_OVERRIDE.get_or_init(|| Mutex::new(None));
    let guard = match mutex.lock() {
        Ok(guard) => guard,
        Err(poisoned) => poisoned.into_inner(),
    };
    guard.clone()
}

#[cfg(test)]
fn set_codex_sessions_root_override_for_tests(new: Option<PathBuf>) -> Option<PathBuf> {
    let mutex = CODEX_SESSIONS_ROOT_OVERRIDE.get_or_init(|| Mutex::new(None));
    let mut guard = match mutex.lock() {
        Ok(guard) => guard,
        Err(poisoned) => poisoned.into_inner(),
    };
    std::mem::replace(&mut *guard, new)
}

fn codex_sessions_root() -> Option<PathBuf> {
    #[cfg(test)]
    if let Some(root) = codex_sessions_root_override() {
        return Some(root);
    }

    let codex_home_from_env = std::env::var_os("CODEX_HOME").map(PathBuf::from);
    let codex_home_from_home = crate::paths::home_dir().map(|home| home.join(".codex"));
    let codex_home = codex_home_from_env.or(codex_home_from_home)?;
    Some(codex_home.join("sessions"))
}

fn codex_candidate_date_dirs(sessions_root: &Path) -> Vec<PathBuf> {
    let local_today = Local::now().date_naive();
    let utc_today = Utc::now().date_naive();

    let candidates = [
        local_today,
        local_today
            .checked_sub_signed(ChronoDuration::days(1))
            .unwrap_or(local_today),
        utc_today,
        utc_today
            .checked_sub_signed(ChronoDuration::days(1))
            .unwrap_or(utc_today),
    ];

    let mut unique: HashSet<PathBuf> = HashSet::new();
    for date in candidates {
        unique.insert(codex_date_dir(sessions_root, date));
    }

    unique.into_iter().filter(|dir| dir.is_dir()).collect()
}

fn codex_date_dir(sessions_root: &Path, date: NaiveDate) -> PathBuf {
    sessions_root
        .join(format!("{:04}", date.year()))
        .join(format!("{:02}", date.month()))
        .join(format!("{:02}", date.day()))
}

fn normalize_path(path: &Path) -> PathBuf {
    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;
    use tempfile::TempDir;

    struct CodexSessionsRootOverrideGuard {
        previous: Option<PathBuf>,
    }

    impl CodexSessionsRootOverrideGuard {
        fn set(new: Option<PathBuf>) -> Self {
            let previous = set_codex_sessions_root_override_for_tests(new);
            Self { previous }
        }
    }

    impl Drop for CodexSessionsRootOverrideGuard {
        fn drop(&mut self) {
            let previous = std::mem::take(&mut self.previous);
            let _ = set_codex_sessions_root_override_for_tests(previous);
        }
    }

    fn codex_session_meta_line(id: &str, cwd: &Path) -> String {
        format!(
            "{}\n",
            serde_json::json!({
                "type": "session_meta",
                "payload": {
                    "id": id,
                    "cwd": cwd.to_string_lossy().into_owned(),
                }
            })
        )
    }

    #[test]
    fn test_detect_agent_cli() {
        assert_eq!(detect_agent_cli("claude"), AgentCli::Claude);
        assert_eq!(detect_agent_cli("codex"), AgentCli::Codex);
        assert_eq!(detect_agent_cli("sh -c 'echo hi'"), AgentCli::Other);
        assert_eq!(detect_agent_cli("   "), AgentCli::Other);
        assert_eq!(detect_agent_cli("sh -c 'unterminated"), AgentCli::Other);
    }

    #[test]
    fn test_build_spawn_argv_claude_adds_session_id() -> Result<()> {
        let argv = build_spawn_argv("claude --debug", Some("hello"), Some("abc"))?;
        assert_eq!(
            argv,
            vec!["claude", "--debug", "--session-id", "abc", "hello"]
        );
        Ok(())
    }

    #[test]
    fn test_build_spawn_argv_claude_does_not_duplicate_session_id() -> Result<()> {
        let argv = build_spawn_argv("claude --session-id existing", Some("hello"), Some("abc"))?;
        assert_eq!(argv, vec!["claude", "--session-id", "existing", "hello"]);
        Ok(())
    }

    #[test]
    fn test_build_spawn_argv_claude_does_not_inject_when_resume_present() -> Result<()> {
        let argv = build_spawn_argv("claude --resume existing", Some("hello"), Some("abc"))?;
        assert_eq!(argv, vec!["claude", "--resume", "existing", "hello"]);
        Ok(())
    }

    #[test]
    fn test_build_spawn_argv_non_claude_does_not_inject_session_id() -> Result<()> {
        let argv = build_spawn_argv("codex", Some("hello"), Some("abc"))?;
        assert_eq!(argv, vec!["codex", "hello"]);
        Ok(())
    }

    #[test]
    fn test_build_resume_argv_codex() -> Result<()> {
        let argv = build_resume_argv("codex --search", "id")?;
        assert_eq!(argv, vec!["codex", "--search", "resume", "id"]);
        Ok(())
    }

    #[test]
    fn test_build_resume_argv_claude() -> Result<()> {
        let argv = build_resume_argv("claude --debug", "id")?;
        assert_eq!(argv, vec!["claude", "--debug", "--resume", "id"]);
        Ok(())
    }

    #[test]
    fn test_build_resume_argv_other() -> Result<()> {
        let argv = build_resume_argv("echo hello", "id")?;
        assert_eq!(argv, vec!["echo", "hello"]);
        Ok(())
    }

    #[test]
    fn test_try_detect_codex_session_id_from_fake_store() -> Result<()> {
        let temp = TempDir::new()?;

        let workdir = temp.path().join("worktree");
        std::fs::create_dir_all(&workdir)?;

        let sessions_root = temp.path().join("sessions");
        let date_dir = codex_date_dir(&sessions_root, Local::now().date_naive());
        std::fs::create_dir_all(&date_dir)?;

        let session_path = date_dir.join("rollout-2026-02-01T00-00-00-deadbeef.jsonl");
        let contents = codex_session_meta_line("deadbeef", &workdir);
        std::fs::write(&session_path, contents)?;

        let id = detect_codex_session_id_once_in_root(
            &sessions_root,
            &workdir,
            SystemTime::UNIX_EPOCH,
            &HashSet::new(),
        );
        assert_eq!(id.as_deref(), Some("deadbeef"));
        Ok(())
    }

    #[test]
    fn test_try_detect_codex_session_id_with_retry_returns_none_on_overflow() {
        let mut detect_calls = 0;
        let id = try_detect_codex_session_id_with_retry(
            Duration::from_secs(u64::MAX),
            || {
                detect_calls += 1;
                Some("unexpected".to_string())
            },
            || SystemTime::UNIX_EPOCH,
            |_| unreachable!("overflow should return before sleeping"),
        );
        assert!(id.is_none());
        assert_eq!(detect_calls, 0);
    }

    #[test]
    fn test_try_detect_codex_session_id_with_retry_times_out() {
        let mut detect_calls = 0;
        let mut now_calls = 0;
        let mut slept = false;
        let id = try_detect_codex_session_id_with_retry(
            Duration::from_millis(0),
            || {
                detect_calls += 1;
                None
            },
            || {
                now_calls += 1;
                SystemTime::UNIX_EPOCH
            },
            |_| slept = true,
        );
        assert!(id.is_none());
        assert_eq!(detect_calls, 1);
        assert_eq!(now_calls, 2);
        assert!(!slept);
    }

    #[test]
    fn test_try_detect_codex_session_id_returns_none_when_no_match() -> Result<()> {
        let temp = TempDir::new()?;
        let workdir = temp.path().join("worktree");
        std::fs::create_dir_all(&workdir)?;
        let sessions_root = temp.path().join("sessions");
        std::fs::create_dir_all(&sessions_root)?;

        let _override_guard = CodexSessionsRootOverrideGuard::set(Some(sessions_root));
        let id = try_detect_codex_session_id(
            &workdir,
            SystemTime::UNIX_EPOCH,
            &HashSet::new(),
            Duration::from_millis(0),
        );
        assert!(id.is_none());
        Ok(())
    }

    #[test]
    fn test_codex_sessions_root() -> Result<()> {
        let _override_guard = CodexSessionsRootOverrideGuard::set(None);
        let root =
            codex_sessions_root().ok_or_else(|| anyhow::anyhow!("Missing Codex sessions root"))?;
        assert_eq!(
            root.file_name().and_then(|name| name.to_str()),
            Some("sessions")
        );
        Ok(())
    }

    #[test]
    fn test_detect_codex_session_id_once_in_root_filters_since() -> Result<()> {
        let temp = TempDir::new()?;

        let workdir = temp.path().join("worktree");
        std::fs::create_dir_all(&workdir)?;

        let sessions_root = temp.path().join("sessions");
        let date_dir = codex_date_dir(&sessions_root, Local::now().date_naive());
        std::fs::create_dir_all(&date_dir)?;

        let session_path = date_dir.join("rollout-2026-02-01T00-00-00-deadbeef.jsonl");
        let contents = codex_session_meta_line("deadbeef", &workdir);
        std::fs::write(&session_path, contents)?;

        let id = detect_codex_session_id_once_in_root(
            &sessions_root,
            &workdir,
            SystemTime::now() + Duration::from_secs(60),
            &HashSet::new(),
        );
        assert!(id.is_none());
        Ok(())
    }

    #[test]
    fn test_detect_codex_session_id_once_in_root_excludes_ids() -> Result<()> {
        let temp = TempDir::new()?;

        let workdir = temp.path().join("worktree");
        std::fs::create_dir_all(&workdir)?;

        let sessions_root = temp.path().join("sessions");
        let date_dir = codex_date_dir(&sessions_root, Local::now().date_naive());
        std::fs::create_dir_all(&date_dir)?;

        let session_path = date_dir.join("rollout-2026-02-01T00-00-00-deadbeef.jsonl");
        let contents = codex_session_meta_line("deadbeef", &workdir);
        std::fs::write(&session_path, contents)?;

        let exclude_ids: HashSet<String> = std::iter::once("deadbeef".to_string()).collect();
        let id = detect_codex_session_id_once_in_root(
            &sessions_root,
            &workdir,
            SystemTime::UNIX_EPOCH,
            &exclude_ids,
        );
        assert!(id.is_none());
        Ok(())
    }

    #[test]
    fn test_detect_codex_session_id_once_in_dirs_skips_read_dir_errors() -> Result<()> {
        let temp = TempDir::new()?;

        let workdir = temp.path().join("worktree");
        std::fs::create_dir_all(&workdir)?;
        let not_a_dir = temp.path().join("not-a-dir");
        std::fs::write(&not_a_dir, "not a directory")?;

        let id = detect_codex_session_id_once_in_dirs(
            &[not_a_dir],
            &workdir,
            SystemTime::UNIX_EPOCH,
            &HashSet::new(),
        );
        assert!(id.is_none());
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn test_detect_codex_session_id_once_in_root_skips_metadata_errors() -> Result<()> {
        use std::os::unix::fs::symlink;

        let temp = TempDir::new()?;

        let workdir = temp.path().join("worktree");
        std::fs::create_dir_all(&workdir)?;

        let sessions_root = temp.path().join("sessions");
        let date_dir = codex_date_dir(&sessions_root, Local::now().date_naive());
        std::fs::create_dir_all(&date_dir)?;

        let broken_target = date_dir.join("missing-target");
        let broken_link = date_dir.join("broken.jsonl");
        symlink(broken_target, broken_link)?;

        let id = detect_codex_session_id_once_in_root(
            &sessions_root,
            &workdir,
            SystemTime::UNIX_EPOCH,
            &HashSet::new(),
        );
        assert!(id.is_none());
        Ok(())
    }

    #[test]
    fn test_detect_codex_session_id_once_in_root_prefers_newest_matching() -> Result<()> {
        let temp = TempDir::new()?;

        let workdir = temp.path().join("worktree");
        std::fs::create_dir_all(&workdir)?;
        let other_workdir = temp.path().join("other");
        std::fs::create_dir_all(&other_workdir)?;

        let sessions_root = temp.path().join("sessions");
        let date_dir = codex_date_dir(&sessions_root, Local::now().date_naive());
        std::fs::create_dir_all(&date_dir)?;

        std::fs::write(date_dir.join("ignore.txt"), "hello")?;
        std::fs::create_dir_all(date_dir.join("dir.jsonl"))?;

        std::fs::write(date_dir.join("empty.jsonl"), "")?;
        let wrong_kind = "{\"type\":\"other\",\"payload\":{\"id\":\"other\",\"cwd\":\"/tmp\"}}\n";
        std::fs::write(date_dir.join("wrong_kind.jsonl"), wrong_kind)?;
        std::fs::write(date_dir.join("invalid.jsonl"), "{")?;
        let wrong_cwd = codex_session_meta_line("wrongcwd", &other_workdir);
        std::fs::write(date_dir.join("wrong_cwd.jsonl"), wrong_cwd)?;

        let first_path = date_dir.join("first.jsonl");
        let first_contents = codex_session_meta_line("first", &workdir);
        std::fs::write(&first_path, first_contents)?;

        let third_path = date_dir.join("third.jsonl");
        let third_contents = codex_session_meta_line("third", &workdir);
        std::fs::write(&third_path, third_contents)?;

        std::thread::sleep(Duration::from_secs(1));

        let second_path = date_dir.join("second.jsonl");
        let second_contents = codex_session_meta_line("second", &workdir);
        std::fs::write(&second_path, second_contents)?;

        let id = detect_codex_session_id_once_in_root(
            &sessions_root,
            &workdir,
            SystemTime::UNIX_EPOCH,
            &HashSet::new(),
        );
        assert_eq!(id.as_deref(), Some("second"));
        Ok(())
    }

    #[test]
    fn test_try_detect_codex_session_id_with_retry_waits_for_session() {
        let mut detect_calls = 0;
        let mut now_calls = 0;
        let mut slept = Vec::new();
        let id = try_detect_codex_session_id_with_retry(
            Duration::from_millis(200),
            || {
                detect_calls += 1;
                if detect_calls == 1 {
                    None
                } else {
                    Some("deadbeef".to_string())
                }
            },
            || {
                now_calls += 1;
                SystemTime::UNIX_EPOCH
            },
            |duration| slept.push(duration),
        );
        assert_eq!(id.as_deref(), Some("deadbeef"));
        assert_eq!(detect_calls, 2);
        assert_eq!(now_calls, 2);
        assert_eq!(slept, vec![Duration::from_millis(25)]);
    }
}
