"""Git-checkout resource handler.

Resolves ``git-checkout`` resources into sandbox-backed
:class:`BoundResource` instances using the ``git_worktree`` sandbox
strategy (with fallback to ``copy_on_write``).

Content CRUD operations (issue #827):

- ``read``  — ``git show HEAD:<path>``
- ``write`` — atomic file write inside the checkout
- ``delete`` — ``os.remove`` + ``git rm --cached``
- ``list_children`` — ``git ls-tree -r --name-only HEAD``
- ``diff``  — ``git diff --no-index``
- ``discover_children`` — ``git ls-tree --name-only HEAD``

Based on:
    - implementation_plan.md group M1.resource-handlers (L2254-L2271)
    - Built-in type definition in resource_registry_service.py L62-98
    - Issue #827 — ResourceHandler CRUD and discovery methods
"""

from __future__ import annotations

import hashlib
import logging
import os
import subprocess
from pathlib import Path

from cleveragents.domain.models.core.resource import (
    Resource,
    SandboxStrategy,
)
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.resource.handlers._base import (
    EMPTY_CONTENT_HASH,
    BaseResourceHandler,
)
from cleveragents.resource.handlers.protocol import (
    CheckpointResult,
    Content,
    DeleteResult,
    DiffResult,
    RollbackResult,
    WriteResult,
)

logger = logging.getLogger(__name__)


class GitCheckoutHandler(BaseResourceHandler):
    """Handler for ``git-checkout`` resource types.

    Provisions a git-worktree sandbox (or copy-on-write fallback)
    for a git repository checkout.  Implements content CRUD via git
    plumbing commands where appropriate, falling back to filesystem
    operations for write/delete.
    """

    _default_strategy = SandboxStrategy.GIT_WORKTREE
    _type_label = "git-checkout"

    # -- Content CRUD (issue #827) -----------------------------------------

    def read(self, *, resource: Resource, path: str = "") -> Content:
        """Read a file from the git checkout using ``git show``.

        Falls back to direct filesystem read if ``git show`` fails
        (e.g. for untracked files).

        Args:
            resource: The git-checkout resource.
            path: Relative path within the repo (empty reads repo root
                listing).

        Returns:
            A :class:`Content` with the file bytes.

        Raises:
            FileNotFoundError: If *path* does not exist in the checkout.
        """
        location = self._require_location(resource)

        if not path:
            children = self.list_children(resource=resource)
            data = "\n".join(children).encode("utf-8")
            return Content(data=data, encoding="utf-8")

        # Validate path doesn't escape root
        target = self._safe_resolve(location, path)

        # Try git show first (works for tracked files)
        try:
            result = subprocess.run(
                ["git", "show", f"HEAD:{path}"],
                cwd=location,
                capture_output=True,
                timeout=30,
                check=False,
            )
            if result.returncode == 0:
                data = result.stdout
                sha = hashlib.sha256(data).hexdigest()
                # Detect binary: if UTF-8 decode fails, mark as binary
                encoding: str | None = "utf-8"
                try:
                    data.decode("utf-8")
                except UnicodeDecodeError:
                    encoding = None
                return Content(data=data, encoding=encoding, content_hash=sha)
        except (subprocess.TimeoutExpired, OSError):
            logger.debug("git show failed for %s, falling back to fs read", path)

        # Fallback: direct filesystem read
        if not target.exists():
            raise FileNotFoundError(
                f"Path '{path}' not found in git-checkout at '{location}'"
            )
        data = target.read_bytes()
        sha = hashlib.sha256(data).hexdigest()
        encoding = "utf-8"
        try:
            data.decode("utf-8")
        except UnicodeDecodeError:
            encoding = None
        return Content(data=data, encoding=encoding, content_hash=sha)

    def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult:
        """Write a file into the git checkout.

        Writes directly to the filesystem within the checkout location.
        Does **not** auto-commit; the caller is responsible for staging
        and committing changes.

        Args:
            resource: The git-checkout resource.
            path: Relative path within the repo.
            data: Raw bytes to write.

        Returns:
            A :class:`WriteResult` indicating success.
        """
        location = self._require_location(resource)
        target = self._safe_resolve(location, path)
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(data)
        sha = hashlib.sha256(data).hexdigest()
        return WriteResult(
            success=True,
            bytes_written=len(data),
            content_hash=sha,
            message=f"Wrote {len(data)} bytes to {path}",
        )

    def delete(self, *, resource: Resource, path: str = "") -> DeleteResult:
        """Delete a file from the git checkout.

        Removes the file from the filesystem.  If the file is tracked,
        also runs ``git rm --cached`` to unstage it.

        Args:
            resource: The git-checkout resource.
            path: Relative path within the repo.

        Returns:
            A :class:`DeleteResult` indicating success.
        """
        if not path:
            raise PermissionError("Cannot delete the resource root (empty path)")
        location = self._require_location(resource)
        target = self._safe_resolve(location, path)

        if not target.exists():
            raise FileNotFoundError(
                f"Path '{path}' not found in git-checkout at '{location}'"
            )

        if target.is_dir():
            import shutil

            shutil.rmtree(target)
        else:
            target.unlink()

        # Unstage if tracked
        subprocess.run(
            ["git", "rm", "--cached", "--ignore-unmatch", "-r", path],
            cwd=location,
            capture_output=True,
            timeout=30,
            check=False,
        )

        return DeleteResult(success=True, message=f"Deleted {path}")

    def list_children(self, *, resource: Resource) -> list[str]:
        """List tracked files using ``git ls-tree``.

        Returns top-level entries (files and directories) tracked by
        git at HEAD.

        Args:
            resource: The git-checkout resource.

        Returns:
            Sorted list of top-level paths.
        """
        location = self._require_location(resource)

        result = subprocess.run(
            ["git", "ls-tree", "--name-only", "HEAD"],
            cwd=location,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )

        if result.returncode != 0:
            logger.warning(
                "git ls-tree failed (rc=%d) for %s: %s",
                result.returncode,
                resource.resource_id,
                result.stderr.strip(),
            )
            # Fallback: filesystem listing
            return sorted(os.listdir(location))

        entries = [line for line in result.stdout.strip().split("\n") if line]
        return sorted(entries)

    def diff(self, *, resource: Resource, other_location: str) -> DiffResult:
        """Compare the git checkout against another location.

        Uses ``git diff --no-index`` which works on arbitrary paths
        (not just git-tracked content).

        Args:
            resource: The git-checkout resource.
            other_location: Filesystem path to compare against.

        Returns:
            A :class:`DiffResult` summarising the differences.
        """
        location = self._require_location(resource)

        # Force C locale so --shortstat output is always English
        env = {**os.environ, "LC_ALL": "C"}

        result = subprocess.run(
            ["git", "diff", "--no-index", "--shortstat", location, other_location],
            capture_output=True,
            text=True,
            timeout=60,
            check=False,
            env=env,
        )

        # git diff --no-index exits 0 = no diff, 1 = has diff, 2+ = error
        has_changes = result.returncode == 1

        # Get unified diff for detail (binary-safe)
        unified_result = subprocess.run(
            ["git", "diff", "--no-index", location, other_location],
            capture_output=True,
            timeout=60,
            check=False,
            env=env,
        )

        unified = (
            unified_result.stdout.decode("utf-8", errors="replace")
            if has_changes
            else ""
        )

        # Parse --shortstat with named patterns to handle all variants:
        #   "N files changed, N insertions(+), N deletions(-)"
        #   "N files changed, N insertions(+)"
        #   "N files changed, N deletions(-)"
        files_changed = 0
        insertions = 0
        deletions = 0
        if has_changes and result.stdout:
            import re

            m_files = re.search(r"(\d+) file", result.stdout)
            m_ins = re.search(r"(\d+) insertion", result.stdout)
            m_del = re.search(r"(\d+) deletion", result.stdout)
            if m_files:
                files_changed = int(m_files.group(1))
            if m_ins:
                insertions = int(m_ins.group(1))
            if m_del:
                deletions = int(m_del.group(1))

        return DiffResult(
            has_changes=has_changes,
            unified_diff=unified,
            files_changed=files_changed,
            insertions=insertions,
            deletions=deletions,
        )

    def discover_children(self, *, resource: Resource) -> list[Resource]:
        """Discover child resources via ``git ls-tree``.

        Each top-level directory in the repo becomes a child resource
        of type ``fs-directory``.

        Args:
            resource: The parent git-checkout resource.

        Returns:
            List of child :class:`Resource` objects for top-level
            directories.
        """
        location = self._require_location(resource)

        result = subprocess.run(
            ["git", "ls-tree", "--name-only", "-d", "HEAD"],
            cwd=location,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )

        children: list[Resource] = []
        if result.returncode != 0:
            return children

        for dirname in result.stdout.strip().split("\n"):
            dirname = dirname.strip()
            if not dirname:
                continue
            child_path = str(Path(location) / dirname)
            child = Resource(
                resource_id=self._derive_child_id(resource.resource_id, dirname),
                name=dirname,
                resource_type_name="fs-directory",
                classification=resource.classification,
                description=f"Directory '{dirname}' in {resource.resource_id}",
                location=child_path,
                parents=[resource.resource_id],
            )
            children.append(child)

        return children

    # -- Checkpoint and rollback (issue #836) ------------------------------

    def create_checkpoint(
        self,
        *,
        resource: Resource,
        plan_id: str,
        sandbox_manager: SandboxManager,
        phase: str = "execution",
    ) -> CheckpointResult:
        """Create a checkpoint via ``git tag`` in the sandbox."""
        from datetime import UTC, datetime

        sandbox = sandbox_manager.get_sandbox(
            plan_id=plan_id, resource_id=resource.resource_id
        )
        if sandbox is None or sandbox.context is None:
            raise RuntimeError(
                f"No active sandbox for resource '{resource.resource_id}' "
                f"(plan={plan_id})"
            )

        timestamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S")
        tag_name = f"checkpoint-{plan_id}-{timestamp}"

        result = subprocess.run(
            ["git", "tag", tag_name],
            cwd=sandbox.context.sandbox_path,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
        if result.returncode != 0:
            raise RuntimeError(f"git tag failed: {result.stderr.strip()}")

        return CheckpointResult(
            checkpoint_id=tag_name,
            plan_id=plan_id,
            snapshot_path=sandbox.context.sandbox_path,
            message=f"Created git tag '{tag_name}' at HEAD",
        )

    def rollback_to(
        self,
        *,
        resource: Resource,
        plan_id: str,
        checkpoint_id: str,
        sandbox_manager: SandboxManager,
    ) -> RollbackResult:
        """Rollback via ``git reset --hard`` + ``git clean -fd``.

        Uses hard reset to restore both tracked and untracked file state.
        """
        sandbox = sandbox_manager.get_sandbox(
            plan_id=plan_id, resource_id=resource.resource_id
        )
        if sandbox is None or sandbox.context is None:
            raise RuntimeError(
                f"No active sandbox for resource '{resource.resource_id}' "
                f"(plan={plan_id})"
            )
        cwd = sandbox.context.sandbox_path

        # Hard reset to the checkpoint tag
        result = subprocess.run(
            ["git", "reset", "--hard", checkpoint_id],
            cwd=cwd,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
        if result.returncode != 0:
            return RollbackResult(
                success=False,
                checkpoint_id=checkpoint_id,
                message=f"git reset --hard failed: {result.stderr.strip()}",
            )

        # Clean untracked files and directories
        subprocess.run(
            ["git", "clean", "-fd"],
            cwd=cwd,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )

        return RollbackResult(
            success=True,
            checkpoint_id=checkpoint_id,
            restored_files=0,
            message=f"Rolled back to checkpoint '{checkpoint_id}'",
        )

    # -- Content Hashing (#836) ---------------------------------------------

    def content_hash(
        self,
        resource: Resource,
        *,
        algorithm: str = "sha256",
    ) -> str:
        """Compute content hash using ``git rev-parse HEAD``.

        For git checkouts, the HEAD commit hash uniquely identifies
        the content state.  The algorithm parameter is used to
        re-hash the commit SHA for consistency with other handlers.
        """
        if not resource.location or not os.path.isdir(resource.location):
            return EMPTY_CONTENT_HASH

        try:
            result = subprocess.run(
                ["git", "rev-parse", "HEAD"],
                cwd=resource.location,
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
            if result.returncode != 0:
                return EMPTY_CONTENT_HASH
            commit_sha = result.stdout.strip()
            # Re-hash through the requested algorithm for consistency
            h = hashlib.new(algorithm)
            h.update(commit_sha.encode("utf-8"))
            return h.hexdigest()
        except (OSError, subprocess.TimeoutExpired):
            return EMPTY_CONTENT_HASH
