"""Built-in git operation tools for CleverAgents.

The built-in git tools provide safe, read-only git operations for agents.
All tools are registered under the ``builtin/`` namespace and include
path validation to prevent sandbox escapes.

## Tools

| Tool                    | Capability     | Description                        |
|-------------------------|----------------|------------------------------------|
| ``builtin/git-status``  | ``read_only``  | Show working tree status           |
| ``builtin/git-diff``    | ``read_only``  | Show changes in working tree       |
| ``builtin/git-log``     | ``read_only``  | Show commit log                    |
| ``builtin/git-blame``   | ``read_only``  | Show line-by-line file attribution |

## Resource Slot Requirements

All git tools accept an optional ``repo_path`` input parameter that
specifies the git repository root directory.  When not provided, the
current working directory is used.

Path traversal attacks (using ``..`` to escape the sandbox) are detected
and rejected with a ``ValueError``.

## Registration

```python
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.registry import ToolRegistry

registry = ToolRegistry()
register_git_tools(registry)
```
"""

from __future__ import annotations

import os
import subprocess
from pathlib import Path
from typing import Any

from cleveragents.domain.models.core.tool import ToolCapability
from cleveragents.tool.runtime import ToolSpec

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

# Default timeout for git commands (seconds)
_GIT_TIMEOUT: int = 30

# Safe environment variables for git execution.
# These disable interactive prompts, colour output, and paging so that
# tool output is deterministic and safe for automated consumption.
_GIT_ENV: dict[str, str] = {
    "GIT_PAGER": "cat",
    "GIT_TERMINAL_PROMPT": "0",
    "GIT_ASKPASS": "",
    "NO_COLOR": "1",
    "GIT_CONFIG_NOSYSTEM": "1",
    "TERM": "dumb",
}

# Cached merge of ``os.environ`` with ``_GIT_ENV``, populated on first use.
_BASE_ENV: dict[str, str] | None = None


def _get_base_env() -> dict[str, str]:
    """Return a cached merge of the process environment with git overrides.

    The snapshot is taken on first call and reused thereafter, avoiding a
    full ``os.environ`` copy on every git invocation.
    """
    global _BASE_ENV
    if _BASE_ENV is None:
        _BASE_ENV = {**os.environ, **_GIT_ENV}
    return _BASE_ENV


# ---------------------------------------------------------------------------
# Error mapping
# ---------------------------------------------------------------------------

# Maps common git stderr patterns to user-friendly messages.
_ERROR_MAP: list[tuple[str, str]] = [
    ("not a git repository", "The specified path is not a git repository."),
    ("fatal: bad default revision", "No commits exist in this repository yet."),
    (
        "does not have any commits yet",
        "No commits exist in this repository yet.",
    ),
    (
        "HEAD detached",
        "Repository is in a detached HEAD state;"
        " some operations may behave differently.",
    ),
    ("pathspec", "The specified file or path does not exist in the repository."),
    (
        "no such path",
        "The specified file or path does not exist in the repository.",
    ),
    (
        "unknown revision or path",
        "Unknown revision or path not in the working tree.",
    ),
]


def _map_git_error(stderr: str) -> str:
    """Map git stderr output to a user-friendly error message.

    If no known pattern matches, the raw stderr is returned.
    """
    lower = stderr.lower()
    for pattern, friendly in _ERROR_MAP:
        if pattern in lower:
            return friendly
    return stderr.strip()


# ---------------------------------------------------------------------------
# Path validation
# ---------------------------------------------------------------------------


def validate_repo_path(
    repo_path_str: str | None = None,
    *,
    sandbox_root: Path | None = None,
) -> Path:
    """Validate a repository path to prevent path traversal attacks.

    When *repo_path_str* is ``None``, the current working directory is
    returned.  Both absolute and relative paths are resolved and checked
    against the sandbox root (defaults to cwd) to prevent escapes.

    Args:
        repo_path_str: User-supplied repository path.  ``None`` means cwd.
        sandbox_root: Explicit root directory for containment checks.
            Defaults to the current working directory when ``None``.

    Raises:
        ValueError: When the resolved path escapes the sandbox root.

    .. note::
        Absolute paths are **not** automatically trusted.  They are
        resolved and validated against *sandbox_root* just like relative
        paths.  Callers that intentionally need to operate outside the
        sandbox should pass an appropriate *sandbox_root*.
    """
    root = (sandbox_root or Path.cwd()).resolve()

    if not repo_path_str:
        return root

    candidate = Path(repo_path_str)
    target = (
        (root / candidate).resolve()
        if not candidate.is_absolute()
        else candidate.resolve()
    )

    if not target.is_relative_to(root):
        raise ValueError(
            f"Path traversal detected: '{repo_path_str}' escapes sandbox root"
        )
    return target


def _resolve_sandbox_root(raw: str | Path | None) -> Path | None:
    """Convert a raw ``_sandbox_root`` input to a resolved :class:`Path`.

    Returns ``None`` when *raw* is falsy, which makes
    ``validate_repo_path`` fall back to cwd.
    """
    if not raw:
        return None
    return Path(raw).resolve() if not isinstance(raw, Path) else raw.resolve()


def _validate_sub_path(path_str: str, repo: Path) -> str:
    """Ensure *path_str* stays within *repo* after resolution.

    Raises ``ValueError`` for traversal attempts such as ``../../etc/passwd``.
    Returns the validated *path_str* unchanged for forwarding to git.
    """
    target = (repo / path_str).resolve()
    if not target.is_relative_to(repo):
        raise ValueError(
            f"Path traversal detected: '{path_str}' escapes repository root"
        )
    return path_str


def _validate_ref(ref: str) -> str:
    """Reject git refs that look like CLI options (start with ``-``).

    Prevents option-injection attacks where a malicious ``ref`` value
    such as ``--exec=...`` could alter git behaviour.
    """
    if ref.startswith("-"):
        raise ValueError(f"Invalid ref '{ref}': must not start with '-'")
    return ref


# ---------------------------------------------------------------------------
# Git subprocess helper
# ---------------------------------------------------------------------------


def _run_git(
    args: list[str],
    cwd: str,
    timeout: int = _GIT_TIMEOUT,
) -> subprocess.CompletedProcess[str]:
    """Run a git command with deterministic output settings.

    Sets ``GIT_PAGER=cat``, ``NO_COLOR=1``, disables interactive
    prompts, and applies safe directory configuration so output is
    stable across environments.

    Args:
        args: Git sub-command and arguments (e.g. ``["status", "--porcelain"]``).
        cwd: Working directory to run in.
        timeout: Maximum seconds to wait.

    Returns:
        Completed process result.

    Raises:
        subprocess.TimeoutExpired: If the command exceeds *timeout*.
        subprocess.CalledProcessError: If the command returns non-zero.
    """
    base = _get_base_env()
    # Per-call dynamic keys: mark the target directory as safe so git
    # doesn't refuse to operate.  We copy the cached base once and add
    # only the three dynamic entries instead of rebuilding from scratch.
    env = {
        **base,
        "GIT_CONFIG_VALUE_0": cwd,
        "GIT_CONFIG_KEY_0": "safe.directory",
        "GIT_CONFIG_COUNT": "1",
    }

    cmd = ["git", "--no-pager", "-c", "color.ui=never", *args]
    return subprocess.run(
        cmd,
        cwd=cwd,
        capture_output=True,
        text=True,
        check=True,
        timeout=timeout,
        env=env,
    )


# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------


def _handle_git_status(inputs: dict[str, Any]) -> dict[str, Any]:
    """Show working tree status."""
    _sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
    repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
    short: bool = inputs.get("short", False)

    args = ["status"]
    if short:
        args.append("--porcelain")

    try:
        result = _run_git(args, cwd=str(repo))
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
    except subprocess.CalledProcessError as exc:
        raise RuntimeError(_map_git_error(exc.stderr)) from exc
    return {
        "output": result.stdout,
        "repo_path": str(repo),
    }


def _handle_git_diff(inputs: dict[str, Any]) -> dict[str, Any]:
    """Show changes in working tree."""
    _sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
    repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
    ref: str | None = inputs.get("ref")
    path: str | None = inputs.get("path")
    staged: bool = inputs.get("staged", False)
    stat: bool = inputs.get("stat", False)

    args = ["diff"]
    if staged:
        args.append("--cached")
    if stat:
        args.append("--stat")
    if ref:
        args.append(_validate_ref(ref))
    if path:
        _validate_sub_path(path, repo)
        args.extend(["--", path])

    try:
        result = _run_git(args, cwd=str(repo))
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
    except subprocess.CalledProcessError as exc:
        raise RuntimeError(_map_git_error(exc.stderr)) from exc
    return {
        "output": result.stdout,
        "repo_path": str(repo),
    }


def _handle_git_log(inputs: dict[str, Any]) -> dict[str, Any]:
    """Show commit log."""
    _sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
    repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
    max_count: int = inputs.get("max_count", 10)
    oneline: bool = inputs.get("oneline", False)
    author: str | None = inputs.get("author")
    since: str | None = inputs.get("since")
    until: str | None = inputs.get("until")
    path: str | None = inputs.get("path")

    args = ["log", f"--max-count={max_count}"]
    if oneline:
        args.append("--oneline")
    if author:
        args.append(f"--author={author}")
    if since:
        args.append(f"--since={since}")
    if until:
        args.append(f"--until={until}")
    if path:
        _validate_sub_path(path, repo)
        args.extend(["--", path])

    try:
        result = _run_git(args, cwd=str(repo))
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
    except subprocess.CalledProcessError as exc:
        raise RuntimeError(_map_git_error(exc.stderr)) from exc
    return {
        "output": result.stdout,
        "repo_path": str(repo),
    }


def _handle_git_blame(inputs: dict[str, Any]) -> dict[str, Any]:
    """Show line-by-line file attribution."""
    _sroot = _resolve_sandbox_root(inputs.get("_sandbox_root"))
    repo = validate_repo_path(inputs.get("repo_path"), sandbox_root=_sroot)
    path: str = inputs["path"]
    line_start: int | None = inputs.get("line_start")
    line_end: int | None = inputs.get("line_end")

    _validate_sub_path(path, repo)

    # Require both or neither line boundaries
    has_start = line_start is not None
    has_end = line_end is not None
    if has_start != has_end:
        raise ValueError("Both line_start and line_end are required for a blame range")

    args = ["blame"]
    if has_start and has_end:
        args.extend(["-L", f"{line_start},{line_end}"])
    args.append(path)

    try:
        result = _run_git(args, cwd=str(repo))
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"Git command timed out after {exc.timeout}s") from exc
    except subprocess.CalledProcessError as exc:
        raise RuntimeError(_map_git_error(exc.stderr)) from exc
    return {
        "output": result.stdout,
        "path": path,
        "repo_path": str(repo),
    }


# ---------------------------------------------------------------------------
# Tool specs
# ---------------------------------------------------------------------------


GIT_STATUS_SPEC = ToolSpec(
    name="builtin/git-status",
    description="Show working tree status",
    input_schema={
        "type": "object",
        "properties": {
            "repo_path": {
                "type": "string",
                "description": "Path to git repository root (defaults to cwd)",
            },
            "short": {
                "type": "boolean",
                "default": False,
                "description": "Use short/porcelain output format",
            },
        },
        "required": [],
    },
    output_schema={
        "type": "object",
        "properties": {
            "output": {"type": "string"},
            "repo_path": {"type": "string"},
        },
    },
    capabilities=ToolCapability(read_only=True),
    handler=_handle_git_status,
)

GIT_DIFF_SPEC = ToolSpec(
    name="builtin/git-diff",
    description="Show changes in working tree",
    input_schema={
        "type": "object",
        "properties": {
            "repo_path": {
                "type": "string",
                "description": "Path to git repository root (defaults to cwd)",
            },
            "ref": {
                "type": "string",
                "description": "Ref to diff against (e.g. HEAD~1, branch name)",
            },
            "path": {
                "type": "string",
                "description": "Limit diff to a specific path",
            },
            "staged": {
                "type": "boolean",
                "default": False,
                "description": "Show staged (cached) changes instead of unstaged",
            },
            "stat": {
                "type": "boolean",
                "default": False,
                "description": "Show diffstat summary instead of full diff",
            },
        },
        "required": [],
    },
    output_schema={
        "type": "object",
        "properties": {
            "output": {"type": "string"},
            "repo_path": {"type": "string"},
        },
    },
    capabilities=ToolCapability(read_only=True),
    handler=_handle_git_diff,
)

GIT_LOG_SPEC = ToolSpec(
    name="builtin/git-log",
    description="Show commit log",
    input_schema={
        "type": "object",
        "properties": {
            "repo_path": {
                "type": "string",
                "description": "Path to git repository root (defaults to cwd)",
            },
            "max_count": {
                "type": "integer",
                "default": 10,
                "description": "Maximum number of commits to show",
            },
            "oneline": {
                "type": "boolean",
                "default": False,
                "description": "Use one-line format",
            },
            "author": {
                "type": "string",
                "description": "Filter commits by author",
            },
            "since": {
                "type": "string",
                "description": "Show commits after date (e.g. 2024-01-01)",
            },
            "until": {
                "type": "string",
                "description": "Show commits before date",
            },
            "path": {
                "type": "string",
                "description": "Limit log to a specific path",
            },
        },
        "required": [],
    },
    output_schema={
        "type": "object",
        "properties": {
            "output": {"type": "string"},
            "repo_path": {"type": "string"},
        },
    },
    capabilities=ToolCapability(read_only=True),
    handler=_handle_git_log,
)

GIT_BLAME_SPEC = ToolSpec(
    name="builtin/git-blame",
    description="Show line-by-line file attribution",
    input_schema={
        "type": "object",
        "properties": {
            "repo_path": {
                "type": "string",
                "description": "Path to git repository root (defaults to cwd)",
            },
            "path": {
                "type": "string",
                "description": "File path to blame",
            },
            "line_start": {
                "type": "integer",
                "description": "Start line for blame range",
            },
            "line_end": {
                "type": "integer",
                "description": "End line for blame range",
            },
        },
        "required": ["path"],
    },
    output_schema={
        "type": "object",
        "properties": {
            "output": {"type": "string"},
            "path": {"type": "string"},
            "repo_path": {"type": "string"},
        },
    },
    capabilities=ToolCapability(read_only=True),
    handler=_handle_git_blame,
)


ALL_GIT_TOOLS: list[ToolSpec] = [
    GIT_STATUS_SPEC,
    GIT_DIFF_SPEC,
    GIT_LOG_SPEC,
    GIT_BLAME_SPEC,
]
