"""Git operation tools for the gjalla-precommit CLI.

Uses GitPython for all git operations with graceful error handling.
Adapted patterns from Aider's battle-tested repo.py implementation.
"""

from __future__ import annotations

import json
import re
from pathlib import Path, PurePosixPath
from typing import Any, TYPE_CHECKING

import pathspec
from git import InvalidGitRepositoryError, Repo
from git.exc import GitCommandError

if TYPE_CHECKING:
    from .executor import ToolResult


# Comprehensive git error handling tuple (from Aider)
# Catches all git-related exceptions plus common Python errors
# that can occur during git operations
try:
    import git

    ANY_GIT_ERROR: list[type[Exception]] = [
        git.exc.ODBError,
        git.exc.GitError,
        git.exc.InvalidGitRepositoryError,
        git.exc.GitCommandNotFound,
    ]
except ImportError:
    git = None  # type: ignore
    ANY_GIT_ERROR = []

ANY_GIT_ERROR += [
    OSError,
    IndexError,
    BufferError,
    TypeError,
    ValueError,
    AttributeError,
    AssertionError,
    TimeoutError,
]
ANY_GIT_ERROR_TUPLE: tuple[type[Exception], ...] = tuple(ANY_GIT_ERROR)


class GitToolError(Exception):
    """Custom exception for git tool errors."""

    pass


# Gjalla notes namespace for storing receipts
NOTES_REF = "refs/notes/gjalla"

# Module-level caching for tracked files by commit
# Maps commit_sha -> set of tracked file paths
_tree_files_cache: dict[str, set[str]] = {}

# Module-level caching for ignore patterns
# Maps (repo_root, ignore_file_mtime) -> PathSpec
_ignore_spec_cache: dict[tuple[str, float], pathspec.PathSpec | None] = {}


def _get_tool_result() -> type:
    """Import ToolResult lazily to avoid circular imports."""
    from .executor import ToolResult
    return ToolResult


def _get_repo(repo_root: Path) -> Repo:
    """Get git repository from path.

    Args:
        repo_root: Path to the repository root.

    Returns:
        Git Repo object.

    Raises:
        InvalidGitRepositoryError: If path is not a git repository.
    """
    return Repo(repo_root, search_parent_directories=True)


def normalize_path(path: str | Path) -> str:
    """Normalize path for cross-platform consistency.

    Converts paths to forward-slash POSIX format regardless of OS.
    This ensures consistent path handling across Windows/Unix.

    Args:
        path: Path string or Path object to normalize.

    Returns:
        Normalized path string with forward slashes.
    """
    return str(PurePosixPath(path))


def get_dirty_files(repo_root: Path) -> list[str]:
    """Get list of dirty (modified/staged) files in working tree.

    Returns both staged and unstaged changes.

    Args:
        repo_root: Path to the repository root.

    Returns:
        List of dirty file paths (normalized), or list with error on failure.
    """
    try:
        repo = _get_repo(repo_root)
        # Get both staged and unstaged changes
        output = repo.git.diff("--name-only", "HEAD")
        if output:
            return [normalize_path(f) for f in output.strip().split("\n") if f]
        return []
    except ANY_GIT_ERROR_TUPLE as e:
        return [f"Error: {e}"]


def _load_ignore_patterns(repo_root: Path) -> pathspec.PathSpec | None:
    """Load and cache ignore patterns from .gitignore and .gjallaignore.

    Uses file mtime for cache invalidation.

    Args:
        repo_root: Path to the repository root.

    Returns:
        PathSpec object for matching ignored files, or None if no ignore files.
    """
    ignore_files = [
        repo_root / ".gitignore",
        repo_root / ".gjallaignore",
    ]

    patterns: list[str] = []
    latest_mtime: float = 0.0

    for ignore_file in ignore_files:
        if ignore_file.exists():
            mtime = ignore_file.stat().st_mtime
            latest_mtime = max(latest_mtime, mtime)

            try:
                with open(ignore_file, encoding="utf-8") as f:
                    file_patterns = f.read().splitlines()
                    # Filter out empty lines and comments
                    patterns.extend(
                        p for p in file_patterns if p.strip() and not p.startswith("#")
                    )
            except OSError:
                continue

    if not patterns:
        return None

    # Check cache
    cache_key = (str(repo_root), latest_mtime)
    if cache_key in _ignore_spec_cache:
        return _ignore_spec_cache[cache_key]

    # Build and cache the PathSpec
    spec = pathspec.PathSpec.from_lines(
        pathspec.patterns.GitWildMatchPattern, patterns
    )
    _ignore_spec_cache[cache_key] = spec
    return spec


def is_ignored(repo_root: Path, file_path: str | Path) -> bool:
    """Check if a file should be ignored based on .gitignore and .gjallaignore.

    Args:
        repo_root: Path to the repository root.
        file_path: Path to the file to check (relative to repo root).

    Returns:
        True if the file should be ignored, False otherwise.
    """
    spec = _load_ignore_patterns(repo_root)
    if spec is None:
        return False

    normalized = normalize_path(file_path)
    return spec.match_file(normalized)


def get_non_ignored_files(repo_root: Path, files: list[str]) -> list[str]:
    """Filter out ignored files from a list.

    Args:
        repo_root: Path to the repository root.
        files: List of file paths to filter.

    Returns:
        List of files that are not ignored.
    """
    spec = _load_ignore_patterns(repo_root)
    if spec is None:
        return files

    return [f for f in files if not spec.match_file(normalize_path(f))]


def clear_caches() -> None:
    """Clear all module-level caches.

    Useful for testing or when repository state changes significantly.
    """
    global _tree_files_cache, _ignore_spec_cache
    _tree_files_cache.clear()
    _ignore_spec_cache.clear()


async def git_status(repo_root: Path) -> "ToolResult":
    """Get staged/unstaged files status.

    Args:
        repo_root: Path to the repository root.

    Returns:
        ToolResult with success=True and data=status string on success,
        or success=False and error message on failure.
    """
    ToolResult = _get_tool_result()

    try:
        repo = _get_repo(repo_root)
        status = repo.git.status()
        return ToolResult(success=True, data=status)
    except InvalidGitRepositoryError:
        return ToolResult(success=False, error="Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        return ToolResult(success=False, error=str(e))


async def git_log(repo_root: Path, n: int = 5) -> "ToolResult":
    """Get recent commits.

    Args:
        repo_root: Path to the repository root.
        n: Number of commits to retrieve (default: 5).

    Returns:
        ToolResult with success=True and data=log string on success,
        or success=False and error message on failure.
    """
    ToolResult = _get_tool_result()

    try:
        repo = _get_repo(repo_root)
        log = repo.git.log(f"-{n}", "--oneline")
        return ToolResult(success=True, data=log)
    except InvalidGitRepositoryError:
        return ToolResult(success=False, error="Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        return ToolResult(success=False, error=str(e))


def get_staged_files(repo_root: Path) -> list[str]:
    """List files in staging area.

    Args:
        repo_root: Path to the repository root.

    Returns:
        List of staged file paths (normalized), or list with error message on failure.
    """
    try:
        repo = _get_repo(repo_root)
        # Get staged files by comparing index to HEAD
        staged: list[str] = []
        try:
            diff = repo.index.diff("HEAD")
            staged = [normalize_path(item.a_path) for item in diff]
        except ANY_GIT_ERROR_TUPLE:
            # Handle case where there's no HEAD (initial commit)
            staged = [normalize_path(item[0]) for item in repo.index.entries.keys()]
        return staged
    except InvalidGitRepositoryError:
        return ["Error: Not a git repository"]
    except ANY_GIT_ERROR_TUPLE as e:
        return [f"Error: {e}"]


def get_staged_diff(repo_root: Path) -> str:
    """Get diff of staged changes (git diff --cached).

    Args:
        repo_root: Path to the repository root.

    Returns:
        Staged diff output as string.

    Raises:
        GitToolError: If path is not a git repository or git command fails.
    """
    try:
        repo = _get_repo(repo_root)
        return repo.git.diff("--cached")
    except InvalidGitRepositoryError:
        raise GitToolError("Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        raise GitToolError(f"Git operation failed: {e}")


def get_working_diff(repo_root: Path) -> str:
    """Get diff of unstaged working tree changes (git diff).

    Args:
        repo_root: Path to the repository root.

    Returns:
        Working tree diff output as string.

    Raises:
        GitToolError: If path is not a git repository or git command fails.
    """
    try:
        repo = _get_repo(repo_root)
        return repo.git.diff()
    except InvalidGitRepositoryError:
        raise GitToolError("Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        raise GitToolError(f"Git operation failed: {e}")


def get_all_changes_diff(repo_root: Path) -> str:
    """Get diff of all changes (staged + unstaged) compared to HEAD.

    Args:
        repo_root: Path to the repository root.

    Returns:
        Combined diff output as string.

    Raises:
        GitToolError: If path is not a git repository or git command fails.
    """
    try:
        repo = _get_repo(repo_root)
        return repo.git.diff("HEAD")
    except InvalidGitRepositoryError:
        raise GitToolError("Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        raise GitToolError(f"Git operation failed: {e}")


def get_tracked_files(repo_root: Path, use_cache: bool = True) -> list[str]:
    """List all tracked files in the repository.

    Uses commit-based caching to avoid repeated traversals.

    Args:
        repo_root: Path to the repository root.
        use_cache: Whether to use cached results (default: True).

    Returns:
        List of tracked file paths (normalized), or list with error message on failure.
    """
    try:
        repo = _get_repo(repo_root)
        commit_sha = repo.head.commit.hexsha

        # Check cache first
        if use_cache and commit_sha in _tree_files_cache:
            return list(_tree_files_cache[commit_sha])

        # Use ls-files to get all tracked files
        output = repo.git.ls_files()
        if output:
            files = [normalize_path(f) for f in output.split("\n") if f]
            # Cache the result
            _tree_files_cache[commit_sha] = set(files)
            return files
        return []
    except ANY_GIT_ERROR_TUPLE as e:
        return [f"Error: {e}"]


def get_head_commit(repo_root: Path) -> str:
    """Get current HEAD commit hash.

    Args:
        repo_root: Path to the repository root.

    Returns:
        HEAD commit hash as string, or error message on failure.
    """
    try:
        repo = _get_repo(repo_root)
        return repo.head.commit.hexsha
    except InvalidGitRepositoryError:
        return "Error: Not a git repository"
    except ValueError:
        return "Error: No commits in repository"
    except ANY_GIT_ERROR_TUPLE as e:
        return f"Error: {e}"


def get_remote_url(repo_root: Path) -> str | None:
    """Get origin remote URL.

    Args:
        repo_root: Path to the repository root.

    Returns:
        Origin remote URL as string, None if no origin remote,
        or error message on failure.
    """
    try:
        repo = _get_repo(repo_root)
        if "origin" in [remote.name for remote in repo.remotes]:
            return repo.remotes.origin.url
        return None
    except InvalidGitRepositoryError:
        return "Error: Not a git repository"
    except ANY_GIT_ERROR_TUPLE as e:
        return f"Error: {e}"


# Additional utility functions


def is_git_repo(repo_root: Path) -> bool:
    """Check if path is a git repository.

    Args:
        repo_root: Path to check.

    Returns:
        True if path is a git repository, False otherwise.
    """
    try:
        _get_repo(repo_root)
        return True
    except InvalidGitRepositoryError:
        return False
    except ANY_GIT_ERROR_TUPLE:
        return False


def get_current_branch(repo_root: Path) -> str:
    """Get current branch name.

    Args:
        repo_root: Path to the repository root.

    Returns:
        Current branch name, or error message on failure.
    """
    try:
        repo = _get_repo(repo_root)
        return repo.active_branch.name
    except InvalidGitRepositoryError:
        return "Error: Not a git repository"
    except TypeError:
        return "Error: Detached HEAD state"
    except ANY_GIT_ERROR_TUPLE as e:
        return f"Error: {e}"


def get_branch_name(repo_root: Path) -> str:
    """Get current branch name (alias for get_current_branch).

    Args:
        repo_root: Path to the repository root.

    Returns:
        Current branch name, or error message on failure.
    """
    return get_current_branch(repo_root)


def attach_receipt_via_notes(repo_root: Path, receipt: dict[str, Any]) -> None:
    """Attach receipt to HEAD commit via git notes.

    Args:
        repo_root: Path to the repository root.
        receipt: Receipt dictionary to attach as JSON.

    Raises:
        GitToolError: If path is not a git repository or git command fails.
    """
    try:
        repo = _get_repo(repo_root)
        receipt_json = json.dumps(receipt, indent=2)
        repo.git.notes("--ref", NOTES_REF, "add", "-f", "-m", receipt_json, "HEAD")
    except InvalidGitRepositoryError:
        raise GitToolError("Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        raise GitToolError(f"Git operation failed: {e}")


def attach_receipt_via_trailer(repo_root: Path, receipt_id: str) -> None:
    """Amend HEAD commit to add Gjalla-Receipt trailer.

    Args:
        repo_root: Path to the repository root.
        receipt_id: Receipt ID to add as trailer.

    Raises:
        GitToolError: If path is not a git repository or git command fails.
    """
    try:
        repo = _get_repo(repo_root)
        current_msg = repo.head.commit.message
        # Don't duplicate the trailer if it already exists
        if f"Gjalla-Receipt: {receipt_id}" not in current_msg:
            new_msg = f"{current_msg.rstrip()}\n\nGjalla-Receipt: {receipt_id}\n"
            repo.git.commit("--amend", "-m", new_msg)
    except InvalidGitRepositoryError:
        raise GitToolError("Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        raise GitToolError(f"Git operation failed: {e}")


def get_commit_message(repo_root: Path) -> str:
    """Get HEAD commit message.

    Args:
        repo_root: Path to the repository root.

    Returns:
        HEAD commit message as string.

    Raises:
        GitToolError: If path is not a git repository or no commits exist.
    """
    try:
        repo = _get_repo(repo_root)
        return repo.head.commit.message.strip()
    except InvalidGitRepositoryError:
        raise GitToolError("Not a git repository")
    except ValueError:
        raise GitToolError("No commits in repository")
    except ANY_GIT_ERROR_TUPLE as e:
        raise GitToolError(f"Git operation failed: {e}")


def extract_ticket_references(message: str) -> list[str]:
    """Extract ticket references from commit message.

    Supports:
    - JIRA/Linear style: PROJ-123
    - GitHub style: #123

    Args:
        message: Commit message to parse.

    Returns:
        List of ticket references found in the message.
    """
    patterns = [
        r"[A-Z]+-\d+",  # JIRA/Linear style: PROJ-123
        r"#\d+",  # GitHub style: #123
    ]
    refs: list[str] = []
    for pattern in patterns:
        refs.extend(re.findall(pattern, message))
    return refs


async def get_diff(repo_root: Path, base: str, head: str) -> "ToolResult":
    """Get diff between two commits.

    Args:
        repo_root: Path to the repository root.
        base: Base commit/ref for diff.
        head: Head commit/ref for diff.

    Returns:
        ToolResult with success=True and data=diff string on success,
        or success=False and error message on failure.
    """
    ToolResult = _get_tool_result()

    try:
        repo = _get_repo(repo_root)
        diff = repo.git.diff(base, head)
        return ToolResult(success=True, data=diff)
    except InvalidGitRepositoryError:
        return ToolResult(success=False, error="Not a git repository")
    except ANY_GIT_ERROR_TUPLE as e:
        return ToolResult(success=False, error=str(e))


def format_receipt_trailer(receipt_id: str) -> str:
    """Format a receipt ID as a Gjalla-Receipt trailer.

    Args:
        receipt_id: Receipt ID to format.

    Returns:
        Formatted trailer string.
    """
    return f"Gjalla-Receipt: {receipt_id}"


def append_receipt_trailer(message: str, receipt_id: str) -> str:
    """Append Gjalla-Receipt trailer to commit message string.

    Args:
        message: Commit message to append trailer to.
        receipt_id: Receipt ID to add as trailer.

    Returns:
        Updated commit message with trailer appended.
        If trailer already exists, returns original message unchanged.
    """
    trailer = f"Gjalla-Receipt: {receipt_id}"
    if trailer in message:
        return message
    return f"{message.rstrip()}\n\n{trailer}\n"


def get_commit_history(repo_root: Path, count: int = 10) -> list[dict[str, Any]]:
    """Get recent commit history with details.

    Args:
        repo_root: Path to the repository root.
        count: Number of commits to retrieve (default: 10).

    Returns:
        List of commit dictionaries with sha, message, author, and date,
        or list with error dict on failure.
    """
    try:
        repo = _get_repo(repo_root)
        commits = []
        for commit in repo.iter_commits(max_count=count):
            commits.append({
                "sha": commit.hexsha[:8],
                "message": commit.message.strip(),
                "author": str(commit.author),
                "date": commit.committed_datetime.isoformat(),
            })
        return commits
    except InvalidGitRepositoryError:
        return [{"error": "Not a git repository"}]
    except ANY_GIT_ERROR_TUPLE as e:
        return [{"error": str(e)}]
