//! [`Git2Repository`] — local Git adapter implementing the [`GitPort`] port via
//! libgit2 (`git2`), ARCHITECTURE §5, L8.
//!
//! Scope is **local** operations (status, stage/unstage, commit, branches,
//! checkout, log, init). Network operations (`pull`/`push`) need remote +
//! credential handling and are deferred to L9 (`RemoteGitRepository` over SSH/WSL
//! and credential callbacks); here they return a clear [`GitError::Operation`].
//!
//! # Async & `Send`
//!
//! [`GitPort`] is `#[async_trait]`, but libgit2 is synchronous and its handles
//! ([`git2::Repository`]) are `!Send`. Each method opens the repository, does all
//! its work, and drops it **within a single poll** — there is no `.await` while a
//! repo/handle is alive — so the returned futures are `Send` and the adapter is
//! safe behind `Arc<dyn GitPort>` on the multi-threaded runtime.

use async_trait::async_trait;
use git2::{BranchType, ErrorCode, Repository, Status, StatusOptions};

use std::collections::HashMap;

use domain::ports::{GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit};
use domain::project::ProjectPath;

/// Local Git adapter backed by libgit2.
#[derive(Clone, Default)]
pub struct Git2Repository;

impl Git2Repository {
    /// Builds the adapter (stateless; a repository is opened per call from the
    /// project root, so one instance serves every project).
    #[must_use]
    pub fn new() -> Self {
        Self
    }
}

/// Maps a libgit2 error to a domain [`GitError`] (its message, not the raw type).
fn op(e: git2::Error) -> GitError {
    GitError::Operation(e.message().to_owned())
}

/// Opens the repository at `root`, distinguishing "not a repo" from other errors.
fn open(root: &ProjectPath) -> Result<Repository, GitError> {
    Repository::open(root.as_str()).map_err(|e| {
        if e.code() == ErrorCode::NotFound {
            GitError::NotFound
        } else {
            op(e)
        }
    })
}

/// Index-side status flags (a path with any of these has staged changes).
const STAGED: Status = Status::INDEX_NEW
    .union(Status::INDEX_MODIFIED)
    .union(Status::INDEX_DELETED)
    .union(Status::INDEX_RENAMED)
    .union(Status::INDEX_TYPECHANGE);

#[async_trait]
impl GitPort for Git2Repository {
    async fn init(&self, root: &ProjectPath) -> Result<(), GitError> {
        Repository::init(root.as_str()).map_err(op)?;
        Ok(())
    }

    async fn status(&self, root: &ProjectPath) -> Result<Vec<GitFileStatus>, GitError> {
        let repo = open(root)?;
        let mut opts = StatusOptions::new();
        opts.include_untracked(true).recurse_untracked_dirs(true);
        let statuses = repo.statuses(Some(&mut opts)).map_err(op)?;
        let mut out = Vec::new();
        for entry in statuses.iter() {
            let s = entry.status();
            if s.is_ignored() {
                continue;
            }
            if let Some(path) = entry.path() {
                out.push(GitFileStatus {
                    path: path.to_owned(),
                    staged: s.intersects(STAGED),
                });
            }
        }
        Ok(out)
    }

    async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> {
        let repo = open(root)?;
        let mut index = repo.index().map_err(op)?;
        index.add_path(std::path::Path::new(path)).map_err(op)?;
        index.write().map_err(op)?;
        Ok(())
    }

    async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError> {
        let repo = open(root)?;
        match repo.head() {
            // Reset the path in the index back to its HEAD state.
            Ok(head) => {
                let obj = head.peel(git2::ObjectType::Commit).map_err(op)?;
                repo.reset_default(Some(&obj), [path]).map_err(op)?;
            }
            // Unborn HEAD (no commit yet): "unstage" means drop it from the index.
            Err(_) => {
                let mut index = repo.index().map_err(op)?;
                index.remove_path(std::path::Path::new(path)).map_err(op)?;
                index.write().map_err(op)?;
            }
        }
        Ok(())
    }

    async fn commit(&self, root: &ProjectPath, message: &str) -> Result<GitCommitInfo, GitError> {
        let repo = open(root)?;
        let mut index = repo.index().map_err(op)?;
        let tree_oid = index.write_tree().map_err(op)?;
        let tree = repo.find_tree(tree_oid).map_err(op)?;

        // Prefer the configured identity; fall back to a stable local one so a
        // fresh repo with no user.name/email can still commit.
        let sig = repo
            .signature()
            .or_else(|_| git2::Signature::now("IdeA", "idea@localhost"))
            .map_err(op)?;

        let parent = match repo.head() {
            Ok(head) => Some(head.peel_to_commit().map_err(op)?),
            Err(_) => None,
        };
        let parents: Vec<&git2::Commit> = parent.iter().collect();

        let oid = repo
            .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
            .map_err(op)?;

        Ok(GitCommitInfo {
            hash: oid.to_string(),
            summary: message.lines().next().unwrap_or("").to_owned(),
        })
    }

    async fn branches(&self, root: &ProjectPath) -> Result<Vec<String>, GitError> {
        let repo = open(root)?;
        let mut names = Vec::new();
        for branch in repo.branches(Some(BranchType::Local)).map_err(op)? {
            let (branch, _) = branch.map_err(op)?;
            if let Some(name) = branch.name().map_err(op)? {
                names.push(name.to_owned());
            }
        }
        Ok(names)
    }

    async fn current_branch(&self, root: &ProjectPath) -> Result<Option<String>, GitError> {
        let repo = open(root)?;
        let head = match repo.head() {
            Ok(head) => head,
            // Unborn branch (no commits yet): no current branch to report.
            Err(e) if e.code() == ErrorCode::UnbornBranch => return Ok(None),
            Err(e) => return Err(op(e)),
        };
        // A detached HEAD (or a non-branch ref) has no branch name.
        Ok(if head.is_branch() {
            head.shorthand().map(ToOwned::to_owned)
        } else {
            None
        })
    }

    async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError> {
        let repo = open(root)?;
        let obj = repo.revparse_single(branch).map_err(op)?;
        repo.checkout_tree(&obj, None).map_err(op)?;
        repo.set_head(&format!("refs/heads/{branch}")).map_err(op)?;
        Ok(())
    }

    async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError> {
        let repo = open(root)?;
        let mut revwalk = repo.revwalk().map_err(op)?;
        // No commits yet ⇒ nothing to walk.
        if revwalk.push_head().is_err() {
            return Ok(Vec::new());
        }
        let mut out = Vec::new();
        for oid in revwalk.take(limit) {
            let oid = oid.map_err(op)?;
            let commit = repo.find_commit(oid).map_err(op)?;
            out.push(GitCommitInfo {
                hash: oid.to_string(),
                summary: commit.summary().unwrap_or("").to_owned(),
            });
        }
        Ok(out)
    }

    async fn log_graph(
        &self,
        root: &ProjectPath,
        limit: usize,
    ) -> Result<Vec<GraphCommit>, GitError> {
        let repo = open(root)?;

        // Build a map from OID → ref short-names for label attachment.
        let mut ref_map: HashMap<git2::Oid, Vec<String>> = HashMap::new();
        if let Ok(references) = repo.references() {
            for reference in references.flatten() {
                // Only handle symbolic and direct refs; skip errors.
                let target_oid = reference.resolve().ok().and_then(|r| r.target());
                if let Some(oid) = target_oid {
                    let label = match reference.shorthand() {
                        Some(name) => {
                            // Distinguish tags from branches by prefix.
                            if reference.is_tag() {
                                format!("tag: {name}")
                            } else {
                                name.to_owned()
                            }
                        }
                        None => continue,
                    };
                    ref_map.entry(oid).or_default().push(label);
                }
            }
        }

        let mut revwalk = repo.revwalk().map_err(op)?;
        revwalk
            .set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
            .map_err(op)?;

        // Push all local branches; if the repo is empty this produces no OIDs.
        let _ = revwalk.push_glob("refs/heads/*");

        let mut out = Vec::new();
        for oid_result in revwalk.take(limit) {
            let oid = oid_result.map_err(op)?;
            let commit = repo.find_commit(oid).map_err(op)?;
            let parents = commit
                .parent_ids()
                .map(|p| p.to_string())
                .collect::<Vec<_>>();
            let author = commit.author().name().unwrap_or("").to_owned();
            let timestamp = commit.time().seconds();
            let refs = ref_map.get(&oid).cloned().unwrap_or_default();
            out.push(GraphCommit {
                hash: oid.to_string(),
                summary: commit.summary().unwrap_or("").to_owned(),
                parents,
                refs,
                author,
                timestamp,
            });
        }
        Ok(out)
    }

    async fn pull(&self, _root: &ProjectPath) -> Result<(), GitError> {
        Err(GitError::Operation(
            "pull requires remote/credential configuration (L9)".to_owned(),
        ))
    }

    async fn push(&self, _root: &ProjectPath) -> Result<(), GitError> {
        Err(GitError::Operation(
            "push requires remote/credential configuration (L9)".to_owned(),
        ))
    }
}
