# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Git utility functions for MergeGuide hooks.

This module provides utility functions for interacting with git repositories,
primarily used by the hooks subsystem to query repository state and file
information during pre-commit and pre-push hooks.

All functions execute git commands via subprocess and handle errors
consistently using the GitError exception class.

Functions:
    is_git_repo: Check if current directory is in a git repository
    get_repo_root: Get the repository root directory path
    get_git_hooks_dir: Get the .git/hooks directory path
    get_staged_files: Get list of files staged for commit
    get_push_range_files: Get files changed between commits
    get_current_branch: Get the current branch name
    get_user_email: Get the configured git user email

Example Usage:
    >>> from mergeguide.hooks.git_utils import is_git_repo, get_staged_files
    >>> if is_git_repo():
    ...     staged = get_staged_files()
    ...     print(f"Staged files: {staged}")

Note:
    These functions require git to be installed and available in PATH.
    Operations on non-git directories will raise GitError.
"""

from __future__ import annotations

import logging
import re
import subprocess
from pathlib import Path

logger = logging.getLogger(__name__)

# Git SHA pattern: 40 hex characters
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")


class GitError(Exception):
    """Exception raised when a git command fails.

    This exception wraps subprocess errors from git commands to provide
    a consistent error handling interface. The error message typically
    includes the stderr output from the failed git command.

    Attributes:
        message: Human-readable description of the error.
    """

    pass


def is_git_repo() -> bool:
    """Check if the current directory is within a git repository.

    Uses `git rev-parse --git-dir` to determine if we're inside a
    git repository. This works from any subdirectory of the repo.

    Returns:
        True if the current directory is in a git repository,
        False otherwise (including if git is not installed).

    Example:
        >>> import os
        >>> os.chdir("/tmp")
        >>> is_git_repo()
        False
        >>> os.chdir("/path/to/git/repo")
        >>> is_git_repo()
        True
    """
    try:
        subprocess.run(
            ["git", "rev-parse", "--git-dir"],
            capture_output=True,
            check=True,
        )
        return True
    except (subprocess.CalledProcessError, FileNotFoundError):
        return False


def get_repo_root() -> Path:
    """Get the root directory of the current git repository.

    Uses `git rev-parse --show-toplevel` to find the repository root.
    This is useful for resolving relative paths to absolute paths.

    Returns:
        Path object pointing to the repository root directory.

    Raises:
        GitError: If not in a git repository, git is not installed,
            or the git command fails for any other reason.

    Example:
        >>> root = get_repo_root()
        >>> print(root)
        PosixPath('/home/user/projects/my-repo')
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            capture_output=True,
            text=True,
            check=True,
        )
        return Path(result.stdout.strip())
    except subprocess.CalledProcessError as e:
        raise GitError(f"Failed to get repo root: {e.stderr}") from e
    except FileNotFoundError as e:
        raise GitError("Git is not installed or not in PATH") from e


def get_git_hooks_dir() -> Path:
    """Get the path to the git hooks directory.

    Uses `git rev-parse --git-dir` to find the .git directory, then
    appends "hooks" to get the hooks directory path. This handles
    worktrees and non-standard git directory locations correctly.

    Returns:
        Path object pointing to the hooks directory (typically .git/hooks).

    Raises:
        GitError: If not in a git repository, git is not installed,
            or the git command fails for any other reason.

    Example:
        >>> hooks_dir = get_git_hooks_dir()
        >>> print(hooks_dir)
        PosixPath('/home/user/projects/my-repo/.git/hooks')
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--git-dir"],
            capture_output=True,
            text=True,
            check=True,
        )
        return Path(result.stdout.strip()) / "hooks"
    except subprocess.CalledProcessError as e:
        raise GitError(f"Failed to get git directory: {e.stderr}") from e
    except FileNotFoundError as e:
        raise GitError("Git is not installed or not in PATH") from e


def get_staged_files() -> list[Path]:
    """Get list of files staged for commit.

    Returns files that have been added to the git index (staged area)
    and are ready for commit. Only includes Added, Copied, and Modified
    files - deleted files are excluded since there's no content to check.

    This is primarily used by the pre-commit hook to determine which
    files need policy validation.

    Returns:
        List of Path objects for staged files, relative to repo root.
        Empty list if no files are staged.

    Raises:
        GitError: If not in a git repository, git is not installed,
            or the git command fails.

    Example:
        >>> staged = get_staged_files()
        >>> for file in staged:
        ...     print(f"Staged: {file}")
        Staged: src/main.py
        Staged: tests/test_main.py
    """
    try:
        # --diff-filter=ACM: Added, Copied, Modified (exclude Deleted)
        result = subprocess.run(
            ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
            capture_output=True,
            text=True,
            check=True,
        )
        files = result.stdout.strip().split("\n")
        return [Path(f) for f in files if f]
    except subprocess.CalledProcessError as e:
        raise GitError(f"Failed to get staged files: {e.stderr}") from e
    except FileNotFoundError as e:
        raise GitError("Git is not installed or not in PATH") from e


def get_push_range_files(remote_sha: str, local_sha: str) -> list[Path]:
    """Get files changed between remote and local commits.

    Determines which files were modified in the commits being pushed.
    This is used by the pre-push hook to validate only the files that
    are actually being changed on the remote.

    Handles two scenarios:
    1. New branch (remote_sha is all zeros): Lists files in the commit tree
    2. Existing branch: Lists files changed between remote and local

    Files that no longer exist on disk are filtered out to prevent
    downstream tools from failing on deleted files (DEV-604).

    Args:
        remote_sha: The SHA of the remote branch tip, or 40 zeros if
            this is a new branch being pushed for the first time.
        local_sha: The SHA of the local commit being pushed.

    Returns:
        List of Path objects for changed files that exist on disk,
        relative to repo root. Empty list if no files changed.

    Raises:
        GitError: If git is not installed or the command fails.

    Example:
        >>> # Existing branch - get files changed since remote
        >>> files = get_push_range_files("abc123", "def456")
        >>> # New branch - get all files in the commit
        >>> files = get_push_range_files("0" * 40, "def456")
    """
    # Validate SHA inputs to prevent command injection
    if not SHA_PATTERN.match(remote_sha):
        raise GitError(f"Invalid remote SHA: {remote_sha!r}")
    if not SHA_PATTERN.match(local_sha):
        raise GitError(f"Invalid local SHA: {local_sha!r}")

    try:
        # Handle new branch case where remote SHA is all zeros
        # --diff-filter=ACMR excludes deleted files (D) which no longer exist on disk
        if remote_sha == "0" * 40:
            # New branch: list all files in the commit tree
            result = subprocess.run(
                [
                    "git",
                    "diff-tree",
                    "--no-commit-id",
                    "--name-only",
                    "--diff-filter=ACMR",
                    "-r",
                    local_sha,
                ],
                capture_output=True,
                text=True,
                check=True,
            )
        else:
            # Existing branch: diff between remote and local
            result = subprocess.run(
                [
                    "git",
                    "diff",
                    "--name-only",
                    "--diff-filter=ACMR",
                    f"{remote_sha}..{local_sha}",
                ],
                capture_output=True,
                text=True,
                check=True,
            )
        files = result.stdout.strip().split("\n")
        candidates = [Path(f) for f in files if f]

        # DEV-604: Filter out files that don't exist on disk.
        # Even with --diff-filter=ACMR, files can appear in the diff range
        # but be absent from the working tree (e.g., a file was added in one
        # commit and then deleted/refactored in a subsequent commit within
        # the same push range).
        try:
            repo_root = get_repo_root()
        except GitError:
            # If we can't determine repo root, return all candidates
            # and let downstream handle any missing files gracefully
            return candidates

        existing_files = [f for f in candidates if (repo_root / f).exists()]
        if len(existing_files) < len(candidates):
            skipped = len(candidates) - len(existing_files)
            logger.debug(
                "Filtered out %d file(s) that no longer exist on disk",
                skipped,
            )
        return existing_files
    except subprocess.CalledProcessError as e:
        raise GitError(f"Failed to get push range files: {e.stderr}") from e
    except FileNotFoundError as e:
        raise GitError("Git is not installed or not in PATH") from e


def get_current_branch() -> str:
    """Get the name of the currently checked out branch.

    Uses `git rev-parse --abbrev-ref HEAD` to get a human-readable
    branch name. Returns "HEAD" if in detached HEAD state.

    Returns:
        The current branch name (e.g., "main", "feature/new-api"),
        or "HEAD" if in detached HEAD state.

    Raises:
        GitError: If not in a git repository, git is not installed,
            or the git command fails.

    Example:
        >>> branch = get_current_branch()
        >>> print(f"On branch: {branch}")
        On branch: main
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip()
    except subprocess.CalledProcessError as e:
        raise GitError(f"Failed to get current branch: {e.stderr}") from e
    except FileNotFoundError as e:
        raise GitError("Git is not installed or not in PATH") from e


def get_user_email() -> str:
    """Get the configured git user email address.

    Retrieves the user.email configuration value. This is useful for
    audit trails to identify who performed an action.

    Returns:
        The configured email address as a string, or empty string
        if user.email is not configured.

    Note:
        Unlike other functions in this module, this function does not
        raise GitError when git is not available or the config is not
        set. It returns an empty string instead for convenience.

    Example:
        >>> email = get_user_email()
        >>> if email:
        ...     print(f"User: {email}")
        ... else:
        ...     print("Email not configured")
    """
    try:
        result = subprocess.run(
            ["git", "config", "user.email"],
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip()
    except (subprocess.CalledProcessError, FileNotFoundError) as e:
        logger.warning("Failed to get git user email: %s", e)
        return ""


# =============================================================================
# Commit Signature Verification Functions
# =============================================================================


class SignatureStatus:
    """Commit signature verification status codes.

    These map to the status codes returned by `git verify-commit --raw`.
    """

    GOOD = "G"  # Good (valid) signature
    BAD = "B"  # Bad signature
    UNTRUSTED = "U"  # Good signature with unknown validity (untrusted key)
    EXPIRED = "X"  # Good signature that has expired
    EXPIRED_KEY = "Y"  # Good signature made by an expired key
    REVOKED = "R"  # Good signature made by a revoked key
    NO_PUBKEY = "E"  # Cannot be verified (missing public key)
    NONE = "N"  # No signature


def verify_commit_signature(commit_sha: str, repo_path: str | Path | None = None) -> dict:
    """Verify the GPG/SSH signature of a commit.

    Uses `git verify-commit` to check if a commit has a valid signature.
    This function supports both GPG and SSH signatures (Git 2.34+).

    Args:
        commit_sha: The SHA of the commit to verify.
        repo_path: Optional path to the repository. If not provided,
            uses the current working directory.

    Returns:
        Dictionary containing:
        - signed: Whether the commit has any signature
        - valid: Whether the signature is valid (good and trusted)
        - status: Signature status code (G, B, U, X, Y, R, E, N)
        - signer: Email or key ID of the signer (if available)
        - key_id: GPG key ID or SSH key fingerprint
        - algorithm: Signature algorithm (gpg, ssh, or unknown)
        - trust_level: Trust level of the key (if GPG)
        - error: Error message if verification failed

    Example:
        >>> result = verify_commit_signature("abc123")
        >>> if result["valid"]:
        ...     print(f"Signed by: {result['signer']}")
        ... else:
        ...     print(f"Not valid: {result.get('error', 'Unknown')}")

    Note:
        This requires git 2.11+ for basic verification, and git 2.34+
        for SSH signature support.
    """
    result = {
        "commit": commit_sha,
        "signed": False,
        "valid": False,
        "status": SignatureStatus.NONE,
        "signer": "",
        "key_id": "",
        "algorithm": "unknown",
        "trust_level": "",
        "error": None,
    }

    cmd = ["git"]
    if repo_path:
        cmd.extend(["-C", str(repo_path)])
    cmd.extend(["verify-commit", "--raw", commit_sha])

    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
        )

        # Parse the raw GPG status output from stderr
        raw_output = proc.stderr
        result = _parse_signature_output(raw_output, result)

        # Also check stdout for additional info
        if proc.stdout:
            result["details"] = proc.stdout.strip()

    except FileNotFoundError:
        result["error"] = "Git is not installed or not in PATH"
    except Exception as e:
        result["error"] = str(e)

    return result


def _parse_signature_output(raw_output: str, result: dict) -> dict:
    """Parse GPG/SSH signature verification output.

    Parses the raw output from `git verify-commit --raw` to extract
    signature status and signer information.

    Args:
        raw_output: The stderr output from git verify-commit
        result: The result dictionary to update

    Returns:
        Updated result dictionary
    """
    if not raw_output:
        result["status"] = SignatureStatus.NONE
        result["error"] = "No signature found"
        return result

    # Check for common status indicators
    lines = raw_output.strip().split("\n")

    for line in lines:
        # GPG signature status lines start with [GNUPG:]
        if "[GNUPG:]" in line:
            result["algorithm"] = "gpg"
            parts = line.split()

            if "GOODSIG" in line:
                result["signed"] = True
                result["status"] = SignatureStatus.GOOD
                # Format: [GNUPG:] GOODSIG KEY_ID Name <email>
                if len(parts) >= 4:
                    result["key_id"] = parts[2]
                    result["signer"] = " ".join(parts[3:])

            elif "BADSIG" in line:
                result["signed"] = True
                result["status"] = SignatureStatus.BAD
                result["error"] = "Bad signature"
                if len(parts) >= 3:
                    result["key_id"] = parts[2]

            elif "ERRSIG" in line:
                result["signed"] = True
                result["status"] = SignatureStatus.NO_PUBKEY
                result["error"] = "Cannot verify signature (missing public key)"
                if len(parts) >= 3:
                    result["key_id"] = parts[2]

            elif "EXPSIG" in line:
                result["signed"] = True
                result["status"] = SignatureStatus.EXPIRED
                result["error"] = "Signature has expired"

            elif "EXPKEYSIG" in line:
                result["signed"] = True
                result["status"] = SignatureStatus.EXPIRED_KEY
                result["error"] = "Signing key has expired"

            elif "REVKEYSIG" in line:
                result["signed"] = True
                result["status"] = SignatureStatus.REVOKED
                result["error"] = "Signing key has been revoked"

            elif "VALIDSIG" in line:
                # Contains detailed signature info
                # Format: [GNUPG:] VALIDSIG fingerprint date timestamp ... key_id ...
                if len(parts) >= 3:
                    result["key_id"] = parts[2]

            elif "TRUST_" in line:
                # Trust level: TRUST_UNDEFINED, TRUST_NEVER, TRUST_MARGINAL,
                # TRUST_FULLY, TRUST_ULTIMATE
                for part in parts:
                    if part.startswith("TRUST_"):
                        result["trust_level"] = part.replace("TRUST_", "").lower()
                        # Only fully or ultimate trust is considered valid
                        if result["trust_level"] in ("fully", "ultimate"):
                            if result["status"] == SignatureStatus.GOOD:
                                result["valid"] = True
                        elif result["status"] == SignatureStatus.GOOD:
                            result["status"] = SignatureStatus.UNTRUSTED
                            result["error"] = f"Key not fully trusted: {result['trust_level']}"

        # SSH signature format (Git 2.34+)
        elif 'Good "git" signature' in line:
            result["algorithm"] = "ssh"
            result["signed"] = True
            result["status"] = SignatureStatus.GOOD
            result["valid"] = True

        elif 'Bad "git" signature' in line:
            result["algorithm"] = "ssh"
            result["signed"] = True
            result["status"] = SignatureStatus.BAD
            result["error"] = "Bad SSH signature"

        # Extract SSH key info
        elif line.strip().startswith("SHA256:"):
            result["key_id"] = line.strip()

    # If we found a good signature with no trust level set, default to valid
    if result["status"] == SignatureStatus.GOOD and not result["trust_level"]:
        result["valid"] = True

    return result


def get_commit_signature_info(commit_sha: str, repo_path: str | Path | None = None) -> dict:
    """Get detailed signature information for a commit.

    Uses `git log` to extract signature-related information from a commit,
    including the signer name, email, and key.

    Args:
        commit_sha: The SHA of the commit.
        repo_path: Optional path to the repository.

    Returns:
        Dictionary containing:
        - commit: The commit SHA
        - author: Author name and email
        - committer: Committer name and email
        - gpgsig: Whether the commit has a GPG signature field
        - signer_name: Name from the signature (if available)
        - signer_email: Email from the signature (if available)
        - key_fingerprint: Key fingerprint (if available)

    Example:
        >>> info = get_commit_signature_info("abc123")
        >>> print(info["gpgsig"])  # True if signed
    """
    result = {
        "commit": commit_sha,
        "author": "",
        "committer": "",
        "gpgsig": False,
        "signer_name": "",
        "signer_email": "",
        "key_fingerprint": "",
    }

    cmd = ["git"]
    if repo_path:
        cmd.extend(["-C", str(repo_path)])

    # Use format placeholders to get signature info
    # %G? = signature status, %GS = signer name, %GK = key, %GF = fingerprint
    cmd.extend(["log", "-1", "--format=%G?|%GS|%GK|%GF|%an <%ae>|%cn <%ce>", commit_sha])

    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            check=True,
        )

        output = proc.stdout.strip()
        if output:
            parts = output.split("|")
            if len(parts) >= 6:
                status = parts[0]
                result["gpgsig"] = status not in ("N", "")
                result["signer_name"] = parts[1]
                result["key_fingerprint"] = parts[2] or parts[3]
                result["author"] = parts[4]
                result["committer"] = parts[5]

    except subprocess.CalledProcessError as e:
        logger.warning("Failed to get commit signature info: %s", e)
    except FileNotFoundError as e:
        logger.warning("Git not found for signature verification: %s", e)

    return result


def get_commits_in_range(
    base_sha: str, head_sha: str, repo_path: str | Path | None = None
) -> list[str]:
    """Get list of commit SHAs between two commits.

    Args:
        base_sha: The base commit (exclusive).
        head_sha: The head commit (inclusive).
        repo_path: Optional path to the repository.

    Returns:
        List of commit SHAs from base to head.

    Raises:
        GitError: If the git command fails.
    """
    cmd = ["git"]
    if repo_path:
        cmd.extend(["-C", str(repo_path)])
    cmd.extend(["rev-list", f"{base_sha}..{head_sha}"])

    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            check=True,
        )
        commits = proc.stdout.strip().split("\n")
        return [c for c in commits if c]
    except subprocess.CalledProcessError as e:
        raise GitError(f"Failed to get commit range: {e.stderr}") from e
    except FileNotFoundError as e:
        raise GitError("Git is not installed or not in PATH") from e


def verify_commits_in_range(
    base_sha: str, head_sha: str, repo_path: str | Path | None = None
) -> list[dict]:
    """Verify signatures for all commits in a range.

    Args:
        base_sha: The base commit (exclusive).
        head_sha: The head commit (inclusive).
        repo_path: Optional path to the repository.

    Returns:
        List of verification results for each commit.
    """
    commits = get_commits_in_range(base_sha, head_sha, repo_path)
    results = []

    for commit_sha in commits:
        result = verify_commit_signature(commit_sha, repo_path)
        results.append(result)

    return results
