//! Git repository helpers used across route handlers.
//!
//! Provides convenience functions for common `gix` operations such as opening a
//! repository, formatting commit dates, validating paths, and iterating
//! references.  The [`GitResultExt`] extension trait lets handlers convert gix
//! errors into [`Error::GitCorrupt`] with a single `.corrupt()` call.

use std::path::Path;

use gix::bstr::ByteSlice;
use jiff::Timestamp;
use jiff::tz::{Offset, TimeZone};

use crate::error::{Error, Result as CrateResult};

/// Information about a tag for list views.
pub struct TagInfo {
    pub name: String,
    pub date: String,
    pub timestamp: i64,
}

/// Information about a branch for list views.
pub struct BranchInfo {
    pub name: String,
    pub date: String,
    pub timestamp: i64,
    pub is_default: bool,
}

/// Summary of a commit for log views.
pub struct CommitSummary {
    pub sha: String,
    pub message: String,
    pub author: String,
    pub date: String,
}

/// Annotation data for an annotated tag.
pub struct TagAnnotation {
    pub tagger: Option<String>,
    pub date: Option<String>,
    pub message: Option<String>,
}

/// Basic commit identity info (sha + author + date).
pub struct CommitInfo {
    pub sha: String,
    pub author: String,
    pub date: String,
}

/// Paginated commit log for a branch.
pub struct BranchLog {
    pub tip_sha: String,
    pub commits: Vec<CommitSummary>,
    pub next_cursor: Option<String>,
}

/// A single entry in blame output.
pub struct BlameEntry {
    pub sha: String,
    pub short_sha: String,
    pub color: String,
    pub num: usize,
    pub content: String,
}

/// A single commit entry for the full ancestry graph view.
pub struct CommitEntry {
    pub sha: String,
    pub message: String,
    pub date: String,
    pub parents: Vec<String>,
}

/// Opens a repository at `root/{name}` or returns [`Error::RepoNotFound`].
pub fn open_repo(root: &Path, name: &str) -> CrateResult<gix::Repository> {
    gix::open(root.join(name)).map_err(|_| Error::RepoNotFound(name.to_owned()))
}

/// Rejects paths containing non-normal [`Component`]s (e.g. `..`) to prevent
/// directory traversal.
pub fn validate_path(path: &str) -> CrateResult<()> {
    if std::path::Path::new(path)
        .components()
        .any(|c| !matches!(c, std::path::Component::Normal(_)))
    {
        return Err(Error::BadRequest("invalid path".to_string()));
    }
    Ok(())
}

/// Returns the commit's author timestamp as Unix seconds.
pub fn commit_timestamp(commit: &gix::Commit) -> Option<i64> {
    commit.time().ok().map(|t| t.seconds)
}

/// Formats a commit's author date as a `YYYY-MM-DD` string in the commit's
/// local timezone, returning `None` if the time cannot be parsed.
pub fn commit_date(commit: &gix::Commit) -> Option<String> {
    let time = commit.time().ok()?;
    format_time(time.seconds, time.offset)
}

/// Formats a commit's committer date as a `YYYY-MM-DD` string in the
/// commit's local timezone, returning `None` if the time cannot be parsed.
pub fn committer_date(commit: &gix::Commit) -> Option<String> {
    let sig = commit.committer().ok()?;
    let time = sig.time().ok()?;
    format_time(time.seconds, time.offset)
}

/// Converts a Unix timestamp + timezone offset into a `YYYY-MM-DD` string.
fn format_time(seconds: i64, offset: i32) -> Option<String> {
    let zone = TimeZone::fixed(Offset::from_seconds(offset).ok()?);
    Timestamp::from_second(seconds)
        .map(|ts| ts.to_zoned(zone).date().to_string())
        .ok()
}

/// Extension trait adding `.corrupt()` to `Result<T, E>` where `E: Display`.
///
/// Converts an error into [`Error::GitCorrupt`] by calling `.to_string()` on
/// the inner error value.  This removes the need for repetitive
/// `.map_err(|e| Error::GitCorrupt(e.to_string()))` chains.
///
/// # Usage
///
/// ```ignore
/// use crate::git::GitResultExt as _;
/// let repo = git_repo.references().corrupt()?;
/// ```
pub trait GitResultExt<T> {
    fn corrupt(self) -> CrateResult<T>;
}

impl<T, E: std::fmt::Display> GitResultExt<T> for std::result::Result<T, E> {
    fn corrupt(self) -> CrateResult<T> {
        self.map_err(|e| Error::GitCorrupt(e.to_string()))
    }
}

/// Silently flattens a `Result<impl IntoIterator<Item = Result<T, E2>>, E1>` into
/// an `Iterator<Item = T>`.
///
/// Both the outer and inner `Result` layers are consumed via `.into_iter()` and
/// `.flatten()`, so errors at either level are silently skipped.  This matches
/// the common git-ref iteration pattern where a single corrupt ref should not
/// crash the page.
pub fn flatten_refs<T, E1, E2, I>(result: std::result::Result<I, E1>) -> impl Iterator<Item = T>
where
    I: IntoIterator<Item = std::result::Result<T, E2>>,
{
    result.into_iter().flatten().flatten()
}

/// Returns the total number of commits reachable from any ref in the
/// repository.
///
/// Collects all ref tips (branches, tags, remote-tracking refs, and detached
/// HEAD), then walks the complete reachable commit graph using gix's
/// commit-graph-aware traversal.  This is consistent regardless of
/// commit-graph availability.
pub fn commit_count(repo: &gix::Repository) -> usize {
    let refs = match repo.references() {
        Ok(r) => r,
        Err(_) => return 0,
    };

    let mut tips = Vec::new();

    for mut reference in flatten_refs(refs.all()) {
        if let Ok(commit) = reference.peel_to_commit() {
            tips.push(commit.id().detach());
        }
    }

    // Include HEAD in case it is detached (not covered by refs.all()).
    if let Ok(head_id) = repo.head_id()
        && let Ok(obj) = head_id.object()
        && let Ok(commit) = obj.try_into_commit()
    {
        tips.push(commit.id().detach());
    }

    tips.sort();
    tips.dedup();

    if tips.is_empty() {
        return 0;
    }

    repo.rev_walk(tips)
        .all()
        .map(|walk| walk.filter_map(Result::ok).count())
        .unwrap_or(0)
}

/// Loads all tags from a repository, sorted newest-first.
/// Errors during iteration are silently skipped.
pub fn load_tags(repo: &gix::Repository) -> Vec<TagInfo> {
    let refs = match repo.references() {
        Ok(r) => r,
        Err(_) => return Vec::new(),
    };
    let mut tags: Vec<TagInfo> = flatten_refs(refs.tags())
        .filter_map(|mut tag| {
            let commit = tag.peel_to_commit().ok()?;
            let date = commit_date(&commit)?;
            let timestamp = commit_timestamp(&commit)?;
            let name = tag.name().shorten().to_string();
            Some(TagInfo { name, date, timestamp })
        })
        .collect();
    tags.sort_by_key(|t| std::cmp::Reverse(t.timestamp));
    tags
}

/// Loads a single tag's annotation and its target commit.
pub fn load_tag(repo: &gix::Repository, tag_name: &str) -> CrateResult<(TagAnnotation, CommitInfo)> {
    let ref_name = format!("refs/tags/{tag_name}");
    let mut reference = repo
        .find_reference(&ref_name)
        .map_err(|_| Error::NotFound(format!("tag {tag_name}")))?;

    let (tagger, tag_date, message) = reference
        .peel_to_tag()
        .ok()
        .and_then(|t| {
            let decoded = t.decode().ok()?;
            let sig = decoded.tagger().ok().flatten();
            let tagger = sig.as_ref().map(|s| s.name.to_str_lossy().into_owned());
            let tag_date = sig.as_ref().and_then(|s| {
                let time = s.time().ok()?;
                format_time(time.seconds, time.offset)
            });
            let msg = decoded.message.to_str_lossy().trim().to_string();
            let message = if msg.is_empty() { None } else { Some(msg) };
            Some((tagger, tag_date, message))
        })
        .unwrap_or((None, None, None));

    let commit = reference.peel_to_commit().corrupt()?;
    let sha = commit.id().to_string();
    let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
    let date = commit_date(&commit).unwrap_or_default();

    Ok((TagAnnotation { tagger, date: tag_date, message }, CommitInfo { sha, author, date }))
}

/// Loads all local branches from a repository, sorted with the default branch
/// first, then by most recent commit. Errors during iteration are silently skipped.
pub fn load_branches(repo: &gix::Repository) -> Vec<BranchInfo> {
    let refs = match repo.references() {
        Ok(r) => r,
        Err(_) => return Vec::new(),
    };
    let head_name = repo.head_name().ok().flatten();

    let mut branches: Vec<BranchInfo> = flatten_refs(refs.local_branches())
        .filter_map(|mut branch| {
            let commit = branch.peel_to_commit().ok()?;
            let date = commit_date(&commit)?;
            let timestamp = commit_timestamp(&commit)?;
            let name = branch.name().shorten().to_string();
            let is_default = head_name.as_ref().is_some_and(|h| h.as_ref() == branch.name());
            Some(BranchInfo { name, date, timestamp, is_default })
        })
        .collect();

    branches.sort_by(|a, b| {
        b.is_default.cmp(&a.is_default).then(b.timestamp.cmp(&a.timestamp))
    });
    branches
}

/// Loads a paginated log of commits reachable from the tip of a branch.
pub fn load_branch_log(
    repo: &gix::Repository,
    branch_name: &str,
    after: Option<String>,
    page_size: usize,
) -> CrateResult<BranchLog> {
    let ref_name = format!("refs/heads/{branch_name}");
    let tip = repo
        .find_reference(&ref_name)
        .map_err(|_| Error::NotFound(format!("branch {branch_name}")))?
        .peel_to_commit()
        .corrupt()?;

    let tip_sha = tip.id().to_string();

    let start_id = match after {
        Some(ref sha) => {
            let oid = gix::ObjectId::from_hex(sha.as_bytes())
                .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
            repo.find_object(oid)
                .map_err(|_| Error::NotFound(format!("commit {sha}")))?
                .id()
        }
        None => tip.id(),
    };

    let skip_count = if after.is_some() { 1 } else { 0 };

    let mut commits: Vec<CommitSummary> = start_id
        .ancestors()
        .all()
        .corrupt()?
        .skip(skip_count)
        .filter_map(|info| {
            let info = info.ok()?;
            let commit = info.id().object().ok()?.try_into_commit().ok()?;
            Some(CommitSummary {
                sha: info.id().to_string(),
                message: commit.message().ok()?.summary().to_string(),
                author: commit.author().ok()?.name.to_string(),
                date: commit_date(&commit)?,
            })
        })
        .take(page_size + 1)
        .collect();

    let has_next = commits.len() > page_size;
    if has_next {
        commits.pop();
    }

    let next_cursor = if has_next { commits.last().map(|c| c.sha.clone()) } else { None };

    Ok(BranchLog { tip_sha, commits, next_cursor })
}

/// Color palette for commit highlighting (matches commit.rs graph colors).
const COLORS: &[&str] = &[
    "var(--blue)", "var(--red)", "var(--green)", "var(--yellow)",
    "var(--violet)", "var(--magenta)", "var(--cyan)", "var(--orange)",
];

#[allow(clippy::cast_possible_truncation)]
fn commit_color(sha: &str) -> &'static str {
    let hash: u64 = sha.bytes().fold(0u64, |acc, b| {
        acc.wrapping_mul(31).wrapping_add(u64::from(b))
    });
    COLORS[hash as usize % COLORS.len()]
}

/// Loads blame annotations for a file at `{sha}:{path}`.
pub fn load_blame(repo: &gix::Repository, sha: &str, path: &str) -> CrateResult<Vec<BlameEntry>> {
    let oid = gix::ObjectId::from_hex(sha.as_bytes())
        .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;

    let _commit = repo
        .find_object(oid)
        .map_err(|_| Error::NotFound(sha.to_string()))?
        .try_into_commit()
        .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;

    let file_path = gix::bstr::BStr::new(path.as_bytes());
    let opts = gix::repository::blame_file::Options::default();
    let outcome = repo.blame_file(file_path, oid, opts).corrupt()?;

    let content = String::from_utf8_lossy(&outcome.blob).into_owned();
    let file_lines: Vec<&str> = content.split('\n').collect();

    let mut entries = Vec::new();
    for entry in &outcome.entries {
        let sha = entry.commit_id.to_string();
        let color = commit_color(&sha);
        let short_sha = sha[..8].to_string();
        for i in 0..entry.len.get() {
            let line_idx = (entry.start_in_blamed_file + i) as usize;
            let content = file_lines.get(line_idx).unwrap_or(&"").to_string();
            entries.push(BlameEntry {
                sha: sha.clone(),
                short_sha: short_sha.clone(),
                color: color.to_string(),
                num: line_idx + 1,
                content,
            });
        }
    }

    Ok(entries)
}

/// Loads up to `limit` commits starting from `after` (inclusive) or from
/// HEAD when `after` is `None`. Returns an empty vec if the repo has no commits.
pub fn load_commits(
    repo: &gix::Repository,
    after: Option<&str>,
    limit: usize,
) -> CrateResult<Vec<CommitEntry>> {
    let start_id = match after {
        Some(sha) => {
            let oid = gix::ObjectId::from_hex(sha.as_bytes())
                .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
            let obj = repo
                .find_object(oid)
                .map_err(|_| Error::NotFound(format!("commit {sha}")))?;
            obj.id()
        }
        None => match repo.head_commit() {
            Ok(head) => head.id(),
            Err(_) => return Ok(Vec::new()),
        },
    };

    let entries = start_id
        .ancestors()
        .all()
        .corrupt()?
        .filter_map(|info| {
            let info = info.ok()?;
            let commit = info.id().object().ok()?.try_into_commit().ok()?;
            Some(CommitEntry {
                sha: info.id().to_string(),
                message: commit.message().ok()?.summary().to_string(),
                date: commit_date(&commit).unwrap_or_default(),
                parents: commit.parent_ids().map(|id| id.to_string()).collect(),
            })
        })
        .take(limit)
        .collect();

    Ok(entries)
}

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

    #[test]
    fn validate_path_normal() {
        assert!(validate_path("foo").is_ok());
    }

    #[test]
    fn validate_path_nested() {
        assert!(validate_path("foo/bar").is_ok());
    }

    #[test]
    fn validate_path_empty() {
        assert!(validate_path("").is_ok());
    }

    #[test]
    fn validate_path_dotdot() {
        assert!(validate_path("..").is_err());
    }

    #[test]
    fn validate_path_dotdot_prefix() {
        assert!(validate_path("../foo").is_err());
    }

    #[test]
    fn validate_path_nested_dotdot() {
        assert!(validate_path("foo/../../bar").is_err());
    }

    #[test]
    fn validate_path_absolute() {
        assert!(validate_path("/absolute").is_err());
    }

    #[test]
    fn corrupt_ok() {
        let result: std::result::Result<i32, &str> = Ok(42);
        assert_eq!(result.corrupt().unwrap(), 42);
    }

    #[test]
    fn corrupt_err() {
        let result: std::result::Result<i32, &str> = Err("boom");
        let err = result.corrupt().unwrap_err();
        assert!(matches!(err, crate::error::Error::GitCorrupt(_)));
    }

    #[test]
    fn flatten_refs_skips_outer_err() {
        let result: std::result::Result<Vec<std::result::Result<i32, ()>>, ()> = Err(());
        let collected: Vec<i32> = flatten_refs(result).collect();
        assert!(collected.is_empty());
    }

    #[test]
    fn flatten_refs_skips_inner_err() {
        let result: std::result::Result<Vec<std::result::Result<i32, &str>>, ()> =
            Ok(vec![Ok(1), Err("skip"), Ok(3)]);
        let collected: Vec<i32> = flatten_refs(result).collect();
        assert_eq!(collected, vec![1, 3]);
    }

    #[test]
    fn commit_color_deterministic() {
        let c1 = commit_color("abc123");
        let c2 = commit_color("abc123");
        assert_eq!(c1, c2);
    }

    #[test]
    fn commit_color_in_palette() {
        let c = commit_color("xyz789");
        assert!(COLORS.contains(&c), "color {c} not in palette");
    }
}
