//! Repository archive discovery and safe refresh helpers for `thesa`.
//!
//! Made by Trevor Knott for Knott Dynamics.
//!
//! RepoForge is intentionally small: it knows how to find Git worktrees inside an
//! archive root, derive a stable `owner/repo` slug from a GitHub remote or path
//! layout, and refresh those clones with `git pull --ff-only`.
//! It does not write manifests, own terminal UI, or mutate dirty worktrees.
//!
//! The refresh path refuses dirty worktrees before pulling, so callers do not
//! silently overwrite local archive edits.
//!
//! ```
//! use std::path::Path;
//!
//! assert_eq!(
//!     repoforge::parse_github_remote_slug("https://github.com/octocat/Hello-World.git"),
//!     Some("octocat/Hello-World".to_string())
//! );
//! assert_eq!(
//!     repoforge::fallback_archive_slug(
//!         Path::new("archives"),
//!         Path::new("archives/octocat/Hello-World"),
//!     ),
//!     "octocat/Hello-World"
//! );
//! ```

use std::collections::BTreeSet;
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Output};
use std::thread;

/// Result type used by RepoForge APIs.
pub type Result<T> = std::result::Result<T, RepoForgeError>;

/// Errors returned while discovering or refreshing Git archive worktrees.
#[derive(Debug)]
pub enum RepoForgeError {
    /// The requested archive root exists but is not a directory.
    OutputNotDirectory(PathBuf),
    /// The `git` executable was not found in `PATH`.
    MissingGit,
    /// A worktree contains local changes and was not refreshed.
    DirtyWorktree {
        /// Derived archive slug, usually `owner/repo`.
        slug: String,
        /// Path to the dirty worktree.
        path: PathBuf,
    },
    /// A `git` command failed inside a worktree.
    GitCommandFailed {
        /// Derived archive slug, usually `owner/repo`.
        slug: String,
        /// Path where the command was run.
        path: PathBuf,
        /// Human-readable command context, such as `pull --ff-only`.
        context: String,
        /// Captured stderr/stdout or exit status.
        message: String,
    },
    /// Filesystem or process I/O failure.
    Io(io::Error),
}

impl fmt::Display for RepoForgeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::OutputNotDirectory(path) => {
                write!(f, "output path '{}' is not a directory", path.display())
            }
            Self::MissingGit => write!(f, "'git' executable not found in PATH"),
            Self::DirtyWorktree { slug, path } => write!(
                f,
                "{slug} has local changes at {}; commit, stash, or clean before refresh",
                path.display()
            ),
            Self::GitCommandFailed {
                slug,
                context,
                message,
                ..
            } => write!(f, "git command failed for '{slug}': {context}: {message}"),
            Self::Io(error) => write!(f, "I/O error: {error}"),
        }
    }
}

impl Error for RepoForgeError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Io(error) => Some(error),
            _ => None,
        }
    }
}

impl From<io::Error> for RepoForgeError {
    fn from(value: io::Error) -> Self {
        Self::Io(value)
    }
}

/// A discovered Git archive worktree.
///
/// `slug` is the canonical identifier used by callers for display and grouping.
/// GitHub remotes produce `owner/repo`; paths fall back to the archive layout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitArchiveCandidate {
    /// Absolute or caller-relative path to the Git worktree.
    pub path: PathBuf,
    /// Stable archive slug, usually `owner/repo`.
    pub slug: String,
    /// Configured `remote.origin.url`, when readable.
    pub remote_url: Option<String>,
}

impl GitArchiveCandidate {
    /// Borrow the slug as `(owner, repo)` when the archive slug is `owner/repo`.
    pub fn slug_parts(&self) -> Option<(&str, &str)> {
        split_owner_repo_slug(&self.slug)
    }

    /// Borrow the slug owner when the archive slug is `owner/repo`.
    pub fn owner(&self) -> Option<&str> {
        self.slug_parts().map(|(owner, _)| owner)
    }

    /// Borrow the repository name when the archive slug is `owner/repo`.
    pub fn repo_name(&self) -> Option<&str> {
        self.slug_parts().map(|(_, repo)| repo)
    }

    /// Return true when the archive slug owner matches `owner` case-insensitively.
    pub fn matches_owner(&self, owner: &str) -> bool {
        let owner = owner.trim();
        !owner.is_empty()
            && self
                .owner()
                .is_some_and(|candidate| candidate.eq_ignore_ascii_case(owner))
    }
}

/// Split a normalized `owner/repo` slug into borrowed owner and repo parts.
///
/// Empty components, `.`/`..`, and slugs with more than one slash are rejected.
pub fn split_owner_repo_slug(slug: &str) -> Option<(&str, &str)> {
    let mut parts = slug.trim().split('/');
    let owner = parts.next()?.trim();
    let repo = parts.next()?.trim();
    if parts.next().is_some()
        || owner.is_empty()
        || repo.is_empty()
        || owner == "."
        || owner == ".."
        || repo == "."
        || repo == ".."
    {
        return None;
    }
    Some((owner, repo))
}

/// Outcome for one successful archive refresh.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitRefreshStatus {
    /// `HEAD` changed after `git pull --ff-only`.
    Updated,
    /// Pull completed and `HEAD` did not change.
    Unchanged,
}

/// Successful refresh result for one worktree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRefreshEntry {
    /// Archive slug refreshed.
    pub slug: String,
    /// Worktree path refreshed.
    pub path: PathBuf,
    /// Refresh status.
    pub status: GitRefreshStatus,
}

/// Failed refresh result for one worktree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRefreshFailure {
    /// Archive slug that failed.
    pub slug: String,
    /// Worktree path that failed.
    pub path: PathBuf,
    /// Human-readable failure message.
    pub message: String,
}

/// Summary returned by `refresh_git_archives`.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct GitRefreshSummary {
    /// Number of worktrees whose `HEAD` changed.
    pub updated: usize,
    /// Number of worktrees that were already current.
    pub unchanged: usize,
    /// Successful per-worktree outcomes.
    pub entries: Vec<GitRefreshEntry>,
    /// Failed per-worktree outcomes.
    pub failed: Vec<GitRefreshFailure>,
}

impl GitRefreshSummary {
    /// Number of worktrees that completed successfully.
    pub fn processed(&self) -> usize {
        self.updated + self.unchanged
    }

    /// Total attempted worktrees, including failures.
    pub fn total(&self) -> usize {
        self.processed() + self.failed.len()
    }

    /// Whether any worktree failed to refresh.
    pub fn has_failures(&self) -> bool {
        !self.failed.is_empty()
    }

    /// Whether every attempted worktree refreshed successfully.
    pub fn is_success(&self) -> bool {
        self.failed.is_empty()
    }

    /// Failed slugs as a set, useful for caller-side manifest grouping.
    pub fn failed_slugs(&self) -> BTreeSet<String> {
        self.failed
            .iter()
            .map(|failure| failure.slug.clone())
            .collect()
    }
}

/// Discover Git worktrees under an archive root.
///
/// Discovery is recursive, but it stops descending when it finds a worktree.
/// `.git`, `.thesa`, and `target` directories are skipped. If `filter` is set,
/// it is matched case-insensitively against the derived slug and full path.
///
/// Missing roots return an empty list; existing non-directory roots return
/// `RepoForgeError::OutputNotDirectory`.
pub fn discover_git_archives(
    root: &Path,
    filter: Option<&str>,
) -> Result<Vec<GitArchiveCandidate>> {
    if !root.exists() {
        return Ok(Vec::new());
    }
    if !root.is_dir() {
        return Err(RepoForgeError::OutputNotDirectory(root.to_path_buf()));
    }

    let mut archives = Vec::new();
    discover_git_archives_inner(root, root, filter, &mut archives)?;
    archives.sort_by(|a, b| a.slug.cmp(&b.slug).then_with(|| a.path.cmp(&b.path)));
    Ok(archives)
}

/// Discover Git worktrees for one GitHub owner/user/org under an archive root.
///
/// The owner is matched against the derived `owner/repo` slug, case-insensitively.
/// This supports both GitHub remote-derived slugs and path-derived layouts such
/// as `archives/<owner>/<repo>`.
pub fn discover_git_archives_for_owner(
    root: &Path,
    owner: &str,
    filter: Option<&str>,
) -> Result<Vec<GitArchiveCandidate>> {
    let owner = owner.trim();
    if owner.is_empty() {
        return Ok(Vec::new());
    }

    let mut archives = discover_git_archives(root, filter)?;
    archives.retain(|archive| archive.matches_owner(owner));
    Ok(archives)
}

fn discover_git_archives_inner(
    root: &Path,
    current: &Path,
    filter: Option<&str>,
    archives: &mut Vec<GitArchiveCandidate>,
) -> Result<()> {
    if is_git_worktree(current) {
        let remote_url = git_remote_origin_url(current);
        let slug = git_archive_slug(root, current, remote_url.as_deref());
        let candidate = GitArchiveCandidate {
            path: current.to_path_buf(),
            slug,
            remote_url,
        };
        if git_archive_matches_filter(&candidate, filter) {
            archives.push(candidate);
        }
        return Ok(());
    }

    let mut entries = fs::read_dir(current)?.collect::<io::Result<Vec<_>>>()?;
    entries.sort_by_key(|entry| entry.path());
    for entry in entries {
        let path = entry.path();
        let file_type = entry.file_type()?;
        if !file_type.is_dir() || should_skip_archive_scan_dir(&path) {
            continue;
        }
        discover_git_archives_inner(root, &path, filter, archives)?;
    }

    Ok(())
}

/// Return true when `path` looks like a Git worktree.
///
/// This checks for a `.git` entry, which supports both regular clone
/// directories and worktrees whose `.git` is a file.
pub fn is_git_worktree(path: &Path) -> bool {
    path.join(".git").exists()
}

fn should_skip_archive_scan_dir(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
        return false;
    };
    matches!(name, ".git" | ".thesa" | "target")
}

/// Read `remote.origin.url` from a Git worktree.
pub fn git_remote_origin_url(repo_path: &Path) -> Option<String> {
    let output = Command::new("git")
        .current_dir(repo_path)
        .args(["config", "--get", "remote.origin.url"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
    (!remote.is_empty()).then_some(remote)
}

/// Derive a stable archive slug from a remote URL or archive path layout.
///
/// GitHub remotes win. If the remote is absent or not parseable as GitHub, the
/// slug falls back to `fallback_archive_slug`.
pub fn git_archive_slug(root: &Path, repo_path: &Path, remote_url: Option<&str>) -> String {
    remote_url
        .and_then(parse_github_remote_slug)
        .unwrap_or_else(|| fallback_archive_slug(root, repo_path))
}

/// Parse a GitHub remote URL into `owner/repo`.
///
/// Supports HTTPS, SSH, and common `git@github.com:owner/repo.git` forms.
pub fn parse_github_remote_slug(remote_url: &str) -> Option<String> {
    let cleaned = remote_url.trim();
    let path = if let Some(path) = cleaned.strip_prefix("git@github.com:") {
        path
    } else if let Some(path) = cleaned.strip_prefix("ssh://git@github.com/") {
        path
    } else if let Some(path) = cleaned.strip_prefix("https://github.com/") {
        path
    } else if let Some(path) = cleaned.strip_prefix("http://github.com/") {
        path
    } else if let Some((_, path)) = cleaned.split_once("github.com/") {
        path
    } else if let Some((_, path)) = cleaned.split_once("github.com:") {
        path
    } else {
        return None;
    };
    github_path_to_slug(path)
}

fn github_path_to_slug(path: &str) -> Option<String> {
    let path = path
        .split(['?', '#'])
        .next()
        .unwrap_or(path)
        .trim()
        .trim_matches('/');
    let mut parts = path.split('/').filter(|part| !part.is_empty());
    let owner = parts.next()?.trim();
    let repo = parts.next()?.trim().trim_end_matches(".git");
    if owner.is_empty()
        || repo.is_empty()
        || owner == "."
        || owner == ".."
        || repo == "."
        || repo == ".."
    {
        return None;
    }
    Some(format!("{owner}/{repo}"))
}

/// Derive a slug from archive path layout when no GitHub remote is available.
///
/// For `archives/octocat/Hello-World`, this returns `octocat/Hello-World`.
pub fn fallback_archive_slug(root: &Path, repo_path: &Path) -> String {
    let relative = repo_path.strip_prefix(root).unwrap_or(repo_path);
    let parts = relative
        .components()
        .filter_map(|component| match component {
            Component::Normal(value) => value.to_str().map(ToString::to_string),
            _ => None,
        })
        .collect::<Vec<_>>();

    match parts.as_slice() {
        [] => repo_path
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or("archive")
            .to_string(),
        [single] => single.clone(),
        [owner, rest @ ..] => format!("{owner}/{}", rest.last().unwrap_or(owner)),
    }
}

fn git_archive_matches_filter(candidate: &GitArchiveCandidate, filter: Option<&str>) -> bool {
    let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) else {
        return true;
    };
    let filter = filter.to_ascii_lowercase();
    candidate.slug.to_ascii_lowercase().contains(&filter)
        || candidate
            .path
            .display()
            .to_string()
            .to_ascii_lowercase()
            .contains(&filter)
}

/// Refresh multiple Git archives with bounded concurrency.
///
/// Each worktree is checked with `git status --porcelain` before pulling. Dirty
/// worktrees are reported as failures and are not pulled.
///
/// ```no_run
/// # fn main() -> repoforge::Result<()> {
/// let archives = repoforge::discover_git_archives(std::path::Path::new("./archives"), None)?;
/// let summary = repoforge::refresh_git_archives(&archives, 4);
/// if summary.has_failures() {
///     eprintln!("{} archives failed", summary.failed.len());
/// }
/// # Ok(())
/// # }
/// ```
pub fn refresh_git_archives(
    archives: &[GitArchiveCandidate],
    concurrency: usize,
) -> GitRefreshSummary {
    let mut summary = GitRefreshSummary::default();
    let batch_size = concurrency.max(1);

    for batch in archives.chunks(batch_size) {
        let handles = batch
            .iter()
            .cloned()
            .map(|archive| {
                let thread_archive = archive.clone();
                (
                    archive,
                    thread::spawn(move || refresh_one_git_archive(&thread_archive)),
                )
            })
            .collect::<Vec<_>>();

        for (archive, handle) in handles {
            match handle.join() {
                Ok(Ok(status)) => {
                    match status {
                        GitRefreshStatus::Updated => summary.updated += 1,
                        GitRefreshStatus::Unchanged => summary.unchanged += 1,
                    }
                    summary.entries.push(GitRefreshEntry {
                        slug: archive.slug,
                        path: archive.path,
                        status,
                    });
                }
                Ok(Err(err)) => summary.failed.push(GitRefreshFailure {
                    slug: archive.slug,
                    path: archive.path,
                    message: err.to_string(),
                }),
                Err(_) => summary.failed.push(GitRefreshFailure {
                    slug: archive.slug,
                    path: archive.path,
                    message: "worker thread panicked while refreshing".to_string(),
                }),
            }
        }
    }

    summary
}

/// Refresh one Git archive with `git pull --ff-only`.
///
/// Returns `Updated` when `HEAD` changes and `Unchanged` otherwise.
pub fn refresh_one_git_archive(archive: &GitArchiveCandidate) -> Result<GitRefreshStatus> {
    ensure_clean_git_worktree(archive)?;
    let before = git_head(archive)?;
    let output = run_git_capture(&archive.path, &["pull", "--ff-only"])?;
    if !output.status.success() {
        return Err(git_command_failed(archive, "pull --ff-only", &output));
    }
    let after = git_head(archive)?;

    if before.is_some() && after.is_some() && before != after {
        Ok(GitRefreshStatus::Updated)
    } else {
        Ok(GitRefreshStatus::Unchanged)
    }
}

/// Fail if a worktree has local changes according to `git status --porcelain`.
pub fn ensure_clean_git_worktree(archive: &GitArchiveCandidate) -> Result<()> {
    let output = run_git_capture(&archive.path, &["status", "--porcelain"])?;
    if !output.status.success() {
        return Err(git_command_failed(archive, "status --porcelain", &output));
    }
    let status = String::from_utf8_lossy(&output.stdout);
    if !status.trim().is_empty() {
        return Err(RepoForgeError::DirtyWorktree {
            slug: archive.slug.clone(),
            path: archive.path.clone(),
        });
    }
    Ok(())
}

/// Read the current `HEAD` commit hash for a worktree.
///
/// Returns `Ok(None)` for repositories without a readable `HEAD`.
pub fn git_head(archive: &GitArchiveCandidate) -> Result<Option<String>> {
    let output = run_git_capture(&archive.path, &["rev-parse", "HEAD"])?;
    if !output.status.success() {
        return Ok(None);
    }
    let head = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok((!head.is_empty()).then_some(head))
}

/// Run `git` in `repo_path` and capture stdout/stderr.
///
/// This low-level helper is public for callers that want RepoForge's consistent
/// missing-git error handling.
pub fn run_git_capture(repo_path: &Path, args: &[&str]) -> Result<Output> {
    Command::new("git")
        .current_dir(repo_path)
        .args(args)
        .output()
        .map_err(|error| {
            if error.kind() == io::ErrorKind::NotFound {
                RepoForgeError::MissingGit
            } else {
                RepoForgeError::Io(error)
            }
        })
}

fn git_command_failed(
    archive: &GitArchiveCandidate,
    context: &str,
    output: &Output,
) -> RepoForgeError {
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let message = if !stderr.is_empty() {
        stderr
    } else if !stdout.is_empty() {
        stdout
    } else {
        output.status.code().map_or_else(
            || "signal/unknown".to_string(),
            |code| format!("exit code {code}"),
        )
    };

    RepoForgeError::GitCommandFailed {
        slug: archive.slug.clone(),
        path: archive.path.clone(),
        context: context.to_string(),
        message,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn parses_github_remote_slugs() {
        assert_eq!(
            parse_github_remote_slug("https://github.com/octocat/Hello-World.git"),
            Some("octocat/Hello-World".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("git@github.com:Tknott95/GitArchiver.git"),
            Some("Tknott95/GitArchiver".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("ssh://git@github.com/owner/repo"),
            Some("owner/repo".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("https://example.com/owner/repo"),
            None
        );
    }

    #[test]
    fn splits_owner_repo_slugs() {
        assert_eq!(
            split_owner_repo_slug(" octocat/Hello-World "),
            Some(("octocat", "Hello-World"))
        );
        assert_eq!(split_owner_repo_slug("octocat"), None);
        assert_eq!(split_owner_repo_slug("octocat/Hello/World"), None);
        assert_eq!(split_owner_repo_slug("../repo"), None);
        assert_eq!(split_owner_repo_slug("owner/."), None);
    }

    #[test]
    fn fallback_slug_uses_archive_path_layout() {
        let root = Path::new("archives");
        assert_eq!(
            fallback_archive_slug(root, Path::new("archives/octocat/Hello-World")),
            "octocat/Hello-World"
        );
        assert_eq!(
            fallback_archive_slug(root, Path::new("archives/linux/linux")),
            "linux/linux"
        );
    }

    #[test]
    fn discovers_git_archives_from_archive_tree() {
        let root = test_temp_dir("discover_git_archives");
        let repo = root.join("octocat").join("Hello-World");
        let skipped = root.join("sites").join("docs").join(".thesa");
        fs::create_dir_all(repo.join(".git")).expect("create fake git worktree marker");
        fs::create_dir_all(skipped).expect("create skipped metadata dir");

        let archives = discover_git_archives(&root, None).expect("discover archives");

        assert_eq!(archives.len(), 1);
        assert_eq!(archives[0].path, repo);
        assert_eq!(archives[0].slug, "octocat/Hello-World");

        let filtered = discover_git_archives(&root, Some("hello")).expect("filter archives");
        assert_eq!(filtered.len(), 1);
        let none = discover_git_archives(&root, Some("nomatch")).expect("filter archives");
        assert!(none.is_empty());

        fs::remove_dir_all(root).expect("cleanup temp dir");
    }

    #[test]
    fn discovers_git_archives_for_owner_from_archive_tree() {
        let root = test_temp_dir("discover_owner_git_archives");
        let octo_repo = root.join("octocat").join("Hello-World");
        let rust_repo = root.join("rust-lang").join("rust");
        fs::create_dir_all(octo_repo.join(".git")).expect("create octocat worktree marker");
        fs::create_dir_all(rust_repo.join(".git")).expect("create rust worktree marker");

        let archives = discover_git_archives_for_owner(&root, "OCTOCAT", None)
            .expect("discover owner archives");

        assert_eq!(archives.len(), 1);
        assert_eq!(archives[0].slug, "octocat/Hello-World");
        assert_eq!(archives[0].slug_parts(), Some(("octocat", "Hello-World")));
        assert_eq!(archives[0].owner(), Some("octocat"));
        assert_eq!(archives[0].repo_name(), Some("Hello-World"));
        assert!(archives[0].matches_owner("octocat"));

        let filtered = discover_git_archives_for_owner(&root, "octocat", Some("hello"))
            .expect("filter owner archives");
        assert_eq!(filtered.len(), 1);
        let none = discover_git_archives_for_owner(&root, "octocat", Some("rust"))
            .expect("filter owner archives");
        assert!(none.is_empty());

        fs::remove_dir_all(root).expect("cleanup temp dir");
    }

    fn test_temp_dir(name: &str) -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system clock")
            .as_nanos();
        std::env::temp_dir().join(format!("repoforge-{name}-{nonce}"))
    }
}
