//! Per-run git worktree management.
//!
//! Every `omne run` creates a detached-HEAD worktree at
//! `.omne/wt/<run_id>/` via `git worktree add --detach`. Detached HEAD
//! avoids git's "branch already checked out" refusal on back-to-back
//! runs from the same branch (plan R5).
//!
//! The module is a thin wrapper over the `git` binary (subprocess), not
//! libgit2, to stay consistent with the user's own git installation and
//! hook configuration. The process pattern mirrors `crate::github` for
//! spawn-and-capture handling, with two local additions:
//!
//! - `LC_ALL=C` is forced on every git invocation so stderr classifier
//!   substrings like `"not a git repository"` stay locale-independent.
//! - A `GIT_TIMEOUT` bounds each subprocess so a wedged git (corrupted
//!   index, stalled network filesystem) cannot hang `omne run` forever.
//!
//! Path-length preflight: Windows' default `MAX_PATH` is 260 characters;
//! worktree creation can fail with cryptic "Filename too long" errors on
//! long volume roots. `preflight_volume_path_length` warns at 80 chars
//! and errors at 120 before any state mutation happens.

#![allow(dead_code)]

use std::collections::BTreeSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::Duration;

use thiserror::Error;
use wait_timeout::ChildExt;

use crate::volume::{wt_dir, wt_for};

/// Volume path length at which a warning is emitted (plan R5 preflight).
pub const PATH_LENGTH_WARN: usize = 80;

/// Volume path length at which preflight fails outright.
pub const PATH_LENGTH_ERROR: usize = 120;

/// Upper bound on any single `git worktree` subprocess. Enough slack for
/// slow NFS/SMB volumes without letting a wedged git stall the runner.
pub const GIT_TIMEOUT: Duration = Duration::from_secs(30);

/// Whether `remove` may discard uncommitted changes in the worktree.
///
/// Expressed as a named enum rather than a `bool` so call sites like
/// `remove(root, id, RemoveMode::Force)` stay self-documenting at the
/// call boundary.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum RemoveMode {
    /// Mirror git's default: refuse to remove a worktree with
    /// uncommitted changes or a lock.
    Normal,
    /// Pass `--force` to `git worktree remove`, discarding local
    /// modifications. Reserved for teardown paths where the executor
    /// has already captured the interesting output.
    Force,
}

#[derive(Debug, Error)]
pub enum Error {
    /// The target worktree directory already exists on disk, or `git
    /// worktree add` reported an existing path. Raised both from the
    /// pre-spawn `exists()` check and from the stderr classifier so a
    /// TOCTOU race between the two calls still surfaces as
    /// `AlreadyExists` rather than a generic `GitCommandFailed`.
    #[error(
        "worktree already exists at {path}\n\
         hint: `git worktree remove --force {}` to reclaim the slot",
        path.display()
    )]
    AlreadyExists { path: PathBuf },

    /// The volume root is not inside a git repository (or cannot reach
    /// `.git`). Plan-mandated preflight for `omne run`.
    #[error(
        "{path} is not a git repository\n\
         hint: run `git init` in the volume root before `omne run`"
    )]
    NotAGitRepo { path: PathBuf },

    /// `run_id` contains characters that would let the worktree path
    /// escape `.omne/wt/` (path separators, `..`, null, leading `-`).
    /// Raised before any filesystem or git call so no partial state
    /// leaks on rejection.
    #[error(
        "invalid run_id {run_id:?}: must not be empty, contain path separators \
         or null bytes, equal \".\" or \"..\", or start with '-'"
    )]
    InvalidRunId { run_id: String },

    /// Volume path is at or above `PATH_LENGTH_ERROR` characters.
    /// Windows-only concern in practice, but the threshold is
    /// platform-agnostic so tests behave the same on every host.
    ///
    /// Note: this threshold measures the **volume root** only. Pipelines
    /// that write deeply-nested files inside the worktree can still
    /// blow past `MAX_PATH` even when the volume root passes preflight.
    /// The threshold exists to catch the most common failure mode (long
    /// user-profile paths), not to guarantee every downstream file
    /// operation stays under 260.
    #[error(
        "volume path {length} chars is at or above max {limit}: {}\n\
         hint: shorten the volume root, or enable Windows Long Paths \
         (`LongPathsEnabled=1` in HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem)",
        path.display()
    )]
    PathTooLong {
        path: PathBuf,
        length: usize,
        limit: usize,
    },

    /// `git` could not be launched (missing binary, permission denied,
    /// etc). Distinct from `GitCommandFailed` (which is git itself
    /// returning non-zero).
    #[error("failed to launch git: {source}")]
    Io {
        #[source]
        source: std::io::Error,
    },

    /// `git` ran past `GIT_TIMEOUT` without exiting. The child is
    /// killed before this error is returned so no zombie subprocess
    /// leaks.
    #[error("git {args:?} did not exit within {elapsed:?}")]
    GitTimeout {
        args: Vec<String>,
        elapsed: Duration,
    },

    /// `git` ran but exited non-zero. `stderr` is preserved verbatim for
    /// the command handler to surface.
    #[error("git {args:?} failed: {stderr}")]
    GitCommandFailed { args: Vec<String>, stderr: String },
}

/// Cross-checked enumeration of per-run worktrees.
///
/// Three buckets because the two sources of truth (filesystem and
/// `git worktree list`) can diverge, and downstream consumers (plan
/// Unit 13 `omne status`, future cleanup path) need to know which:
///
/// - `paired` — fs dir AND git record both present. Healthy run.
/// - `fs_only` — fs dir present, git never knew or forgot (manual
///   `git worktree remove` without cleaning the dir, partial-state
///   leak from an interrupted `create`).
/// - `git_only` — git still tracks but fs dir is gone (manual `rm -rf`
///   without `git worktree prune`). `git worktree prune` is the fix.
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct WorktreeList {
    pub paired: Vec<String>,
    pub fs_only: Vec<String>,
    pub git_only: Vec<String>,
}

/// Check volume root path length against the plan's thresholds.
///
/// Returns `Ok(())` below `PATH_LENGTH_WARN`, `Ok(())` with a stderr
/// warning when the length is at or above `PATH_LENGTH_WARN` but below
/// `PATH_LENGTH_ERROR`, and `Err(PathTooLong)` when the length is at
/// or above `PATH_LENGTH_ERROR`. The thresholds are inclusive at the
/// boundary: the 120-char case fails; the 80-char case warns.
///
/// Length is measured in bytes of the `OsStr` representation. On ASCII
/// paths (the overwhelming majority of volume roots) this equals the
/// character count. On non-ASCII paths the threshold is conservative
/// relative to Unicode length, which is the right direction for a
/// headroom check against Windows `MAX_PATH`.
pub fn preflight_volume_path_length(volume_root: &Path) -> Result<(), Error> {
    let length = volume_root.as_os_str().len();
    if length >= PATH_LENGTH_ERROR {
        return Err(Error::PathTooLong {
            path: volume_root.to_path_buf(),
            length,
            limit: PATH_LENGTH_ERROR,
        });
    }
    if length >= PATH_LENGTH_WARN {
        eprintln!(
            "warning: volume path is {length} characters (>= {PATH_LENGTH_WARN}); \
             git worktree may hit Windows MAX_PATH limits"
        );
    }
    Ok(())
}

/// Create a detached-HEAD worktree at `.omne/wt/<run_id>`.
///
/// Returns the absolute (or volume-relative, as `git` prints it)
/// worktree path on success. The parent `.omne/wt/` directory is
/// created on demand so `create` is safe to call before scaffold has
/// ever touched the worktree root (useful in tests).
///
/// `create` does **not** call `preflight_volume_path_length` —
/// `omne run` runs preflight earlier so failures surface before any
/// ULID is minted. Callers invoking `create` directly must run
/// preflight themselves if they want early path-length diagnosis.
///
/// Errors:
/// - `AlreadyExists` if the target path exists before the git call.
/// - `NotAGitRepo` if `git` reports the volume root is not a repo.
/// - `GitTimeout` if the subprocess exceeds `GIT_TIMEOUT`.
/// - `GitCommandFailed` for any other non-zero `git` exit.
/// - `Io` if the `git` binary cannot be launched.
pub fn create(volume_root: &Path, run_id: &str) -> Result<PathBuf, Error> {
    validate_run_id(run_id)?;
    let wt_path = wt_for(volume_root, run_id);
    if wt_path.exists() {
        return Err(Error::AlreadyExists { path: wt_path });
    }

    let parent = wt_dir(volume_root);
    std::fs::create_dir_all(&parent).map_err(|source| Error::Io { source })?;

    let wt_path_str = wt_path.to_string_lossy();
    let args: [&str; 4] = ["worktree", "add", "--detach", wt_path_str.as_ref()];
    let output = run_git(volume_root, &args)?;

    if !output.status.success() {
        // Classify TOCTOU: another process created the same path
        // between the pre-spawn `exists()` check and this spawn.
        // `LC_ALL=C` is pinned by `run_git`, so the substring is stable.
        let stderr = String::from_utf8_lossy(&output.stderr);
        if stderr.contains("already exists") {
            return Err(Error::AlreadyExists { path: wt_path });
        }
        return Err(classify_git_error(volume_root, &args, &output.stderr));
    }
    Ok(wt_path)
}

/// Remove a per-run worktree via `git worktree remove`.
///
/// `RemoveMode::Force` passes `--force` to git, allowing removal of a
/// worktree that has uncommitted changes or is locked.
pub fn remove(volume_root: &Path, run_id: &str, mode: RemoveMode) -> Result<(), Error> {
    validate_run_id(run_id)?;
    let wt_path = wt_for(volume_root, run_id);
    let wt_path_str = wt_path.to_string_lossy();
    let mut args: Vec<&str> = vec!["worktree", "remove"];
    if matches!(mode, RemoveMode::Force) {
        args.push("--force");
    }
    args.push(wt_path_str.as_ref());

    let output = run_git(volume_root, &args)?;
    if !output.status.success() {
        return Err(classify_git_error(volume_root, &args, &output.stderr));
    }
    Ok(())
}

/// Enumerate per-run worktrees under `.omne/wt/`, partitioned into
/// healthy pairs plus filesystem-only and git-only orphans.
///
/// Three sources of truth are reconciled:
/// - child directories physically present under `.omne/wt/`,
/// - paths reported by `git worktree list --porcelain` resolving under
///   `.omne/wt/`,
/// - their intersection.
///
/// Unit 13 `omne status` uses the orphan buckets to flag unpaired
/// runs; a future cleanup path uses them to drive `git worktree prune`
/// or stray-directory removal. Returning only the intersection (as an
/// earlier draft did) silently leaked orphan state past consumers —
/// hence this struct-return shape.
///
/// If `.omne/wt/` exists but cannot be canonicalized (permission,
/// reparse-point anomaly), the error is surfaced as `Error::Io`
/// rather than being silently downgraded to empty buckets.
pub fn list(volume_root: &Path) -> Result<WorktreeList, Error> {
    let args: [&str; 3] = ["worktree", "list", "--porcelain"];
    let output = run_git(volume_root, &args)?;

    if !output.status.success() {
        return Err(classify_git_error(volume_root, &args, &output.stderr));
    }
    let stdout = String::from_utf8_lossy(&output.stdout);

    let wt_root = wt_dir(volume_root);
    let canonical_wt_root = if wt_root.is_dir() {
        Some(
            wt_root
                .canonicalize()
                .map_err(|source| Error::Io { source })?,
        )
    } else {
        None
    };

    // Parse porcelain output: each worktree record starts with a
    // `worktree <path>` line, followed by metadata lines. Empty lines
    // separate records.
    let mut git_ids: BTreeSet<String> = BTreeSet::new();
    for line in stdout.lines() {
        let Some(rest) = line.strip_prefix("worktree ") else {
            continue;
        };
        let p = Path::new(rest.trim());
        let Some(ref root) = canonical_wt_root else {
            continue;
        };
        let Ok(canon_p) = p.canonicalize() else {
            continue;
        };
        let Ok(rel) = canon_p.strip_prefix(root) else {
            continue;
        };
        if let Some(first) = rel.components().next() {
            git_ids.insert(first.as_os_str().to_string_lossy().into_owned());
        }
    }

    // Filesystem enumeration: direct children of `.omne/wt/` that are
    // directories. Missing wt_root is not an error — just no worktrees.
    let mut fs_ids: BTreeSet<String> = BTreeSet::new();
    if wt_root.is_dir() {
        let entries = std::fs::read_dir(&wt_root).map_err(|source| Error::Io { source })?;
        for entry in entries {
            let entry = entry.map_err(|source| Error::Io { source })?;
            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
            if is_dir {
                fs_ids.insert(entry.file_name().to_string_lossy().into_owned());
            }
        }
    }

    let paired: Vec<String> = git_ids.intersection(&fs_ids).cloned().collect();
    let fs_only: Vec<String> = fs_ids.difference(&git_ids).cloned().collect();
    let git_only: Vec<String> = git_ids.difference(&fs_ids).cloned().collect();
    Ok(WorktreeList {
        paired,
        fs_only,
        git_only,
    })
}

/// Reject a `run_id` that could be misinterpreted by downstream path
/// joining or by `git worktree add`. Called from `create` and `remove`
/// before any filesystem or subprocess effect so rejection leaves no
/// state behind.
///
/// `Path::join` on a segment containing `..` or an absolute prefix
/// escapes the `.omne/wt/` parent — accepting such a `run_id` would
/// let a caller drive the worktree machinery against arbitrary paths.
/// A leading `-` additionally risks git misinterpreting the path as a
/// flag.
fn validate_run_id(run_id: &str) -> Result<(), Error> {
    let invalid = run_id.is_empty()
        || run_id == "."
        || run_id == ".."
        || run_id.starts_with('-')
        || run_id.contains('/')
        || run_id.contains('\\')
        || run_id.contains('\0');
    if invalid {
        return Err(Error::InvalidRunId {
            run_id: run_id.to_string(),
        });
    }
    Ok(())
}

/// Spawn `git` with the module's fixed environment and a wall-clock
/// deadline, then capture stdout/stderr.
///
/// The child inherits the parent env, plus:
/// - `LC_ALL=C` so stderr substrings stay locale-independent for
///   `classify_git_error`.
/// - `LANGUAGE=` to suppress the GNU-specific localization variable
///   that otherwise wins over `LC_ALL` on some distros.
///
/// If the subprocess does not exit within `GIT_TIMEOUT`, the child is
/// killed and `Error::GitTimeout` is returned. Stdout and stderr are
/// captured via `Stdio::piped()` and fully read after the child exits;
/// the short, bounded output of `git worktree` commands makes this
/// safe without a reader thread.
fn run_git(volume_root: &Path, args: &[&str]) -> Result<Output, Error> {
    let mut child = Command::new("git")
        .current_dir(volume_root)
        .env("LC_ALL", "C")
        .env("LANGUAGE", "")
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|source| Error::Io { source })?;

    // Drain pipes before waiting to prevent deadlock when git output
    // fills the OS pipe buffer.
    let mut stdout = Vec::new();
    if let Some(mut s) = child.stdout.take() {
        s.read_to_end(&mut stdout)
            .map_err(|source| Error::Io { source })?;
    }
    let mut stderr = Vec::new();
    if let Some(mut s) = child.stderr.take() {
        s.read_to_end(&mut stderr)
            .map_err(|source| Error::Io { source })?;
    }

    let status = match child
        .wait_timeout(GIT_TIMEOUT)
        .map_err(|source| Error::Io { source })?
    {
        Some(status) => status,
        None => {
            let _ = child.kill();
            let _ = child.wait();
            return Err(Error::GitTimeout {
                args: args.iter().map(|s| s.to_string()).collect(),
                elapsed: GIT_TIMEOUT,
            });
        }
    };

    Ok(Output {
        status,
        stdout,
        stderr,
    })
}

fn classify_git_error(volume_root: &Path, args: &[&str], stderr: &[u8]) -> Error {
    let stderr = String::from_utf8_lossy(stderr).into_owned();
    // `run_git` pins `LC_ALL=C`, so the English substring is stable.
    if stderr.contains("not a git repository") {
        return Error::NotAGitRepo {
            path: volume_root.to_path_buf(),
        };
    }
    Error::GitCommandFailed {
        args: args.iter().map(|s| s.to_string()).collect(),
        stderr,
    }
}

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

    #[test]
    fn preflight_passes_under_warn_threshold() {
        let short = PathBuf::from("C:/o");
        preflight_volume_path_length(&short).expect("under warn threshold should pass");
    }

    #[test]
    fn preflight_warns_at_exact_warn_threshold() {
        // Exactly PATH_LENGTH_WARN chars: warns (stderr), does not err.
        let at_warn = PathBuf::from("x".repeat(PATH_LENGTH_WARN));
        preflight_volume_path_length(&at_warn).expect("at warn boundary must warn, not err");
    }

    #[test]
    fn preflight_warns_between_thresholds_without_error() {
        let mid = PathBuf::from("x".repeat(PATH_LENGTH_WARN + 10));
        preflight_volume_path_length(&mid).expect("mid-range should warn, not error");
    }

    #[test]
    fn preflight_errors_at_exact_error_threshold() {
        // Exactly PATH_LENGTH_ERROR chars: must err (inclusive threshold).
        let at_err = PathBuf::from("x".repeat(PATH_LENGTH_ERROR));
        let err = preflight_volume_path_length(&at_err).unwrap_err();
        assert!(
            matches!(
                err,
                Error::PathTooLong { length, limit, .. }
                    if length == PATH_LENGTH_ERROR && limit == PATH_LENGTH_ERROR
            ),
            "expected PathTooLong at exact threshold, got {err:?}"
        );
    }

    #[test]
    fn validate_run_id_accepts_typical_pipe_ulid_shape() {
        validate_run_id("faber-01arz3ndektsv4rrffq69g5fav").expect("typical run_id should pass");
        validate_run_id("x").expect("single-char run_id should pass");
    }

    #[test]
    fn validate_run_id_rejects_traversal_and_separators() {
        for bad in ["", ".", "..", "../escape", "a/b", "a\\b", "a\0b", "-flag"] {
            let err = validate_run_id(bad).unwrap_err();
            assert!(
                matches!(err, Error::InvalidRunId { .. }),
                "expected InvalidRunId for {bad:?}, got {err:?}"
            );
        }
    }

    #[test]
    fn preflight_errors_above_error_threshold() {
        let long = PathBuf::from("x".repeat(PATH_LENGTH_ERROR + 1));
        let err = preflight_volume_path_length(&long).unwrap_err();
        assert!(
            matches!(
                err,
                Error::PathTooLong { length, limit, .. }
                    if length == PATH_LENGTH_ERROR + 1 && limit == PATH_LENGTH_ERROR
            ),
            "expected PathTooLong, got {err:?}"
        );
    }
}
