//! Implements `limb migrate`. Converts plain clone → bare-clone layout.

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

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

use crate::cancel::Cancel;

use crate::cli::MigrateArgs;
use crate::context::Context;
use crate::git;

/// RAII guard that removes `path` on drop unless [`Self::commit`] was
/// called. Used to clean up a half-built staging directory if migration
/// errors out before the staged `.git` has been moved into it; once the
/// `.git` is inside, the guard must be committed (auto-cleanup at that
/// point would destroy the user's only copy of the git history).
struct StagingGuard {
    path: PathBuf,
    committed: bool,
}

impl StagingGuard {
    fn new(path: PathBuf) -> Self {
        Self {
            path,
            committed: false,
        }
    }

    fn commit(mut self) {
        self.committed = true;
    }
}

impl Drop for StagingGuard {
    fn drop(&mut self) {
        if !self.committed && self.path.exists() {
            let _ = std::fs::remove_dir_all(&self.path);
        }
    }
}

/// Runs `limb migrate`.
///
/// Converts a plain `.git`-based clone into the bare-clone + worktrees
/// layout that `limb` is designed around: the original `.git` directory
/// becomes `.bare/`, and the worktree content moves into a subdirectory
/// named after the checked-out branch.
///
/// Refuses to run on dirty trees, detached HEAD, or if a `.bare` already
/// exists. The original tree is renamed to `.{repo}-limb-old/` so the
/// operation is reversible by hand.
///
/// # Errors
///
/// Returns an error if the repo is dirty, detached, already bare, or if
/// any of the required filesystem moves fail.
pub fn run(ctx: &Context, args: &MigrateArgs) -> Result<()> {
    let repo = match &args.repo {
        Some(p) => p.clone(),
        None => ctx.repo()?,
    };
    migrate(&repo, args.dry_run, &ctx.cancel)
}

fn migrate(repo: &Path, dry_run: bool, cancel: &Cancel) -> Result<()> {
    let repo = repo
        .canonicalize()
        .with_context(|| format!("canonicalize {}", repo.display()))?;
    if !repo.is_dir() {
        anyhow::bail!("not a directory: {}", repo.display());
    }

    let git_dir = repo.join(".git");
    if repo.join(".bare").is_dir() {
        anyhow::bail!("{} already has .bare/; nothing to do", repo.display());
    }
    if !git_dir.is_dir() {
        anyhow::bail!(
            "{} does not look like a plain git clone (no .git directory); \
             try: `limb migrate` only works on repos you cloned directly, not worktrees",
            repo.display()
        );
    }

    let head_branch = git::capture(&repo, &["rev-parse", "--abbrev-ref", "HEAD"])?
        .trim()
        .to_string();
    if head_branch.is_empty() || head_branch == "HEAD" {
        anyhow::bail!("repo is in detached HEAD; check out a named branch first");
    }

    let dirty = git::capture(&repo, &["status", "--porcelain=v1"])?;
    if !dirty.trim().is_empty() {
        anyhow::bail!("working tree has uncommitted changes; commit or stash them first");
    }

    let parent = repo
        .parent()
        .context("repo has no parent directory")?
        .to_path_buf();
    let repo_name = repo
        .file_name()
        .and_then(|n| n.to_str())
        .context("repo has no basename")?
        .to_string();
    let new_root = parent.join(&repo_name);
    let bare_dir = new_root.join(".bare");
    let worktree_dir = new_root.join(&head_branch);
    let staging_dir = parent.join(format!(".{repo_name}-limb-migrate"));

    eprintln!("migrate plan:");
    eprintln!("  from: {}", repo.display());
    eprintln!("  bare:     {}", bare_dir.display());
    eprintln!(
        "  worktree: {}  (branch: {head_branch})",
        worktree_dir.display()
    );
    if dry_run {
        eprintln!("\ndry run; no changes made");
        return Ok(());
    }

    std::fs::create_dir_all(&staging_dir)
        .with_context(|| format!("create staging dir {}", staging_dir.display()))?;
    let staging_guard = StagingGuard::new(staging_dir.clone());

    cancel.check()?;

    let staged_bare = staging_dir.join(".bare");
    std::fs::rename(&git_dir, &staged_bare)
        .with_context(|| format!("move {} → {}", git_dir.display(), staged_bare.display()))?;
    // .git is now inside staging; further failures must not auto-clean
    // (would destroy the user's only copy of the git history).
    staging_guard.commit();

    git::run(&staged_bare, &["config", "--unset-all", "core.worktree"]).ok();
    git::run(&staged_bare, &["config", "core.bare", "true"])?;

    let staged_worktree = staging_dir.join(&head_branch);
    let staged_worktree_parent = staged_worktree
        .parent()
        .with_context(|| format!("no parent for {}", staged_worktree.display()))?;
    std::fs::create_dir_all(staged_worktree_parent)?;
    move_contents(&repo, &staged_worktree)?;

    std::fs::rename(&repo, parent.join(format!(".{repo_name}-limb-old")))
        .with_context(|| format!("rename old repo dir {}", repo.display()))?;
    std::fs::rename(&staging_dir, &new_root)
        .with_context(|| format!("rename staging → {}", new_root.display()))?;

    git::run(
        &new_root.join(".bare"),
        &[
            "worktree",
            "add",
            worktree_dir.to_string_lossy().as_ref(),
            &head_branch,
        ],
    )
    .ok();

    let old = parent.join(format!(".{repo_name}-limb-old"));
    if old.exists() {
        let _ = std::fs::remove_dir_all(&old);
    }

    eprintln!("\nmigrated: {} (branch {head_branch})", new_root.display());
    Ok(())
}

fn move_contents(src: &Path, dst: &Path) -> Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let name = entry.file_name();
        if name == ".git" || name == ".bare" {
            continue;
        }
        let to = dst.join(&name);
        std::fs::rename(entry.path(), &to)
            .with_context(|| format!("move {} → {}", entry.path().display(), to.display()))?;
    }
    Ok(())
}

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

    #[test]
    fn staging_guard_removes_dir_when_uncommitted() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let staging = tmp.path().join("staging");
        std::fs::create_dir(&staging).unwrap();
        std::fs::write(staging.join("marker"), b"").unwrap();
        {
            let _guard = StagingGuard::new(staging.clone());
            assert!(staging.exists());
        }
        assert!(!staging.exists(), "uncommitted guard must remove staging");
    }

    #[test]
    fn staging_guard_preserves_dir_when_committed() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let staging = tmp.path().join("staging");
        std::fs::create_dir(&staging).unwrap();
        std::fs::write(staging.join("marker"), b"").unwrap();
        {
            let guard = StagingGuard::new(staging.clone());
            guard.commit();
        }
        assert!(staging.exists(), "committed guard must preserve staging");
        assert!(staging.join("marker").exists());
    }

    #[test]
    fn staging_guard_handles_already_removed_path() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let staging = tmp.path().join("staging");
        // Path never existed; Drop should not panic.
        let _guard = StagingGuard::new(staging);
    }
}
