"""ChangeSet capture integration for file tools.

Wraps tool execution to automatically record change entries for
write/edit/delete operations.  Provides ``ChangeSetCapture`` which
can wrap any ``ToolSpec`` and maintain a running log of mutations.

The capture layer bridges the execution-layer ``ToolSpec`` handlers
and the domain-layer ``SpecChangeSet`` / ``ChangeEntry`` models
defined in ``cleveragents.domain.models.core.change``.
"""

from __future__ import annotations

import hashlib
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from pydantic import BaseModel, ConfigDict, Field

from cleveragents.domain.models.core.change import (
    ChangeEntry,
    ChangeOperation,
    SpecChangeSet,
)
from cleveragents.tool.runtime import ToolSpec

# ------------------------------------------------------------------
# Legacy lightweight models (kept for backward compat)
# ------------------------------------------------------------------


class ChangeSetEntry(BaseModel):
    """A single recorded change from a tool execution.

    This is the lightweight execution-layer entry.  For the full
    domain model see
    :class:`~cleveragents.domain.models.core.change.ChangeEntry`.
    """

    operation: str = Field(
        ...,
        description=("Type of change: create, modify, delete, or move"),
    )
    path: str = Field(..., description="Path of the affected file")
    resource_id: str | None = Field(
        default=None,
        description="Optional resource identifier",
    )
    tool_name: str | None = Field(
        default=None,
        description="Namespaced tool that caused the change",
    )
    before_hash: str | None = Field(
        default=None,
        description="SHA-256 hash of file before change",
    )
    after_hash: str | None = Field(
        default=None,
        description="SHA-256 hash of file after change",
    )
    timestamp: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="UTC timestamp of the change",
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="Additional metadata",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


class ChangeSet(BaseModel):
    """Accumulated set of changes from tool executions.

    Lightweight execution-layer container.  The full domain model
    is :class:`~cleveragents.domain.models.core.change.SpecChangeSet`.
    """

    plan_id: str = Field(..., description="Plan identifier for this changeset")
    entries: list[ChangeSetEntry] = Field(
        default_factory=list,
        description="List of recorded changes",
    )
    created_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="When this changeset was created",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )

    def summary(self) -> str:
        """Return a human-readable summary of the changeset."""
        if not self.entries:
            return "No changes recorded"

        counts: dict[str, int] = {}
        for entry in self.entries:
            counts[entry.operation] = counts.get(entry.operation, 0) + 1

        parts = [f"{count} {op}" for op, count in sorted(counts.items())]
        return f"{len(self.entries)} changes: {', '.join(parts)}"


# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------


def _file_hash(path_str: str, sandbox_root: str | None = None) -> str | None:
    """Compute SHA-256 hash of a file, or None if missing."""
    root = Path(sandbox_root) if sandbox_root else Path.cwd()
    p = (root / path_str).resolve()
    if not p.exists():
        return None
    return hashlib.sha256(p.read_bytes()).hexdigest()


def _normalize_path(path_str: str, sandbox_root: str | None = None) -> str:
    """Normalize a file path to be repo-relative.

    If *sandbox_root* is provided the path is made relative to it.
    Otherwise the original path is returned unchanged.
    """
    if not path_str:
        return path_str
    if sandbox_root:
        try:
            return os.path.relpath(
                (Path(sandbox_root) / path_str).resolve(),
                Path(sandbox_root).resolve(),
            )
        except ValueError:
            return path_str
    return path_str


# ------------------------------------------------------------------
# Errors
# ------------------------------------------------------------------


class ReadOnlyViolationError(Exception):
    """Raised when a write operation is attempted on a read-only capture."""


# ------------------------------------------------------------------
# Capture wrapper
# ------------------------------------------------------------------


class ChangeSetCapture:
    """Wraps tool execution to record change entries.

    Usage::

        capture = ChangeSetCapture(plan_id="plan-123")
        wrapped = capture.wrap_tool(file_write_spec)
        # ... execute wrapped tool ...
        changeset = capture.get_changeset()
    """

    def __init__(
        self,
        plan_id: str,
        resource_id: str | None = None,
        sandbox_root: str | None = None,
        *,
        read_only: bool = False,
    ) -> None:
        self._plan_id = plan_id
        self._resource_id = resource_id or ""
        self._sandbox_root = sandbox_root
        self._read_only = read_only
        self._entries: list[ChangeSetEntry] = []

    def wrap_tool(self, tool_spec: ToolSpec) -> ToolSpec:
        """Return a new ToolSpec whose handler records changes.

        Only wraps tools that have ``writes=True`` in their
        capabilities.  Read-only tools are returned unchanged.

        Raises
        ------
        ReadOnlyViolationError
            If the capture is read-only and the tool has ``writes=True``.
        """
        if not tool_spec.capabilities.writes:
            return tool_spec

        if self._read_only:
            raise ReadOnlyViolationError(
                f"Cannot wrap write-capable tool '{tool_spec.name}' "
                f"in read-only ChangeSetCapture (plan={self._plan_id})"
            )

        original_handler = tool_spec.handler
        capture = self

        def _wrapped_handler(
            inputs: dict[str, Any],
        ) -> Any:
            path_str = inputs.get("path", "")
            sandbox = inputs.get("sandbox_root") or capture._sandbox_root
            before = _file_hash(path_str, sandbox) if path_str else None

            result = original_handler(inputs)

            after = _file_hash(path_str, sandbox) if path_str else None
            output = result if isinstance(result, dict) else {}

            operation = _detect_operation(tool_spec.name, before, after, output)
            resource_id = inputs.get("resource_id") or capture._resource_id
            normalized = _normalize_path(path_str, sandbox)

            entry = ChangeSetEntry(
                operation=operation,
                path=normalized,
                resource_id=resource_id or None,
                tool_name=tool_spec.name,
                before_hash=before,
                after_hash=after,
                metadata={"tool": tool_spec.name},
            )
            capture._entries.append(entry)

            return result

        return ToolSpec(
            name=tool_spec.name,
            description=tool_spec.description,
            input_schema=tool_spec.input_schema,
            output_schema=tool_spec.output_schema,
            capabilities=tool_spec.capabilities,
            handler=_wrapped_handler,
        )

    def get_changeset(self) -> ChangeSet:
        """Return accumulated changes as a ``ChangeSet``."""
        return ChangeSet(
            plan_id=self._plan_id,
            entries=list(self._entries),
        )

    def to_spec_changeset(self) -> SpecChangeSet:
        """Convert accumulated changes to spec-aligned model.

        Builds domain-layer ``ChangeEntry`` objects from the
        lightweight ``ChangeSetEntry`` records.
        """
        domain_entries: list[ChangeEntry] = []
        for e in self._entries:
            op = _map_operation(e.operation)
            domain_entries.append(
                ChangeEntry(
                    plan_id=self._plan_id,
                    resource_id=e.resource_id or "",
                    tool_name=e.tool_name or "",
                    operation=op,
                    path=e.path,
                    before_hash=e.before_hash,
                    after_hash=e.after_hash,
                    timestamp=e.timestamp,
                )
            )
        return SpecChangeSet(
            plan_id=self._plan_id,
            entries=domain_entries,
        )

    def clear(self) -> None:
        """Reset the change tracking."""
        self._entries.clear()


def _map_operation(op_str: str) -> ChangeOperation:
    """Map a string operation to the domain enum."""
    mapping: dict[str, ChangeOperation] = {
        "create": ChangeOperation.CREATE,
        "modify": ChangeOperation.MODIFY,
        "delete": ChangeOperation.DELETE,
        "move": ChangeOperation.RENAME,
        "rename": ChangeOperation.RENAME,
    }
    return mapping.get(op_str, ChangeOperation.MODIFY)


def _detect_operation(
    tool_name: str,
    before: str | None,
    after: str | None,
    output: dict[str, Any],
) -> str:
    """Detect the operation type from tool name and file state."""
    if "delete" in tool_name:
        return "delete"
    if "edit" in tool_name:
        return "modify"
    # file-write: created vs modified
    if output.get("created", False):
        return "create"
    if before is None and after is not None:
        return "create"
    return "modify"
