"""Git adapter for metabranch operations."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import re

import git

from ..exceptions import InvalidGitRepositoryError


@dataclass(frozen=True)
class ReconciliationCommit:
    """Git commit details used by metabranch reconciliation flows."""

    sha: str
    subject: str
    message: str
    parent_shas: tuple[str, ...] = ()


class GitClient:
    """Thin adapter around the current repository's Git operations."""

    def __init__(self, repo: git.Repo | None = None) -> None:
        self._repo = repo

    @property
    def repo(self) -> git.Repo:
        """Return the current repository."""
        if self._repo is None:
            try:
                self._repo = git.Repo(search_parent_directories=True)
            except git.InvalidGitRepositoryError as exc:
                raise InvalidGitRepositoryError("Not in a Git repository") from exc
        return self._repo

    @property
    def git(self) -> git.Git:
        """Return the Git command facade."""
        return self.repo.git

    @property
    def working_dir(self) -> Path:
        """Return the repository working directory."""
        return Path(self.repo.working_tree_dir)

    @property
    def git_dir(self) -> Path:
        """Return the repository Git directory."""
        return Path(self.repo.git_dir)

    def current_branch_name(self) -> str:
        """Return the active branch name, or HEAD when detached."""
        try:
            return self.repo.active_branch.name
        except TypeError:
            return "HEAD"

    def branch_exists(self, name: str) -> bool:
        """Return whether a local branch exists."""
        return any(branch.name == name for branch in self.repo.heads)

    def list_branches(self) -> list[str]:
        """Return all local branch names."""
        return [branch.name for branch in self.repo.heads]

    def branch_head(self, name: str) -> str:
        """Return the commit hash at a branch head."""
        return self.repo.heads[name].commit.hexsha

    def tracking_branch_name(self, name: str) -> str | None:
        """Return the tracking ref for a local branch, if configured."""
        if not self.branch_exists(name):
            return None
        tracking_branch = self.repo.heads[name].tracking_branch()
        if tracking_branch is None:
            return None
        return str(tracking_branch)

    def ahead_behind(
        self, name: str, tracking_ref: str | None = None
    ) -> tuple[int, int] | None:
        """Return commits ahead/behind for a branch relative to a tracking ref."""
        ref = tracking_ref or self.tracking_branch_name(name)
        if ref is None:
            return None
        try:
            output = self.git.rev_list("--left-right", "--count", f"{name}...{ref}").strip()
        except git.GitCommandError:
            return None
        if not output:
            return (0, 0)
        ahead_text, behind_text = output.split()
        return (int(ahead_text), int(behind_text))

    def is_dirty(self, ignore_paths: tuple[str, ...] = ()) -> bool:
        """Return whether the worktree or index is dirty."""
        ignored = set(ignore_paths)
        if not ignored:
            return self.repo.is_dirty(untracked_files=True)

        changed_paths = {
            diff.a_path or diff.b_path for diff in self.repo.index.diff(None)
        }
        changed_paths.update(
            diff.a_path or diff.b_path for diff in self.repo.index.diff("HEAD")
        )
        changed_paths.update(self.repo.untracked_files)
        return any(path not in ignored for path in changed_paths)

    def checkout(self, name: str, create: bool = False) -> None:
        """Checkout a branch, optionally creating it first."""
        if create:
            self.git.checkout("-b", name)
            return
        self.git.checkout(name)

    def push(self, name: str, force: bool = False) -> None:
        """Push a branch."""
        branch = self.repo.heads[name]
        tracking_branch = branch.tracking_branch()
        if tracking_branch is None:
            self.git.push("--set-upstream", "origin", name, force=force)
            return
        self.git.push(
            tracking_branch.remote_name,
            f"{name}:{tracking_branch.remote_head}",
            force=force,
        )

    def pull(self, name: str) -> None:
        """Pull a branch from its configured remote."""
        self.checkout(name)
        self.git.pull()

    def rebase(self, base: str, interactive: bool = False) -> None:
        """Rebase the current branch."""
        self.git.rebase(base, interactive=interactive)

    def reset_hard(self, target: str) -> None:
        """Hard reset the current branch to the target."""
        self.git.reset(target, "--hard")

    def merge_ff_only(self, branch_name: str) -> None:
        """Fast-forward merge a branch into the current branch."""
        self.git.merge("--ff-only", branch_name)

    def merge(self, branch_name: str) -> None:
        """Merge a branch into the current branch without opening an editor."""
        self.git.merge("--no-edit", branch_name)

    def is_ancestor(self, commitish: str, ref: str) -> bool:
        """Return whether the first commit is an ancestor of the second ref."""
        try:
            self.git.merge_base("--is-ancestor", commitish, ref)
        except git.GitCommandError:
            return False
        return True

    def ref_commit_timestamp(
        self,
        ref: str,
        *,
        path: str | None = None,
        first_addition: bool = False,
    ) -> str | None:
        """Return the commit timestamp for a ref or a file within that ref."""
        args: list[str] = [ref]
        if first_addition:
            args.extend(["--diff-filter=A", "--format=%cI", "--reverse"])
        else:
            args.extend(["-1", "--format=%cI"])
        if path is not None:
            args.extend(["--", path])
        try:
            output = self.git.log(*args).strip()
        except git.GitCommandError:
            return None
        if not output:
            return None
        return output.splitlines()[0]

    def commit_message(self, commit_sha: str) -> str:
        """Return the full commit message for a commit."""
        return self.git.show("-s", "--format=%B", commit_sha).rstrip()

    def commit_subject(self, commit_sha: str) -> str:
        """Return the subject line for a commit."""
        return self.git.show("-s", "--format=%s", commit_sha).strip()

    def list_unique_commits(
        self, branch_name: str, exclude_refs: tuple[str, ...]
    ) -> list[ReconciliationCommit]:
        """Return commits reachable only from the given branch, oldest first."""
        output = self.git.rev_list(
            "--reverse",
            branch_name,
            *[f"^{ref}" for ref in exclude_refs],
        ).strip()
        if not output:
            return []
        commits: list[ReconciliationCommit] = []
        for commit_sha in output.splitlines():
            commit = self.repo.commit(commit_sha)
            commits.append(
                ReconciliationCommit(
                    sha=commit_sha,
                    subject=self.commit_subject(commit_sha),
                    message=self.commit_message(commit_sha),
                    parent_shas=tuple(parent.hexsha for parent in commit.parents),
                )
            )
        return commits

    def recent_commit_subjects(
        self, branch_name: str, max_count: int = 50
    ) -> tuple[str, ...]:
        """Return recent commit subjects for a branch."""
        output = self.git.log(
            branch_name,
            f"--max-count={max_count}",
            "--format=%s",
        ).strip()
        if not output:
            return ()
        return tuple(output.splitlines())

    def replayed_commit_origins(self, ref: str) -> set[str]:
        """Return original commit SHAs recorded by `git cherry-pick -x` on a ref."""
        if not self.branch_exists(ref) and ref != "HEAD":
            return set()

        origins: set[str] = set()
        pattern = re.compile(r"\(cherry picked from commit ([0-9a-f]{7,40})\)")
        try:
            commits = list(self.repo.iter_commits(ref))
        except git.GitCommandError:
            return set()

        for commit in commits:
            for match in pattern.findall(commit.message):
                origins.add(match)
        return origins

    def cherry_pick_no_commit(self, commit_sha: str) -> None:
        """Apply a commit without creating a commit yet."""
        self.git.cherry_pick("--no-commit", commit_sha)

    def cherry_pick(self, commit_sha: str, *, record_origin: bool = False) -> None:
        """Apply a commit and create a new commit on the current branch."""
        args: list[str] = []
        if record_origin:
            args.append("-x")
        args.append(commit_sha)
        self.git.cherry_pick(*args)

    def in_progress_operation(self) -> str | None:
        """Return the active Git operation type, if any."""
        if (self.git_dir / "CHERRY_PICK_HEAD").exists():
            return "cherry-pick"
        if (self.git_dir / "rebase-merge").exists() or (
            self.git_dir / "rebase-apply"
        ).exists():
            return "rebase"
        if (self.git_dir / "MERGE_HEAD").exists():
            return "merge"
        return None

    def continue_in_progress_operation(self) -> str | None:
        """Continue the active Git operation, if any."""
        operation = self.in_progress_operation()
        if operation == "cherry-pick":
            self.git.cherry_pick("--continue")
        elif operation == "rebase":
            self.git.rebase("--continue")
        elif operation == "merge":
            self.git.merge("--continue")
        return operation

    def skip_in_progress_operation(self) -> str | None:
        """Skip the active Git operation, if the current step should be discarded."""
        operation = self.in_progress_operation()
        if operation == "cherry-pick":
            self.git.cherry_pick("--skip")
        elif operation == "rebase":
            self.git.rebase("--skip")
        return operation

    def abort_in_progress_operation(self) -> None:
        """Clear any in-progress Git operation before restoring checkpoints."""
        for command in (
            lambda: self.git.cherry_pick("--abort"),
            lambda: self.git.rebase("--abort"),
            lambda: self.git.merge("--abort"),
        ):
            try:
                command()
            except git.GitCommandError:
                pass
        try:
            self.git.reset("--hard", "HEAD")
        except git.GitCommandError:
            pass

    def has_pending_changes(self) -> bool:
        """Return whether the worktree or index has pending tracked changes."""
        return self.repo.is_dirty(untracked_files=False)

    def has_unmerged_paths(self) -> bool:
        """Return whether the index currently contains unresolved merge entries."""
        return bool(self.git.diff("--name-only", "--diff-filter=U").strip())

    def path_has_changes(self, path: str) -> bool:
        """Return whether a specific path has staged, unstaged, or untracked changes."""
        return bool(self.git.status("--porcelain", "--", path).strip())

    def commit_only_path(
        self,
        path: str,
        *,
        subject: str,
        body: str | None = None,
    ) -> bool:
        """Commit only one path, ignoring other staged files."""
        if not self.path_has_changes(path):
            return False
        self.git.add("--", path)
        args: list[str] = ["--only", "-m", subject]
        if body is not None:
            args.extend(["-m", body])
        args.extend(["--", path])
        self.git.commit(*args)
        return True

    def commit_paths(self, commit_sha: str) -> tuple[str, ...]:
        """Return the file paths touched by a commit."""
        output = self.git.diff_tree(
            "--no-commit-id",
            "--name-only",
            "-r",
            commit_sha,
        ).strip()
        if not output:
            return ()
        return tuple(path for path in output.splitlines() if path)

    def commit(self, message: str) -> None:
        """Create a commit with the given message."""
        self.git.commit("-m", message)

    def commit_with_args(self, *args: str) -> None:
        """Run `git commit` with the provided raw argument list."""
        self.git.commit(*args)

    def status(self, branch_name: str | None = None) -> str:
        """Return Git status output."""
        if branch_name is None or branch_name == self.current_branch_name():
            return self.git.status()
        if self.branch_exists(branch_name):
            return f"{branch_name}: available"
        return f"{branch_name}: missing"

    def read_text_from_branch(self, branch_name: str, path: str) -> str | None:
        """Read a file from a branch, returning None when it does not exist."""
        try:
            return self.git.show(f"{branch_name}:{path}")
        except git.GitCommandError:
            return None

    def discard_path_changes(self, path: str) -> None:
        """Discard staged and unstaged changes for one path."""
        if self.read_text_from_ref("HEAD", path) is None:
            try:
                self.git.reset("HEAD", "--", path)
            except git.GitCommandError:
                pass
            file_path = self.working_dir / path
            if file_path.exists():
                file_path.unlink()
            return
        self.git.restore("--source=HEAD", "--staged", "--worktree", "--", path)

    def read_text_from_ref(self, ref: str, path: str) -> str | None:
        """Read a file from an arbitrary ref, returning None when it does not exist."""
        try:
            return self.git.show(f"{ref}:{path}")
        except git.GitCommandError:
            return None


def get_repo() -> git.Repo:
    """Compatibility helper returning the current repository."""
    return GitClient().repo


def get_git() -> git.Git:
    """Compatibility helper returning the current Git facade."""
    return GitClient().git
