//! Implements `limb setup`. Symlinks shared files into every worktree.

use std::path::{Component, Path, PathBuf};

use anyhow::{Context as _, Result};
use pathdiff::diff_paths;

use crate::cli::SetupArgs;
use crate::context::Context;
use crate::worktree::{self, Worktree};

/// Runs `limb setup`.
///
/// For every file declared under `[worktrees] shared = [...]` in
/// `.limb.toml`, creates a symlink in each worktree pointing back to the
/// source copy (typically under `.shared/`). A no-op if the worktree
/// already has a matching file, unless `--refresh-shared` is passed (in
/// which case symlinks pointing elsewhere are recreated; regular files
/// are preserved).
///
/// # Errors
///
/// Returns an error if no `.limb.toml` is present, the shared source
/// directory is missing, or a symlink cannot be created.
pub fn run(ctx: &Context, args: &SetupArgs) -> Result<()> {
    let repo_config = ctx
        .repo_config()?
        .context("no .limb.toml in this repo or any ancestor; try: `limb doctor`")?;
    if repo_config.worktrees_shared.is_empty() {
        eprintln!("no shared files declared in .limb.toml; nothing to do");
        return Ok(());
    }

    let repo = ctx.repo()?;
    let worktrees = worktree::list(&repo)?;
    let source_dir = repo_config.resolved_shared_source();
    if !source_dir.is_dir() {
        anyhow::bail!(
            "shared source dir does not exist: {}; \
             try: `mkdir -p {}` and add the files listed under worktrees.shared",
            source_dir.display(),
            source_dir.display()
        );
    }

    let mut summary = Summary::default();
    for wt in &worktrees {
        if wt.bare {
            continue;
        }
        for relative in &repo_config.worktrees_shared {
            sync_one(
                &source_dir,
                relative,
                wt,
                args.refresh_shared,
                args.dry_run,
                &mut summary,
            );
        }
    }
    summary.emit();
    Ok(())
}

#[derive(Default)]
struct Summary {
    linked: usize,
    relinked: usize,
    already_ok: usize,
    skipped_missing_source: usize,
    skipped_conflict: usize,
}

impl Summary {
    fn emit(&self) {
        let action = |n: usize, verb: &str| {
            if n == 0 {
                return;
            }
            eprintln!("  {n} {verb}");
        };
        eprintln!("setup:");
        action(self.linked, "linked");
        action(self.relinked, "relinked");
        action(self.already_ok, "already ok");
        action(self.skipped_missing_source, "skipped (source missing)");
        action(self.skipped_conflict, "skipped (existing non-symlink)");
    }
}

fn sync_one(
    source_dir: &Path,
    relative: &Path,
    worktree: &Worktree,
    refresh: bool,
    dry_run: bool,
    summary: &mut Summary,
) {
    // `source_dir` is already absolute (see `Repo::resolved_shared_source`).
    // Normalize away any `.`/`..` components so `pathdiff` can compute a
    // minimal relative path even when callers pass things like
    // `<root>/../foo`.
    let src_abs = normalize(&source_dir.join(relative));
    let dst = worktree.path.join(relative);

    if !src_abs.exists() {
        eprintln!("skip: {} → source missing", dst.display());
        summary.skipped_missing_source += 1;
        return;
    }

    // The symlink content must be relative to the symlink's *parent
    // directory*, not the worktree root — otherwise nested shared files
    // (e.g. `public/settings.json`) don't resolve correctly.
    let link_target = dst
        .parent()
        .and_then(|parent| diff_paths(&src_abs, normalize(parent)))
        .unwrap_or_else(|| src_abs.clone());

    match classify(&dst, &link_target) {
        State::Absent => create_link(&link_target, &dst, dry_run, summary, LinkAction::New),
        State::CorrectLink => {
            summary.already_ok += 1;
        }
        State::WrongLink if refresh => {
            if !dry_run {
                let _ = std::fs::remove_file(&dst);
            }
            create_link(&link_target, &dst, dry_run, summary, LinkAction::Relink);
        }
        State::WrongLink => {
            eprintln!(
                "skip: {} → symlink points elsewhere; use --refresh-shared to replace",
                dst.display()
            );
            summary.skipped_conflict += 1;
        }
        State::RegularFile => {
            eprintln!(
                "skip: {} → regular file exists; remove it manually to let `limb setup` link",
                dst.display()
            );
            summary.skipped_conflict += 1;
        }
    }
}

/// Lexically resolves `.` and `..` components in `path` (no filesystem
/// access; does not follow symlinks).
///
/// Behavior:
/// - Absolute paths: `..` above the root is silently dropped (matches POSIX
///   `cd ..` at `/`).
/// - Relative paths: leading `..` are preserved (so `../foo` stays
///   `../foo`); interior `..` collapse against the prior component.
/// - Empty result becomes `.` so the return value is always a valid path.
fn normalize(path: &Path) -> PathBuf {
    let mut comps: Vec<Component<'_>> = Vec::with_capacity(8);
    for c in path.components() {
        match c {
            Component::CurDir => {}
            Component::ParentDir => match comps.last() {
                Some(Component::Normal(_)) => {
                    comps.pop();
                }
                Some(Component::RootDir | Component::Prefix(_)) => {}
                _ => comps.push(Component::ParentDir),
            },
            other => comps.push(other),
        }
    }
    if comps.is_empty() {
        PathBuf::from(".")
    } else {
        comps.iter().map(|c| c.as_os_str()).collect()
    }
}

#[derive(Clone, Copy)]
enum LinkAction {
    New,
    Relink,
}

impl LinkAction {
    const fn verb(self) -> &'static str {
        match self {
            Self::New => "link",
            Self::Relink => "relink",
        }
    }
}

fn create_link(src: &Path, dst: &Path, dry_run: bool, summary: &mut Summary, action: LinkAction) {
    if let Some(parent) = dst.parent()
        && !parent.exists()
        && !dry_run
    {
        let _ = std::fs::create_dir_all(parent);
    }
    let verb = action.verb();
    if dry_run {
        eprintln!("would {verb}: {} → {}", dst.display(), src.display());
    } else {
        match symlink(src, dst) {
            Ok(()) => eprintln!("{verb}: {} → {}", dst.display(), src.display()),
            Err(e) => {
                eprintln!("error: {verb} {}: {e}", dst.display());
                return;
            }
        }
    }
    match action {
        LinkAction::New => summary.linked += 1,
        LinkAction::Relink => summary.relinked += 1,
    }
}

enum State {
    Absent,
    CorrectLink,
    WrongLink,
    RegularFile,
}

fn classify(dst: &Path, expected: &Path) -> State {
    let Ok(metadata) = std::fs::symlink_metadata(dst) else {
        return State::Absent;
    };
    if metadata.file_type().is_symlink() {
        match std::fs::read_link(dst) {
            Ok(target) if target == *expected => State::CorrectLink,
            _ => State::WrongLink,
        }
    } else {
        State::RegularFile
    }
}

#[cfg(unix)]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
    std::os::unix::fs::symlink(src, dst)
}

#[cfg(windows)]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
    if src.is_dir() {
        std::os::windows::fs::symlink_dir(src, dst)
    } else {
        std::os::windows::fs::symlink_file(src, dst)
    }
}

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

    fn pb(s: &str) -> PathBuf {
        PathBuf::from(s)
    }

    #[test]
    fn normalize_passthrough_for_simple_paths() {
        assert_eq!(normalize(&pb("/a/b/c")), pb("/a/b/c"));
        assert_eq!(normalize(&pb("a/b/c")), pb("a/b/c"));
    }

    #[test]
    fn normalize_drops_curdir_components() {
        assert_eq!(normalize(&pb("./foo")), pb("foo"));
        assert_eq!(normalize(&pb("foo/./bar")), pb("foo/bar"));
        assert_eq!(normalize(&pb("/a/./b")), pb("/a/b"));
    }

    #[test]
    fn normalize_collapses_interior_parentdir_against_prior_component() {
        assert_eq!(normalize(&pb("a/b/../c")), pb("a/c"));
        assert_eq!(normalize(&pb("/a/b/../c")), pb("/a/c"));
        assert_eq!(normalize(&pb("a/b/../../c")), pb("c"));
    }

    #[test]
    fn normalize_drops_parentdir_above_root_in_absolute_paths() {
        assert_eq!(normalize(&pb("/..")), pb("/"));
        assert_eq!(normalize(&pb("/../..")), pb("/"));
        assert_eq!(normalize(&pb("/../foo")), pb("/foo"));
    }

    #[test]
    fn normalize_preserves_leading_parentdir_in_relative_paths() {
        assert_eq!(normalize(&pb("../foo")), pb("../foo"));
        assert_eq!(normalize(&pb("../../foo")), pb("../../foo"));
        assert_eq!(normalize(&pb("../foo/../bar")), pb("../bar"));
    }

    #[test]
    fn normalize_returns_curdir_for_paths_that_collapse_to_empty() {
        assert_eq!(normalize(&pb("")), pb("."));
        assert_eq!(normalize(&pb(".")), pb("."));
        assert_eq!(normalize(&pb("foo/..")), pb("."));
        assert_eq!(normalize(&pb("./.")), pb("."));
    }

    #[test]
    fn normalize_handles_repeated_parentdirs_relative() {
        // a/../../foo: pop "a", then leading ".." preserved, then "foo".
        assert_eq!(normalize(&pb("a/../../foo")), pb("../foo"));
    }
}
