//! Worktree data model and git-worktree CLI wrappers.
//!
//! This module owns the shape of the data the rest of the crate moves
//! around: [`Worktree`], [`Status`], [`Upstream`]. It also contains the
//! parser for `git worktree list --porcelain` output and the primitive
//! operations (`add`, `remove`, `require`, `list*`, `fast_forward`,
//! `fetch`) that each `cmd/*.rs` composes into a full subcommand.

use std::path::{Path, PathBuf};
use std::time::SystemTime;

use anyhow::{Context, Result};
use rayon::prelude::*;
use serde::{Serialize, Serializer};

use crate::discover;
use crate::git;
use crate::refresh;

/// A git worktree, as returned by `git worktree list --porcelain`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Worktree {
    /// Absolute path to the worktree directory.
    pub path: PathBuf,
    /// Display name. The file-name component of [`Self::path`].
    pub name: String,
    /// Checked-out branch, or `None` for detached HEAD.
    pub branch: Option<String>,
    /// Short HEAD commit hash, or `None` for bare worktrees.
    pub head: Option<String>,
    /// `true` for the bare-clone sentinel worktree entry.
    #[serde(skip_serializing_if = "is_false")]
    pub bare: bool,
    /// `true` if the worktree is locked (see `git worktree lock`).
    #[serde(skip_serializing_if = "is_false")]
    pub locked: bool,
    /// Reason recorded with `git worktree lock --reason`, when present.
    /// Always `None` when [`Self::locked`] is `false`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub locked_reason: Option<String>,
    /// `true` if git considers this worktree prunable.
    #[serde(skip_serializing_if = "is_false")]
    pub prunable: bool,
    /// Reason git reported for the prunable state, when present.
    /// Always `None` when [`Self::prunable`] is `false`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prunable_reason: Option<String>,
}

/// A [`Worktree`] plus live status: dirty-file count and upstream tracking.
#[derive(Debug, Clone, Serialize)]
pub struct Status {
    #[serde(flatten)]
    pub worktree: Worktree,
    /// Count of tracked files with staged or unstaged changes.
    pub dirty_files: usize,
    /// Upstream-tracking info, or `None` if the branch has no upstream
    /// or is detached.
    pub upstream: Option<Upstream>,
    /// Last `git fetch` time for the enclosing repo (mtime of `FETCH_HEAD`).
    /// `None` when nothing has ever been fetched. Serialized as
    /// seconds-since-unix-epoch.
    #[serde(
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_unix_secs"
    )]
    pub fetched_at: Option<SystemTime>,
}

#[allow(clippy::ref_option)]
fn serialize_unix_secs<S: Serializer>(t: &Option<SystemTime>, ser: S) -> Result<S::Ok, S::Error> {
    match t.and_then(|st| st.duration_since(SystemTime::UNIX_EPOCH).ok()) {
        Some(d) => ser.serialize_u64(d.as_secs()),
        None => ser.serialize_none(),
    }
}

/// Tracking state of a branch relative to its upstream.
#[derive(Debug, Clone, Serialize)]
pub struct Upstream {
    /// Full upstream ref name, e.g. `origin/main`.
    pub name: String,
    /// Commits this worktree is ahead of the upstream.
    pub ahead: u32,
    /// Commits this worktree is behind the upstream.
    pub behind: u32,
}

#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
    !b
}

/// Lists every worktree attached to `repo`.
///
/// Backed by `git worktree list --porcelain`.
///
/// # Errors
///
/// Returns an error if the git invocation fails.
pub fn list(repo: &Path) -> Result<Vec<Worktree>> {
    let out = git::capture(repo, &["worktree", "list", "--porcelain"])?;
    Ok(parse_porcelain(&out))
}

/// Returns the worktree named `name`, or an error with a "did you mean?"
/// hint if no exact match is found.
///
/// # Errors
///
/// Returns an error if the worktree list cannot be read or if `name` does
/// not match any non-bare worktree.
pub fn require(repo: &Path, name: &str) -> Result<Worktree> {
    let worktrees = list(repo)?;
    if let Some(w) = worktrees.iter().find(|w| w.name == name) {
        return Ok(w.clone());
    }
    let hint = suggest_name(name, &worktrees);
    anyhow::bail!("no worktree named '{name}'{hint}; try: `limb list` to see available")
}

fn suggest_name(name: &str, worktrees: &[Worktree]) -> String {
    const MIN_SIMILARITY: f64 = 0.72;
    worktrees
        .iter()
        .filter(|w| !w.bare)
        .map(|w| (strsim::jaro_winkler(name, &w.name), &w.name))
        .filter(|(score, _)| *score >= MIN_SIMILARITY)
        .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
        .map(|(_, n)| format!("; did you mean '{n}'?"))
        .unwrap_or_default()
}

/// Like [`list`], but each row carries live dirty-count and upstream info.
///
/// Status is computed in parallel across worktrees via `rayon`.
///
/// # Errors
///
/// Returns an error if the worktree list cannot be read.
pub fn list_with_status(repo: &Path) -> Result<Vec<Status>> {
    let trees = list(repo)?;
    let fetched_at = refresh::fetched_at(repo);
    Ok(trees
        .par_iter()
        .map(|w| compute_status(w, fetched_at))
        .collect())
}

/// A [`Worktree`] tagged with the enclosing repo name.
///
/// Returned by [`list_all`] when scanning across `projects.roots`.
#[derive(Debug, Clone, Serialize)]
pub struct RepoWorktree {
    /// Enclosing repo's directory name.
    pub repo: String,
    #[serde(flatten)]
    pub worktree: Worktree,
}

/// Cross-repo version of [`list`], scanning every configured root.
///
/// Repos that cannot be read emit a warning on stderr and are skipped.
/// this function never fails; `projects.roots` is user config and a
/// single bad entry should not nuke the whole command.
#[must_use]
pub fn list_all(roots: &[PathBuf]) -> Vec<RepoWorktree> {
    discover_repos(roots)
        .par_iter()
        .flat_map(|(name, repo_dir)| {
            let Some(anchor) = discover::anchor_for(repo_dir) else {
                return Vec::new();
            };
            list(&anchor)
                .unwrap_or_default()
                .into_iter()
                .map(|w| RepoWorktree {
                    repo: name.clone(),
                    worktree: w,
                })
                .collect::<Vec<_>>()
        })
        .collect()
}

/// Cross-repo version of [`list_with_status`]; same error-swallowing
/// behaviour as [`list_all`].
///
/// `FETCH_HEAD` mtime is read once per repo and shared across every
/// worktree of that repo (which is the right granularity since fetching
/// is a repo-level operation).
#[must_use]
pub fn list_all_with_status(roots: &[PathBuf]) -> Vec<RepoStatus> {
    discover_repos(roots)
        .par_iter()
        .flat_map(|(name, repo_dir)| {
            let Some(anchor) = discover::anchor_for(repo_dir) else {
                return Vec::new();
            };
            let fetched_at = refresh::fetched_at(&anchor);
            list(&anchor)
                .unwrap_or_default()
                .into_iter()
                .map(|w| RepoStatus {
                    repo: name.clone(),
                    status: compute_status(&w, fetched_at),
                })
                .collect::<Vec<_>>()
        })
        .collect()
}

fn discover_repos(roots: &[PathBuf]) -> Vec<(String, PathBuf)> {
    roots
        .iter()
        .filter_map(|root| {
            if !root.is_dir() {
                eprintln!("warning: {} is not a directory. Skipping", root.display());
                return None;
            }
            discover::repos_under(root)
                .map_err(|e| {
                    eprintln!("warning: {}: {e}", root.display());
                })
                .ok()
        })
        .flatten()
        .map(|r| (discover::repo_name(&r), r))
        .collect()
}

/// A [`Status`] tagged with the enclosing repo name.
#[derive(Debug, Clone, Serialize)]
pub struct RepoStatus {
    /// Enclosing repo's directory name.
    pub repo: String,
    #[serde(flatten)]
    pub status: Status,
}

/// Resolves where a worktree named `name` should be created.
///
/// `base_dir` may be absolute (used as-is), `..` (sibling of `repo`), or
/// relative to `repo` (e.g. `worktrees`).
///
/// # Errors
///
/// Returns an error if `base_dir == ".."` and `repo` has no parent
/// directory (which in practice only happens for filesystem roots).
///
/// # Examples
///
/// ```
/// use std::path::{Path, PathBuf};
/// use limb::worktree::target_path;
///
/// let sibling = target_path(Path::new("/repos/myproj"), "feat-x", Path::new(".."))?;
/// assert_eq!(sibling, PathBuf::from("/repos/feat-x"));
///
/// let nested = target_path(Path::new("/repos/myproj"), "feat-x", Path::new("worktrees"))?;
/// assert_eq!(nested, PathBuf::from("/repos/myproj/worktrees/feat-x"));
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn target_path(repo: &Path, name: &str, base_dir: &Path) -> Result<PathBuf> {
    if base_dir.is_absolute() {
        Ok(base_dir.join(name))
    } else if base_dir == Path::new("..") {
        let parent = repo
            .parent()
            .context("repo has no parent directory; cannot place sibling worktree")?;
        Ok(parent.join(name))
    } else {
        Ok(repo.join(base_dir).join(name))
    }
}

/// Optional flags forwarded to `git worktree add`.
///
/// Matches the git-worktree(1) flag surface. Mutually-exclusive
/// combinations are rejected at the CLI layer (clap `conflicts_with`);
/// this struct doesn't re-validate.
#[derive(Debug, Default, Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
pub struct AddOptions<'a> {
    /// Existing branch to check out (`git worktree add <path> <branch>`).
    pub branch: Option<&'a str>,
    /// Commit-ish the new branch (or detached HEAD) should start from.
    pub from: Option<&'a str>,
    /// Configure upstream tracking on the new branch (`--track`).
    /// Only meaningful when creating a new branch (i.e. `from` is set).
    pub track: bool,
    /// Disable upstream tracking on the new branch (`--no-track`).
    /// Only meaningful when creating a new branch (i.e. `from` is set).
    pub no_track: bool,
    /// Create a detached HEAD instead of a branch.
    pub detach: bool,
    /// Create an orphan branch (no parent commits). Branch name defaults
    /// to the basename of the worktree path.
    pub orphan: bool,
    /// Lock the new worktree via `--lock`.
    pub lock: bool,
    /// Reason passed to `--reason`; requires [`Self::lock`].
    pub reason: Option<&'a str>,
    /// Force-create branch (`-B`); resets it if it already exists.
    /// Mutually exclusive with [`Self::branch`]; requires [`Self::from`].
    pub force_branch: Option<&'a str>,
    /// Guess the upstream remote-tracking branch from the start commit
    /// (`--guess-remote`).
    pub guess_remote: bool,
    /// Disable upstream guessing (`--no-guess-remote`).
    pub no_guess_remote: bool,
    /// Skip checking files out into the new worktree (`--no-checkout`).
    pub no_checkout: bool,
    /// Store admin paths relative to the repo (`--relative-paths`).
    pub relative_paths: bool,
    /// Force absolute admin paths (`--no-relative-paths`).
    pub no_relative_paths: bool,
    /// Pass `--quiet` through to git, suppressing its progress output.
    pub quiet: bool,
}

/// Creates a new worktree under `base_dir` (or `..` if unset).
///
/// Dispatches on [`AddOptions`] to pick the correct `git worktree add`
/// invocation. Detached, orphan, existing branch, or new branch from
/// commit-ish.
///
/// # Errors
///
/// Returns an error if `target_path` resolution fails or the underlying
/// `git worktree add` exits non-zero.
pub fn add(repo: &Path, name: &str, base_dir: &Path, opts: AddOptions<'_>) -> Result<PathBuf> {
    let target = target_path(repo, name, base_dir)?;
    let target_str = target.to_string_lossy().into_owned();

    let mut args: Vec<String> = vec!["worktree".into(), "add".into()];

    if opts.lock {
        args.push("--lock".into());
        if let Some(reason) = opts.reason {
            args.push("--reason".into());
            args.push(reason.into());
        }
    }
    if opts.quiet {
        args.push("--quiet".into());
    }
    if opts.no_checkout {
        args.push("--no-checkout".into());
    }
    if opts.relative_paths {
        args.push("--relative-paths".into());
    } else if opts.no_relative_paths {
        args.push("--no-relative-paths".into());
    }

    if opts.detach {
        args.push("--detach".into());
        args.push(target_str);
        if let Some(commit) = opts.from {
            args.push(commit.into());
        }
    } else if opts.orphan {
        args.push("--orphan".into());
        args.push(target_str);
    } else if let Some(fb) = opts.force_branch {
        args.push("-B".into());
        args.push(fb.into());
        push_branch_modifier_flags(&mut args, &opts);
        args.push(target_str);
        if let Some(f) = opts.from {
            args.push(f.into());
        }
    } else {
        match (opts.branch, opts.from) {
            (None, None) => {
                args.push(target_str);
            }
            (Some(b), None) => {
                args.push(target_str);
                args.push(b.into());
            }
            (None, Some(f)) => {
                args.push("-b".into());
                args.push(name.into());
                push_branch_modifier_flags(&mut args, &opts);
                args.push(target_str);
                args.push(f.into());
            }
            (Some(b), Some(f)) => {
                args.push("-b".into());
                args.push(b.into());
                push_branch_modifier_flags(&mut args, &opts);
                args.push(target_str);
                args.push(f.into());
            }
        }
    }

    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
    git::run(repo, &refs)?;
    Ok(target)
}

fn push_branch_modifier_flags(args: &mut Vec<String>, opts: &AddOptions<'_>) {
    if opts.track {
        args.push("--track".into());
    } else if opts.no_track {
        args.push("--no-track".into());
    }
    if opts.guess_remote {
        args.push("--guess-remote".into());
    } else if opts.no_guess_remote {
        args.push("--no-guess-remote".into());
    }
}

/// Removes the worktree named `name`.
///
/// Translates to `git worktree remove [--force] [--quiet] <path>`.
///
/// # Errors
///
/// Returns an error if `name` is not a known worktree or the git
/// invocation fails (for example, dirty worktree without `force`).
pub fn remove(repo: &Path, name: &str, force: bool, quiet: bool) -> Result<()> {
    let w = require(repo, name)?;
    let path_str = w.path.to_string_lossy().into_owned();
    let mut args = vec!["worktree", "remove"];
    if force {
        args.push("--force");
    }
    if quiet {
        args.push("--quiet");
    }
    args.push(&path_str);
    git::run(repo, &args)
}

/// Runs `git fetch --all --prune` in `repo`.
///
/// # Errors
///
/// Returns an error if the fetch fails (typically: offline, auth issues).
pub fn fetch(repo: &Path, quiet: bool) -> Result<()> {
    let mut args = vec!["fetch", "--all", "--prune"];
    if quiet {
        args.push("--quiet");
    }
    git::run(repo, &args)
}

/// Fast-forwards `worktree` to its upstream (`git merge --ff-only
/// @{upstream}`).
///
/// # Errors
///
/// Returns an error if no upstream is configured or if the merge is not a
/// fast-forward. This is treated as "skip" by `limb update`, not fatal.
pub fn fast_forward(worktree: &Path, quiet: bool) -> Result<()> {
    let mut args = vec!["merge", "--ff-only", "@{upstream}"];
    if quiet {
        args.push("--quiet");
    }
    git::run(worktree, &args)
}

fn compute_status(w: &Worktree, fetched_at: Option<SystemTime>) -> Status {
    let dirty_files = if w.bare {
        0
    } else {
        git::capture(&w.path, &["status", "--porcelain=v1"]).map_or(0, |s| s.lines().count())
    };
    let upstream = upstream_for(&w.path);
    Status {
        worktree: w.clone(),
        dirty_files,
        upstream,
        fetched_at,
    }
}

fn upstream_for(path: &Path) -> Option<Upstream> {
    let name = git::capture(
        path,
        &[
            "rev-parse",
            "--abbrev-ref",
            "--symbolic-full-name",
            "@{upstream}",
        ],
    )
    .ok()?
    .trim()
    .to_string();
    if name.is_empty() {
        return None;
    }
    let counts = git::capture(
        path,
        &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
    )
    .ok()?;
    let mut it = counts.split_whitespace();
    let behind: u32 = it.next()?.parse().ok()?;
    let ahead: u32 = it.next()?.parse().ok()?;
    Some(Upstream {
        name,
        ahead,
        behind,
    })
}

/// Parses the output of `git worktree list --porcelain` into [`Worktree`]s.
///
/// Each record in the input is delimited by a blank line. Recognised keys
/// are `worktree`, `HEAD`, `branch`, `bare`, `locked`, and `prunable`;
/// unknown keys are ignored. Trailing reasons on `locked`/`prunable` lines
/// are captured into [`Worktree::locked_reason`] / [`Worktree::prunable_reason`].
///
/// # Examples
///
/// ```
/// use limb::worktree::parse_porcelain;
///
/// let input = "worktree /repos/myproj\nHEAD abc123\nbranch refs/heads/main\n\n";
/// let trees = parse_porcelain(input);
/// assert_eq!(trees.len(), 1);
/// assert_eq!(trees[0].name, "myproj");
/// assert_eq!(trees[0].branch.as_deref(), Some("main"));
/// ```
#[must_use]
pub fn parse_porcelain(s: &str) -> Vec<Worktree> {
    let mut out = Vec::new();
    let mut cur: Option<Worktree> = None;
    for line in s.lines() {
        if line.is_empty() {
            if let Some(t) = cur.take() {
                out.push(t);
            }
            continue;
        }
        let (key, val) = line.split_once(' ').unwrap_or((line, ""));
        match key {
            "worktree" => {
                let path = PathBuf::from(val);
                let name = path
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_default();
                cur = Some(Worktree {
                    path,
                    name,
                    branch: None,
                    head: None,
                    bare: false,
                    locked: false,
                    locked_reason: None,
                    prunable: false,
                    prunable_reason: None,
                });
            }
            "HEAD" => {
                if let Some(t) = cur.as_mut() {
                    t.head = Some(val.to_string());
                }
            }
            "branch" => {
                if let Some(t) = cur.as_mut() {
                    let short = val.strip_prefix("refs/heads/").unwrap_or(val);
                    t.branch = Some(short.to_string());
                }
            }
            "bare" => {
                if let Some(t) = cur.as_mut() {
                    t.bare = true;
                }
            }
            "locked" => {
                if let Some(t) = cur.as_mut() {
                    t.locked = true;
                    if !val.is_empty() {
                        t.locked_reason = Some(val.to_string());
                    }
                }
            }
            "prunable" => {
                if let Some(t) = cur.as_mut() {
                    t.prunable = true;
                    if !val.is_empty() {
                        t.prunable_reason = Some(val.to_string());
                    }
                }
            }
            _ => {}
        }
    }
    if let Some(t) = cur.take() {
        out.push(t);
    }
    out
}

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

    #[test]
    fn parses_single_worktree() {
        let input = "worktree /home/u/proj\nHEAD abc123\nbranch refs/heads/main\n\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].name, "proj");
        assert_eq!(out[0].head.as_deref(), Some("abc123"));
        assert_eq!(out[0].branch.as_deref(), Some("main"));
        assert!(!out[0].bare);
    }

    #[test]
    fn parses_multiple_worktrees() {
        let input = "worktree /a/main\nHEAD a\nbranch refs/heads/main\n\nworktree /a/feat\nHEAD b\nbranch refs/heads/feat\n\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 2);
        assert_eq!(out[0].name, "main");
        assert_eq!(out[1].name, "feat");
    }

    #[test]
    fn parses_bare_repo() {
        let input = "worktree /a/.bare\nbare\n\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 1);
        assert!(out[0].bare);
        assert!(out[0].branch.is_none());
    }

    #[test]
    fn parses_locked_and_prunable() {
        let input = "worktree /a/w\nHEAD h\nbranch refs/heads/x\nlocked\nprunable gone\n\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 1);
        assert!(out[0].locked);
        assert!(out[0].locked_reason.is_none());
        assert!(out[0].prunable);
        assert_eq!(out[0].prunable_reason.as_deref(), Some("gone"));
    }

    #[test]
    fn parses_locked_with_reason() {
        let input = "worktree /a/w\nHEAD h\nbranch refs/heads/x\nlocked in use by build\n\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 1);
        assert!(out[0].locked);
        assert_eq!(out[0].locked_reason.as_deref(), Some("in use by build"));
    }

    #[test]
    fn parses_detached_head() {
        let input = "worktree /a/w\nHEAD abc\ndetached\n\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 1);
        assert!(out[0].branch.is_none());
        assert_eq!(out[0].head.as_deref(), Some("abc"));
    }

    #[test]
    fn trailing_newline_optional() {
        let input = "worktree /a\nHEAD x\nbranch refs/heads/main\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 1);
    }

    #[test]
    fn parses_path_with_spaces() {
        let input = "worktree /my folder/repo\nHEAD abc\nbranch refs/heads/main\n\n";
        let out = parse_porcelain(input);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].path, PathBuf::from("/my folder/repo"));
        assert_eq!(out[0].name, "repo");
    }
}

#[cfg(test)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    fn format_porcelain(w: &Worktree) -> String {
        let mut s = String::new();
        s.push_str("worktree ");
        s.push_str(&w.path.to_string_lossy());
        s.push('\n');
        if let Some(h) = &w.head {
            s.push_str("HEAD ");
            s.push_str(h);
            s.push('\n');
        }
        if let Some(b) = &w.branch {
            s.push_str("branch refs/heads/");
            s.push_str(b);
            s.push('\n');
        }
        if w.bare {
            s.push_str("bare\n");
        }
        if w.locked {
            s.push_str("locked");
            if let Some(r) = &w.locked_reason {
                s.push(' ');
                s.push_str(r);
            }
            s.push('\n');
        }
        if w.prunable {
            s.push_str("prunable");
            if let Some(r) = &w.prunable_reason {
                s.push(' ');
                s.push_str(r);
            }
            s.push('\n');
        }
        s.push('\n');
        s
    }

    prop_compose! {
        fn arb_worktree()(
            segments in prop::collection::vec("[a-z][a-z0-9_-]{0,8}", 1..4),
            branch in prop::option::of("[a-z][a-z0-9/_-]{0,16}"),
            head in prop::option::of("[0-9a-f]{7,40}"),
            bare in any::<bool>(),
            locked in any::<bool>(),
            locked_reason in prop::option::of("[a-z][a-z0-9]{0,15}"),
            prunable in any::<bool>(),
            prunable_reason in prop::option::of("[a-z][a-z0-9]{0,15}"),
        ) -> Worktree {
            let path = PathBuf::from(format!("/{}", segments.join("/")));
            let name = path
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_default();
            let (branch, head) = if bare { (None, None) } else { (branch, head) };
            let locked_reason = if locked { locked_reason } else { None };
            let prunable_reason = if prunable { prunable_reason } else { None };
            Worktree {
                path,
                name,
                branch,
                head,
                bare,
                locked,
                locked_reason,
                prunable,
                prunable_reason,
            }
        }
    }

    proptest! {
        #[test]
        fn porcelain_round_trips(w in arb_worktree()) {
            let s = format_porcelain(&w);
            let parsed = parse_porcelain(&s);
            prop_assert_eq!(parsed.len(), 1);
            prop_assert_eq!(&parsed[0], &w);
        }
    }
}
