use super::path_codec::{decode_project_path, encode_path_for_claude};
use crate::session;
use crate::session::record::{parse_content_blocks, ContentMode, SessionRecord};
use std::fs;
use std::io::{BufRead, BufReader};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::Command;

const SESSIONS_INDEX_FILE: &str = "sessions-index.json";

/// Session metadata extracted from a single JSONL parse pass.
struct SessionAnalysis {
    first_prompt: String,
    message_count: usize,
    first_ts: String,
    last_ts: String,
    git_branch: String,
}

/// Parse a JSONL session file once, extracting all metadata needed for resume.
fn analyze_session(file_path: &str) -> Option<SessionAnalysis> {
    let file = fs::File::open(file_path).ok()?;
    let reader = BufReader::new(file);

    let mut first_prompt = String::new();
    let mut message_count: usize = 0;
    let mut first_ts = String::new();
    let mut last_ts = String::new();
    let mut git_branch = String::new();

    for line in reader.lines().map_while(Result::ok) {
        let json: serde_json::Value = match serde_json::from_str(line.trim()) {
            Ok(v) => v,
            Err(_) => continue,
        };

        let msg_type = match json.get("type").and_then(|v| v.as_str()) {
            Some(t) => t,
            None => continue,
        };

        if msg_type != "user" && msg_type != "assistant" {
            continue;
        }

        message_count += 1;

        let ts = json.get("timestamp").and_then(|v| v.as_str()).unwrap_or("");
        if first_ts.is_empty() && !ts.is_empty() {
            first_ts = ts.to_string();
        }
        if !ts.is_empty() {
            last_ts = ts.to_string();
        }

        if git_branch.is_empty() {
            if let Some(b) = json
                .get("gitBranch")
                .or_else(|| json.get("branch"))
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
            {
                git_branch = b.to_string();
            }
        }

        if msg_type == "user" && first_prompt.is_empty() {
            if let Some(content) = json.get("message").and_then(|m| m.get("content")) {
                let blocks = parse_content_blocks(content);
                let full = SessionRecord::render_content(&blocks, &ContentMode::Full);
                first_prompt = full.chars().take(200).collect();
            }
        }
    }

    Some(SessionAnalysis {
        first_prompt,
        message_count,
        first_ts,
        last_ts,
        git_branch,
    })
}

/// Check if session_id exists in the sessions-index.json at the given project dir.
fn is_session_in_index(project_dir: &Path, session_id: &str) -> bool {
    let index_path = project_dir.join(SESSIONS_INDEX_FILE);
    let content = match fs::read_to_string(&index_path) {
        Ok(c) => c,
        Err(_) => return false,
    };
    let index: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return false,
    };
    index
        .get("entries")
        .and_then(|e| e.as_array())
        .is_some_and(|entries| {
            entries.iter().any(|e| {
                e.get("sessionId")
                    .and_then(|v| v.as_str())
                    .is_some_and(|s| s == session_id)
            })
        })
}

/// Register a session in Claude CLI's sessions-index.json (if index exists and session is missing).
/// Uses atomic write (temp file + rename) to prevent corruption on crash/interruption.
fn ensure_session_in_index(session_id: &str, file_path: &str, analysis: &SessionAnalysis) {
    let project_dir = match Path::new(file_path).parent() {
        Some(d) => d,
        None => return,
    };
    let index_path = project_dir.join(SESSIONS_INDEX_FILE);

    let mut index: serde_json::Value = match fs::read_to_string(&index_path) {
        Ok(content) => serde_json::from_str(&content).unwrap_or_else(
            |_| serde_json::json!({"version": 1, "entries": [], "originalPath": ""}),
        ),
        Err(_) => return,
    };

    let entries = match index.get_mut("entries").and_then(|e| e.as_array_mut()) {
        Some(arr) => arr,
        None => return,
    };

    let already_exists = entries.iter().any(|e| {
        e.get("sessionId")
            .and_then(|v| v.as_str())
            .is_some_and(|s| s == session_id)
    });
    if already_exists {
        return;
    }

    let file_mtime = fs::metadata(file_path)
        .ok()
        .and_then(|m| m.modified().ok())
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);

    let project_path = decode_project_path(file_path).unwrap_or_default();

    entries.push(serde_json::json!({
        "sessionId": session_id,
        "fullPath": file_path,
        "fileMtime": file_mtime,
        "firstPrompt": analysis.first_prompt,
        "summary": "",
        "messageCount": analysis.message_count,
        "created": analysis.first_ts,
        "modified": analysis.last_ts,
        "gitBranch": analysis.git_branch,
        "projectPath": project_path,
        "isSidechain": false
    }));

    // Atomic write: temp file (unique per process) + rename prevents corruption on crash.
    // PID in temp name avoids temp-file collisions; lost-update race on read-modify-write
    // is accepted since the index is advisory (Claude CLI rebuilds it on next session list).
    if let Ok(json_str) = serde_json::to_string_pretty(&index) {
        let tmp_path = project_dir.join(format!(
            ".{}.{}.tmp",
            SESSIONS_INDEX_FILE,
            std::process::id()
        ));
        if let Err(e) = fs::write(&tmp_path, &json_str) {
            eprintln!("[ccs] failed to write session index temp file: {}", e);
            return;
        }
        if let Err(e) = fs::rename(&tmp_path, &index_path) {
            eprintln!("[ccs] failed to rename session index temp file: {}", e);
            // Clean up orphaned temp file
            let _ = fs::remove_file(&tmp_path);
        }
    }
}

/// Ensure the project directory exists
pub fn ensure_project_dir(file_path: &str) -> Result<(), String> {
    let dir = Path::new(file_path)
        .parent()
        .ok_or_else(|| "Cannot get parent directory".to_string())?;

    if !dir.exists() {
        fs::create_dir_all(dir).map_err(|e| format!("Failed to create directory: {}", e))?;
    }

    Ok(())
}

/// Ensure the project directory and session index are set up for resume.
/// Returns the session ID to pass to `claude --resume`.
///
/// Cross-project resume works because we:
/// 1. Decode the project path from the JSONL file location
/// 2. Set cwd to that project directory (in build_resume_command)
/// 3. Ensure the session is registered in sessions-index.json
///
/// So `claude --resume <session-id>` finds it in the correct project dir.
pub(super) fn prepare_resume(session_id: &str, file_path: &str) -> Result<String, String> {
    ensure_project_dir(file_path)?;

    let analysis = analyze_session(file_path);
    let project_dir = Path::new(file_path).parent();
    let in_index = project_dir
        .map(|d| is_session_in_index(d, session_id))
        .unwrap_or(false);

    if !in_index {
        if let Some(ref a) = analysis {
            ensure_session_in_index(session_id, file_path, a);
        }
    }

    Ok(session_id.to_string())
}

fn fallback_working_dir(file_path: &str) -> String {
    Path::new(file_path)
        .parent()
        .filter(|p| p.exists())
        .map(|p| p.to_string_lossy().to_string())
        .or_else(|| dirs::home_dir().map(|p| p.to_string_lossy().to_string()))
        .unwrap_or_else(|| "/tmp".to_string())
}

/// Whether launching `claude --resume <session-id>` with `cwd = working_dir`
/// will actually locate the session.
///
/// Claude derives the project directory from cwd by re-encoding it (the same
/// transform as `encode_path_for_claude`) and then looks for the transcript in
/// `~/.claude/projects/<encoded>/`. So session-id resume only resolves when that
/// encoding matches the directory that actually holds the JSONL file — i.e. the
/// parent directory name of `file_path`.
fn resume_by_session_id_resolves(working_dir: &str, file_path: &str) -> bool {
    match Path::new(file_path)
        .parent()
        .and_then(|p| p.file_name())
        .and_then(|n| n.to_str())
    {
        Some(parent_name) => encode_path_for_claude(working_dir) == parent_name,
        None => false,
    }
}

fn canonical_resume_path(file_path: &str) -> Result<String, String> {
    fs::canonicalize(file_path)
        .map(|p| p.to_string_lossy().to_string())
        .map_err(|e| format!("Failed to canonicalize resume path {}: {}", file_path, e))
}

/// Build the resume command arguments. Returns (working_dir, resume_arg).
/// Extracted for testability.
pub(super) fn build_resume_command(
    session_id: &str,
    file_path: &str,
) -> Result<(String, String), String> {
    ensure_project_dir(file_path)?;

    let decoded_project_dir = decode_project_path(file_path);

    // Use decoded project path only if it already exists on disk.
    // Never create directories for decoded paths — the decode is lossy
    // and could point to a wrong location.
    let working_dir = match decoded_project_dir {
        Some(ref dir) if Path::new(dir).exists() => dir.clone(),
        _ => fallback_working_dir(file_path),
    };

    // `claude --resume <session-id>` finds the transcript by re-encoding cwd into
    // a `~/.claude/projects/<encoded>/` directory name. That only works when
    // `encode(working_dir)` matches the directory actually holding the JSONL. When
    // the original project directory no longer exists on disk (it was moved,
    // renamed, or the decode is lossy), `working_dir` is a fallback whose encoding
    // points elsewhere, and claude prints "No conversation found with session ID".
    // In that case resume by the absolute `.jsonl` path instead — claude's
    // `--resume <file.jsonl>` loads the transcript straight from the file,
    // independent of cwd.
    let resume_arg = if resume_by_session_id_resolves(&working_dir, file_path) {
        prepare_resume(session_id, file_path)?
    } else {
        canonical_resume_path(file_path)?
    };

    Ok((working_dir, resume_arg))
}

pub(super) fn build_codex_resume_command(
    session_id: &str,
    file_path: &str,
) -> Result<(String, String), String> {
    let working_dir = session::read_codex_session_cwd(file_path)
        .filter(|cwd| Path::new(cwd).exists())
        .unwrap_or_else(|| fallback_working_dir(file_path));

    Ok((working_dir, session_id.to_string()))
}

/// Resume a Claude Code CLI session using exec.
pub fn resume_cli(session_id: &str, file_path: &str) -> Result<(), String> {
    let (working_dir, resume_arg) = build_resume_command(session_id, file_path)?;

    let claude_path =
        which::which("claude").map_err(|_| "Claude binary not found in PATH".to_string())?;

    ccs_debug!(
        "[ccs:resume_cli] claude={} cwd={} --resume {}",
        claude_path.display(),
        working_dir,
        resume_arg
    );

    let mut cmd = Command::new(&claude_path);
    cmd.current_dir(&working_dir)
        .args(["--resume", &resume_arg]);
    exec_command(&mut cmd)
}

/// Resume a Claude Code CLI session as a child process (returns when claude exits).
/// Unlike `resume_cli()` which uses exec() and replaces the current process,
/// this spawns claude as a child and waits for it to finish, allowing the caller
/// to continue afterwards (e.g., return to TUI in overlay mode).
pub fn resume_cli_child(session_id: &str, file_path: &str) -> Result<(), String> {
    let (working_dir, resume_arg) = build_resume_command(session_id, file_path)?;

    let claude_path =
        which::which("claude").map_err(|_| "Claude binary not found in PATH".to_string())?;

    ccs_debug!(
        "[ccs:resume_cli_child] claude={} cwd={} --resume {}",
        claude_path.display(),
        working_dir,
        resume_arg
    );

    // Any exit code is acceptable — Claude CLI exits non-zero on Ctrl-C (130),
    // /exit, or other normal termination paths. In overlay mode we just need to
    // know that the process finished so we can return to the TUI.
    Command::new(&claude_path)
        .current_dir(&working_dir)
        .args(["--resume", &resume_arg])
        .status()
        .map_err(|e| format!("Failed to spawn claude: {}", e))?;

    Ok(())
}

/// Resume a Codex CLI session using exec.
pub fn resume_codex(session_id: &str, file_path: &str) -> Result<(), String> {
    let (working_dir, resume_arg) = build_codex_resume_command(session_id, file_path)?;

    let codex_path =
        which::which("codex").map_err(|_| "Codex binary not found in PATH".to_string())?;

    ccs_debug!(
        "[ccs:resume_codex] codex={} cwd={} resume -C {} {}",
        codex_path.display(),
        working_dir,
        working_dir,
        resume_arg
    );

    let mut cmd = Command::new(&codex_path);
    cmd.current_dir(&working_dir)
        .args(["resume", "-C", &working_dir, &resume_arg]);
    exec_command(&mut cmd)
}

/// Resume a Codex CLI session as a child process.
pub fn resume_codex_child(session_id: &str, file_path: &str) -> Result<(), String> {
    let (working_dir, resume_arg) = build_codex_resume_command(session_id, file_path)?;

    let codex_path =
        which::which("codex").map_err(|_| "Codex binary not found in PATH".to_string())?;

    ccs_debug!(
        "[ccs:resume_codex_child] codex={} cwd={} resume -C {} {}",
        codex_path.display(),
        working_dir,
        working_dir,
        resume_arg
    );

    Command::new(&codex_path)
        .current_dir(&working_dir)
        .args(["resume", "-C", &working_dir, &resume_arg])
        .status()
        .map_err(|e| format!("Failed to spawn codex: {}", e))?;

    Ok(())
}

/// Pick the working directory for an Opencode resume. Prefers the session's
/// recorded `directory` field, falls back to the project's `worktree`, then
/// to a generic fallback when neither exists on disk anymore.
pub(super) fn build_opencode_resume_command(
    session_id: &str,
    file_path: &str,
) -> Result<(String, String), String> {
    use crate::session::opencode::{parse_session_path, read_project_worktree, OpencodeSession};

    let path = Path::new(file_path);
    let session = OpencodeSession::from_file(path);
    let db_path = parse_session_path(file_path).map(|(db, _)| db);

    let project_worktree = session.as_ref().and_then(|s| {
        db_path
            .as_ref()
            .and_then(|db| read_project_worktree(db, &s.project_id))
    });
    let candidate_dirs = [
        session.as_ref().and_then(|s| s.directory.clone()),
        project_worktree,
    ];

    let working_dir = candidate_dirs
        .into_iter()
        .flatten()
        .find(|d| Path::new(d).exists())
        .unwrap_or_else(|| fallback_working_dir(file_path));

    Ok((working_dir, session_id.to_string()))
}

/// Resume an Opencode session using exec.
pub fn resume_opencode(session_id: &str, file_path: &str) -> Result<(), String> {
    let (working_dir, resume_arg) = build_opencode_resume_command(session_id, file_path)?;

    let opencode_path =
        which::which("opencode").map_err(|_| "opencode binary not found in PATH".to_string())?;

    ccs_debug!(
        "[ccs:resume_opencode] opencode={} cwd={} --session {}",
        opencode_path.display(),
        working_dir,
        resume_arg
    );

    let mut cmd = Command::new(&opencode_path);
    cmd.current_dir(&working_dir)
        .args(["--session", &resume_arg]);
    exec_command(&mut cmd)
}

/// Resume an Opencode session as a child process.
pub fn resume_opencode_child(session_id: &str, file_path: &str) -> Result<(), String> {
    let (working_dir, resume_arg) = build_opencode_resume_command(session_id, file_path)?;

    let opencode_path =
        which::which("opencode").map_err(|_| "opencode binary not found in PATH".to_string())?;

    ccs_debug!(
        "[ccs:resume_opencode_child] opencode={} cwd={} --session {}",
        opencode_path.display(),
        working_dir,
        resume_arg
    );

    Command::new(&opencode_path)
        .current_dir(&working_dir)
        .args(["--session", &resume_arg])
        .status()
        .map_err(|e| format!("Failed to spawn opencode: {}", e))?;

    Ok(())
}

/// Open Claude Desktop app for Desktop sessions
pub fn resume_desktop() -> Result<(), String> {
    let mut cmd = Command::new("open");
    cmd.args(["-a", "Claude"]);
    exec_command(&mut cmd)
}

/// Open Claude Desktop app as a child process and wait for it to close.
/// Uses `open -W` so the TUI regains control only after the user closes Claude Desktop.
pub fn resume_desktop_child() -> Result<(), String> {
    // Ignore exit code: `open -W` can return non-zero for benign reasons
    // (e.g., app already running). Same approach as resume_cli_child().
    Command::new("open")
        .args(["-W", "-a", "Claude"])
        .status()
        .map_err(|e| format!("Failed to open Claude Desktop: {}", e))?;

    Ok(())
}

#[cfg(unix)]
fn exec_command(cmd: &mut Command) -> Result<(), String> {
    let err = cmd.exec();
    Err(format!("Failed to exec: {}", err))
}

#[cfg(not(unix))]
fn exec_command(cmd: &mut Command) -> Result<(), String> {
    let status = cmd
        .status()
        .map_err(|e| format!("Failed to spawn: {}", e))?;
    if status.success() {
        Ok(())
    } else {
        Err(format!("Process exited with {}", status))
    }
}

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

    #[test]
    fn test_ensure_project_dir_creates_directory() {
        let temp_dir = TempDir::new().unwrap();
        let new_dir = temp_dir.path().join("subdir").join("session.jsonl");

        let result = ensure_project_dir(new_dir.to_str().unwrap());

        assert!(result.is_ok());
        assert!(temp_dir.path().join("subdir").exists());
    }

    #[test]
    fn test_prepare_resume_returns_session_id() {
        let dir = TempDir::new().unwrap();
        let session_id = "abc-123";
        let session_file = dir.path().join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let result = prepare_resume(session_id, session_file.to_str().unwrap()).unwrap();

        // Should return session ID (Claude CLI doesn't accept file paths for --resume)
        assert_eq!(result, session_id);
    }

    #[test]
    fn test_build_resume_command_resumes_by_jsonl_path_when_project_dir_missing() {
        // Regression: when the original project directory no longer exists on
        // disk (moved/renamed), `claude --resume <session-id>` cannot resolve the
        // session from the fallback cwd and prints
        // "No conversation found with session ID". build_resume_command must
        // instead point claude at the absolute .jsonl path so the transcript is
        // loaded straight from the file, independent of cwd.
        let dir = TempDir::new().unwrap();

        // A guaranteed-missing decoded target: create then drop a tempdir.
        let ghost_dir = TempDir::new().unwrap();
        let ghost_path = ghost_dir.path().to_path_buf();
        drop(ghost_dir);
        assert!(
            !ghost_path.exists(),
            "ghost dir should not exist after drop"
        );

        let encoded = encode_path_for_claude(ghost_path.to_str().unwrap());
        let project_dir = dir.path().join(".claude").join("projects").join(&encoded);
        fs::create_dir_all(&project_dir).unwrap();

        let session_id = "missing-project-test";
        let session_file = project_dir.join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let (working_dir, resume_arg) =
            build_resume_command(session_id, session_file.to_str().unwrap()).unwrap();

        // Falls back to a real, existing directory for cwd...
        assert!(
            Path::new(&working_dir).exists(),
            "working_dir should exist: {}",
            working_dir
        );
        // ...and resumes by the absolute .jsonl path, not the bare session id.
        assert_eq!(
            resume_arg,
            session_file.canonicalize().unwrap().to_string_lossy(),
            "Missing project dir must resume by jsonl path so claude finds the session"
        );
    }

    #[test]
    fn test_build_resume_command_canonicalizes_relative_jsonl_resume_path() {
        // Regression: build_resume_command launches claude with cwd set to
        // working_dir. If the path-based resume arg stays relative, claude
        // resolves it under that new cwd and looks in the wrong location.
        let cwd = std::env::current_dir().unwrap();
        let dir = TempDir::new_in(&cwd).unwrap();

        let ghost_dir = TempDir::new().unwrap();
        let ghost_path = ghost_dir.path().to_path_buf();
        drop(ghost_dir);
        assert!(
            !ghost_path.exists(),
            "ghost dir should not exist after drop"
        );

        let encoded = encode_path_for_claude(ghost_path.to_str().unwrap());
        let project_dir = dir.path().join(".claude").join("projects").join(&encoded);
        fs::create_dir_all(&project_dir).unwrap();

        let session_id = "relative-missing-project-test";
        let session_file = project_dir.join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let relative_session_file = session_file.strip_prefix(&cwd).unwrap();
        assert!(
            relative_session_file.is_relative(),
            "test must pass a relative session path"
        );

        let (_working_dir, resume_arg) =
            build_resume_command(session_id, relative_session_file.to_str().unwrap()).unwrap();

        assert!(
            Path::new(&resume_arg).is_absolute(),
            "path-based resume arg must be absolute: {}",
            resume_arg
        );
        assert_eq!(
            Path::new(&resume_arg).canonicalize().unwrap(),
            session_file.canonicalize().unwrap(),
            "relative input must be canonicalized before changing child cwd"
        );
    }

    #[test]
    fn test_build_resume_command_does_not_create_nonexistent_project_dir() {
        // When decode_project_path returns a path that doesn't exist on disk,
        // build_resume_command should NOT create it — it should fall back safely.
        let dir = TempDir::new().unwrap();
        // Create a sibling tempdir for the "decoded" target that we immediately delete,
        // guaranteeing the path cannot exist on any machine.
        let ghost_dir = TempDir::new().unwrap();
        let ghost_path = ghost_dir.path().to_path_buf();
        drop(ghost_dir); // remove the directory
        assert!(
            !ghost_path.exists(),
            "ghost dir should not exist after drop"
        );

        // Encode the ghost path in Claude's dash-separated format.
        // decode_project_path will decode it back, but the directory won't exist.
        let encoded = ghost_path.to_str().unwrap().replace('/', "-");
        let project_dir = dir.path().join(".claude").join("projects").join(&encoded);
        fs::create_dir_all(&project_dir).unwrap();

        let session_id = "nodir-test";
        let session_file = project_dir.join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let result = build_resume_command(session_id, session_file.to_str().unwrap());
        assert!(result.is_ok());

        let (working_dir, _) = result.unwrap();
        // The decoded ghost path must NOT have been created
        assert!(
            !ghost_path.exists(),
            "build_resume_command should not create non-existent decoded project directories"
        );
        // working_dir should still be a valid, existing directory (fallback)
        assert!(
            Path::new(&working_dir).exists(),
            "working_dir should exist (fallback): {}",
            working_dir
        );
    }

    #[test]
    fn test_build_resume_command_uses_existing_decoded_path() {
        // When decode_project_path returns a path that already exists,
        // it should be used as the working directory.
        let dir = TempDir::new().unwrap();
        // Create both the .claude/projects/<encoded> dir and the real project dir
        let real_project = dir.path().join("myproject");
        fs::create_dir_all(&real_project).unwrap();

        // Encode the temp path so decode_project_path can resolve it
        let encoded_name = crate::resume::path_codec::encode_path_for_claude(
            dir.path().join("myproject").to_str().unwrap(),
        );
        let project_dir = dir
            .path()
            .join(".claude")
            .join("projects")
            .join(&encoded_name);
        fs::create_dir_all(&project_dir).unwrap();

        let session_id = "existing-dir-test";
        let session_file = project_dir.join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let (working_dir, resume_arg) =
            build_resume_command(session_id, session_file.to_str().unwrap()).unwrap();

        // Should use the real existing project directory
        assert_eq!(
            Path::new(&working_dir).canonicalize().unwrap(),
            real_project.canonicalize().unwrap(),
            "Should use the existing decoded project path as working dir"
        );
        // cwd re-encodes to the JSONL's project dir, so resume by session id.
        assert_eq!(
            resume_arg, session_id,
            "When the project dir exists, claude resolves the session from cwd by id"
        );
    }

    #[test]
    fn test_build_resume_command_falls_back_to_session_parent() {
        // When decode_project_path fails (no .claude/projects/ in path),
        // the fallback should be the session file's parent directory.
        let dir = TempDir::new().unwrap();
        // Place file directly in temp dir — no .claude/projects/ structure
        let session_id = "fallback-test";
        let session_file = dir.path().join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let (working_dir, _) =
            build_resume_command(session_id, session_file.to_str().unwrap()).unwrap();

        // Should fall back to session file's parent directory
        assert_eq!(
            Path::new(&working_dir).canonicalize().unwrap(),
            dir.path().canonicalize().unwrap(),
            "Should fall back to session file parent dir"
        );
    }

    #[test]
    fn test_build_codex_resume_command_uses_session_cwd() {
        let dir = TempDir::new().unwrap();
        let project_dir = dir.path().join("codex-project");
        fs::create_dir_all(&project_dir).unwrap();

        let session_dir = dir.path().join(".codex/sessions/2026/05/03");
        fs::create_dir_all(&session_dir).unwrap();

        let session_id = "019f0000-0000-7000-8000-000000000001";
        let session_file =
            session_dir.join(format!("rollout-2026-05-03T10-00-00-{session_id}.jsonl"));
        let meta = serde_json::json!({
            "timestamp": "2026-05-03T10:00:00Z",
            "type": "session_meta",
            "payload": {
                "id": session_id,
                "cwd": project_dir.to_string_lossy().to_string(),
                "source": "cli"
            }
        });
        fs::write(&session_file, format!("{meta}\n")).unwrap();

        let (working_dir, resume_arg) =
            build_codex_resume_command(session_id, session_file.to_str().unwrap()).unwrap();

        assert_eq!(resume_arg, session_id);
        assert_eq!(
            Path::new(&working_dir).canonicalize().unwrap(),
            project_dir.canonicalize().unwrap(),
            "Codex resume should start in the cwd from session_meta"
        );
    }

    #[test]
    fn test_build_codex_resume_command_falls_back_to_session_parent() {
        let dir = TempDir::new().unwrap();
        let ghost_dir = TempDir::new().unwrap();
        let ghost_path = ghost_dir.path().to_path_buf();
        drop(ghost_dir);
        assert!(!ghost_path.exists());

        let session_dir = dir.path().join(".codex/sessions/2026/05/03");
        fs::create_dir_all(&session_dir).unwrap();

        let session_id = "019f0000-0000-7000-8000-000000000001";
        let session_file =
            session_dir.join(format!("rollout-2026-05-03T10-00-00-{session_id}.jsonl"));
        let meta = serde_json::json!({
            "timestamp": "2026-05-03T10:00:00Z",
            "type": "session_meta",
            "payload": {
                "id": session_id,
                "cwd": ghost_path.to_string_lossy().to_string(),
                "source": "cli"
            }
        });
        fs::write(&session_file, format!("{meta}\n")).unwrap();

        let (working_dir, resume_arg) =
            build_codex_resume_command(session_id, session_file.to_str().unwrap()).unwrap();

        assert_eq!(resume_arg, session_id);
        assert_eq!(
            Path::new(&working_dir).canonicalize().unwrap(),
            session_dir.canonicalize().unwrap(),
            "Codex resume should fall back to the rollout parent when cwd is gone"
        );
    }

    #[test]
    fn test_ensure_session_in_index_uses_unique_tmp_file() {
        // Verify temp file name includes process ID for uniqueness.
        // We place a sentinel file at the OLD fixed temp name and verify it's
        // not overwritten — proving the function uses a different (PID-based) name.
        let dir = TempDir::new().unwrap();
        let session_id = "unique-tmp-test";
        let session_file = dir.path().join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        // Create an existing index so ensure_session_in_index will add to it
        let index_path = dir.path().join(SESSIONS_INDEX_FILE);
        fs::write(
            &index_path,
            r#"{"version":1,"entries":[],"originalPath":""}"#,
        )
        .unwrap();

        // Place a sentinel at the OLD fixed temp name
        let old_tmp = dir.path().join(format!(".{}.tmp", SESSIONS_INDEX_FILE));
        fs::write(&old_tmp, "SENTINEL").unwrap();

        let analysis = analyze_session(session_file.to_str().unwrap()).unwrap();
        ensure_session_in_index(session_id, session_file.to_str().unwrap(), &analysis);

        // Sentinel must be intact — the function should not have touched the old fixed name.
        // If the function still uses the fixed name, the sentinel gets overwritten then renamed away.
        let sentinel_content = fs::read_to_string(&old_tmp).unwrap_or_default();
        assert_eq!(
            sentinel_content, "SENTINEL",
            "Fixed-name temp file was overwritten or removed; temp names must include PID"
        );

        // The index should be updated
        let content = fs::read_to_string(&index_path).unwrap();
        assert!(content.contains(session_id));
    }

    #[test]
    fn test_ensure_session_in_index_atomic_rename() {
        // Verify that after ensure_session_in_index, the index file is valid JSON.
        let dir = TempDir::new().unwrap();
        let session_id = "atomic-rename-test";
        let session_file = dir.path().join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let index_path = dir.path().join(SESSIONS_INDEX_FILE);
        fs::write(
            &index_path,
            r#"{"version":1,"entries":[],"originalPath":""}"#,
        )
        .unwrap();

        let analysis = analyze_session(session_file.to_str().unwrap()).unwrap();
        ensure_session_in_index(session_id, session_file.to_str().unwrap(), &analysis);

        // Index must be valid JSON after the write
        let content = fs::read_to_string(&index_path).unwrap();
        let parsed: Result<serde_json::Value, _> = serde_json::from_str(&content);
        assert!(
            parsed.is_ok(),
            "Index file must be valid JSON after atomic write"
        );

        // No stale temp files should remain
        let entries: Vec<_> = fs::read_dir(dir.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
            .collect();
        assert!(
            entries.is_empty(),
            "No temp files should remain after successful atomic rename"
        );
    }

    /// Build a tiny SQLite DB with one session pointing at `directory` and
    /// return `(temp_dir, synthetic_path)`. The synthetic path is what the
    /// rest of ccs stores on `RecentSession.file_path` for an Opencode session.
    fn build_opencode_db_fixture(directory: &str) -> (TempDir, String) {
        use rusqlite::Connection;
        let dir = TempDir::new().unwrap();
        let db_path = dir.path().join("opencode.db");
        let conn = Connection::open(&db_path).unwrap();
        conn.execute_batch(
            r#"
            CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, name TEXT, time_created INTEGER, time_updated INTEGER);
            CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, slug TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER);
            CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT);
            CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT);
            "#,
        )
        .unwrap();
        conn.execute(
            "INSERT INTO project VALUES ('projAAA', ?1, 'myproj', 1, 2)",
            [directory],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO session VALUES ('ses_TEST', 'projAAA', '', ?1, 't', 1, 2)",
            [directory],
        )
        .unwrap();
        let synthetic = format!("{}#ses_TEST", db_path.to_string_lossy());
        (dir, synthetic)
    }

    #[test]
    fn test_build_opencode_resume_command_uses_session_directory() {
        let dir = TempDir::new().unwrap();
        let project = dir.path().join("myproj");
        fs::create_dir_all(&project).unwrap();
        let (_db_dir, synthetic) = build_opencode_db_fixture(project.to_str().unwrap());

        let (working_dir, resume_arg) =
            build_opencode_resume_command("ses_TEST", &synthetic).unwrap();
        assert_eq!(resume_arg, "ses_TEST");
        assert_eq!(
            Path::new(&working_dir).canonicalize().unwrap(),
            project.canonicalize().unwrap()
        );
    }

    #[test]
    fn test_build_opencode_resume_command_falls_back_when_directory_missing() {
        let ghost = TempDir::new().unwrap();
        let ghost_path = ghost.path().to_path_buf();
        drop(ghost);
        assert!(!ghost_path.exists());

        let (_db_dir, synthetic) = build_opencode_db_fixture(ghost_path.to_str().unwrap());

        let (working_dir, _) = build_opencode_resume_command("ses_TEST", &synthetic).unwrap();
        assert!(
            Path::new(&working_dir).exists(),
            "working_dir should exist when session.directory is missing"
        );
        assert_ne!(
            Path::new(&working_dir).canonicalize().unwrap(),
            ghost_path,
            "must not use the missing directory"
        );
    }

    #[test]
    fn test_ensure_session_in_index_idempotent() {
        // Calling twice with the same session_id should not duplicate the entry.
        let dir = TempDir::new().unwrap();
        let session_id = "idempotent-test";
        let session_file = dir.path().join(format!("{}.jsonl", session_id));
        {
            let mut f = fs::File::create(&session_file).unwrap();
            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"hi"}},"sessionId":"{}","timestamp":"2025-01-01T00:00:00Z"}}"#, session_id).unwrap();
        }

        let index_path = dir.path().join(SESSIONS_INDEX_FILE);
        fs::write(
            &index_path,
            r#"{"version":1,"entries":[],"originalPath":""}"#,
        )
        .unwrap();

        let analysis = analyze_session(session_file.to_str().unwrap()).unwrap();

        // Call twice
        ensure_session_in_index(session_id, session_file.to_str().unwrap(), &analysis);
        ensure_session_in_index(session_id, session_file.to_str().unwrap(), &analysis);

        // Count entries with this session_id — should be exactly 1
        let content = fs::read_to_string(&index_path).unwrap();
        let index: serde_json::Value = serde_json::from_str(&content).unwrap();
        let count = index["entries"]
            .as_array()
            .unwrap()
            .iter()
            .filter(|e| e["sessionId"].as_str() == Some(session_id))
            .count();
        assert_eq!(
            count, 1,
            "Session should appear exactly once in index, not duplicated"
        );
    }

    // NOTE: test_resume_cli_child_fails_without_claude_binary was moved to
    // tests/resume_resolution.rs — it mutates PATH globally and caused flaky
    // failures in parallel ripgrep tests when run as a unit test.
}
