"""Filesystem-directory resource handler.

Resolves ``fs-directory`` resources into sandbox-backed
:class:`BoundResource` instances using the ``copy_on_write`` sandbox
strategy.

Content CRUD operations (issue #827):

- ``read``  — direct filesystem read via :mod:`pathlib`
- ``write`` — atomic file write with parent directory creation
- ``delete`` — ``os.remove`` / ``shutil.rmtree``
- ``list_children`` — ``os.listdir`` (top-level entries)
- ``diff``  — unified diff via :mod:`difflib`
- ``discover_children`` — ``os.scandir`` for subdirectories

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

from __future__ import annotations

import difflib
import hashlib
import logging
import os
import shutil
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 FsDirectoryHandler(BaseResourceHandler):
    """Handler for ``fs-directory`` resource types.

    Provisions a copy-on-write sandbox for a local filesystem directory.
    Implements full content CRUD via standard filesystem operations.
    """

    _default_strategy = SandboxStrategy.COPY_ON_WRITE
    _type_label = "fs-directory"

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

    def read(self, *, resource: Resource, path: str = "") -> Content:
        """Read a file or directory listing from the filesystem.

        Args:
            resource: The fs-directory resource.
            path: Relative path within the directory (empty reads the
                directory listing).

        Returns:
            A :class:`Content` with the file bytes or directory listing.

        Raises:
            FileNotFoundError: If *path* does not exist.
        """
        location = self._require_location(resource)
        target = (
            self._safe_resolve(location, path) if path else Path(location).resolve()
        )

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

        if target.is_dir():
            children = sorted(os.listdir(target))
            data = "\n".join(children).encode("utf-8")
            return Content(data=data, encoding="utf-8")

        data = target.read_bytes()
        sha = hashlib.sha256(data).hexdigest()
        return Content(data=data, encoding="utf-8", content_hash=sha)

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

        Creates parent directories as needed.

        Args:
            resource: The fs-directory resource.
            path: Relative path within the directory.
            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 or subdirectory.

        Args:
            resource: The fs-directory resource.
            path: Relative path within the directory.

        Returns:
            A :class:`DeleteResult` indicating success.

        Raises:
            FileNotFoundError: If *path* does not exist.
        """
        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 fs-directory at '{location}'"
            )

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

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

    def list_children(self, *, resource: Resource) -> list[str]:
        """List top-level entries in the directory.

        Args:
            resource: The fs-directory resource.

        Returns:
            Sorted list of entry names.
        """
        location = self._require_location(resource)
        return sorted(os.listdir(location))

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

        Performs a file-by-file unified diff of all files in both
        directories.

        Args:
            resource: The fs-directory resource.
            other_location: Filesystem path to compare against.

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

        # Collect all files from both sides
        a_files = {str(p.relative_to(a_path)) for p in a_path.rglob("*") if p.is_file()}
        b_files = {str(p.relative_to(b_path)) for p in b_path.rglob("*") if p.is_file()}
        all_files = sorted(a_files | b_files)

        unified_parts: list[str] = []
        files_changed = 0
        total_insertions = 0
        total_deletions = 0

        for rel in all_files:
            a_file = a_path / rel
            b_file = b_path / rel

            a_lines = (
                a_file.read_text(encoding="utf-8", errors="replace").splitlines(
                    keepends=True
                )
                if a_file.exists()
                else []
            )
            b_lines = (
                b_file.read_text(encoding="utf-8", errors="replace").splitlines(
                    keepends=True
                )
                if b_file.exists()
                else []
            )

            diff_lines = list(
                difflib.unified_diff(
                    a_lines,
                    b_lines,
                    fromfile=f"a/{rel}",
                    tofile=f"b/{rel}",
                )
            )
            if diff_lines:
                files_changed += 1
                unified_parts.extend(diff_lines)
                for line in diff_lines:
                    if line.startswith("+") and not line.startswith("+++"):
                        total_insertions += 1
                    elif line.startswith("-") and not line.startswith("---"):
                        total_deletions += 1

        return DiffResult(
            has_changes=files_changed > 0,
            unified_diff="".join(unified_parts),
            files_changed=files_changed,
            insertions=total_insertions,
            deletions=total_deletions,
        )

    def discover_children(self, *, resource: Resource) -> list[Resource]:
        """Discover subdirectories as child resources.

        Each immediate subdirectory becomes a child ``fs-directory``
        resource.

        Args:
            resource: The parent fs-directory resource.

        Returns:
            List of child :class:`Resource` objects.
        """
        location = self._require_location(resource)
        children: list[Resource] = []

        for entry in sorted(os.scandir(location), key=lambda e: e.name):
            if not entry.is_dir():
                continue
            child = Resource(
                resource_id=self._derive_child_id(resource.resource_id, entry.name),
                name=entry.name,
                resource_type_name="fs-directory",
                classification=resource.classification,
                description=(f"Directory '{entry.name}' in {resource.resource_id}"),
                location=entry.path,
                parents=[resource.resource_id],
            )
            children.append(child)

        return children

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

    # Instance-level checkpoint store to avoid shared mutable ClassVar state.
    # Each handler instance tracks its own checkpoints.  Plan-level cleanup
    # happens via discard_checkpoints().
    def __init__(self) -> None:
        self._checkpoints: dict[str, str] = {}

    def create_checkpoint(
        self,
        *,
        resource: Resource,
        plan_id: str,
        sandbox_manager: SandboxManager,
        phase: str = "execution",
    ) -> CheckpointResult:
        """Create a checkpoint via filesystem snapshot."""
        import tempfile
        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")
        checkpoint_id = f"checkpoint-{plan_id}-{timestamp}"
        snapshot_dir = tempfile.mkdtemp(prefix=f"ckpt_{checkpoint_id}_")
        snapshot_path = Path(snapshot_dir) / "snapshot"

        shutil.copytree(
            sandbox.context.sandbox_path,
            snapshot_path,
            dirs_exist_ok=True,
        )

        self._checkpoints[checkpoint_id] = str(snapshot_path)

        return CheckpointResult(
            checkpoint_id=checkpoint_id,
            plan_id=plan_id,
            snapshot_path=str(snapshot_path),
            message=f"Created filesystem snapshot at {snapshot_dir}",
        )

    def rollback_to(
        self,
        *,
        resource: Resource,
        plan_id: str,
        checkpoint_id: str,
        sandbox_manager: SandboxManager,
    ) -> RollbackResult:
        """Rollback by clearing sandbox and restoring from snapshot.

        Skips ``.git`` directories to avoid destroying git metadata
        in git-backed sandboxes.
        """
        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})"
            )

        snapshot_path = self._checkpoints.get(checkpoint_id)
        if snapshot_path is None or not Path(snapshot_path).exists():
            return RollbackResult(
                success=False,
                checkpoint_id=checkpoint_id,
                message=f"Checkpoint '{checkpoint_id}' not found or expired",
            )

        sandbox_path = Path(sandbox.context.sandbox_path)

        # Clear sandbox (skip .git to preserve git metadata)
        for item in sandbox_path.iterdir():
            if item.name == ".git":
                continue
            if item.is_dir():
                shutil.rmtree(item)
            else:
                item.unlink()

        # Restore from snapshot (skip .git)
        restored = 0
        for item in Path(snapshot_path).iterdir():
            if item.name == ".git":
                continue
            dest = sandbox_path / item.name
            if item.is_dir():
                shutil.copytree(item, dest)
            else:
                shutil.copy2(item, dest)
            restored += 1

        return RollbackResult(
            success=True,
            checkpoint_id=checkpoint_id,
            restored_files=restored,
            message=f"Restored {restored} items from '{checkpoint_id}'",
        )

    def discard_checkpoints(self, plan_id: str = "") -> int:
        """Remove checkpoint snapshots (cleanup). Returns count removed."""
        removed = 0
        to_remove = [cid for cid in self._checkpoints if not plan_id or plan_id in cid]
        for cid in to_remove:
            snap = self._checkpoints.pop(cid, None)
            if snap and Path(snap).exists():
                shutil.rmtree(Path(snap).parent, ignore_errors=True)
            removed += 1
        return removed

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

    def content_hash(
        self,
        resource: Resource,
        *,
        algorithm: str = "sha256",
    ) -> str:
        """Compute a recursive content hash of the directory.

        Walks the directory tree in sorted order, hashing each file's
        relative path and content.  Only regular files are included
        (symlinks and special files are skipped).
        """
        if not resource.location or not os.path.isdir(resource.location):
            return EMPTY_CONTENT_HASH

        root = resource.location
        h = hashlib.new(algorithm)
        found_any = False

        for dirpath, dirnames, filenames in os.walk(root):
            dirnames.sort()
            for fname in sorted(filenames):
                full = os.path.join(dirpath, fname)
                if not os.path.isfile(full):
                    continue
                rel = os.path.relpath(full, root)
                h.update(rel.encode("utf-8"))
                h.update(b"\0")
                try:
                    with open(full, "rb") as f:
                        for chunk in iter(lambda: f.read(65536), b""):
                            h.update(chunk)
                except OSError:
                    continue
                found_any = True

        return h.hexdigest() if found_any else EMPTY_CONTENT_HASH
