"""Change domain model for CleverAgents.

Based on Phase 0 discovery, ADR-004 (Pydantic Validation), and the
D0.alpha changeset specification.  Contains both the legacy change
models (used by existing plan workflows) and the spec-aligned
ChangeSet domain model with ULID identifiers and content hashes.

Also provides ToolInvocation tracking (C5.model) for auditability
of tool executions and their resulting changes.
"""

import contextlib
from collections import defaultdict
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import PurePosixPath
from typing import Any, Protocol

from pydantic import BaseModel, ConfigDict, Field, model_validator
from ulid import ULID

# ------------------------------------------------------------------
# Legacy models (kept for backward compatibility with C0.files)
# ------------------------------------------------------------------


class OperationType(StrEnum):
    """Type of file operation."""

    CREATE = "create"
    MODIFY = "modify"
    DELETE = "delete"
    MOVE = "move"


class Operation(BaseModel):
    """A single file operation."""

    type: OperationType = Field(...)
    path: str = Field(..., min_length=1)
    content: str | None = Field(None)
    original_content: str | None = Field(None)

    # For move operations
    new_path: str | None = Field(None)

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


class Change(BaseModel):
    """Domain model for a change.

    A change represents a modification to be made to a file.
    """

    id: int | None = Field(None)
    plan_id: int = Field(..., gt=0)
    file_path: str = Field(..., min_length=1)
    operation: OperationType = Field(...)
    original_content: str | None = Field(None)
    new_content: str | None = Field(None)
    applied: bool = Field(False)
    created_at: datetime = Field(default_factory=datetime.now)
    applied_at: datetime | None = Field(None)

    # For move operations
    new_path: str | None = Field(None)

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


class LegacyChangeSet(BaseModel):
    """Legacy set of changes for a plan (C0.files era)."""

    plan_id: int = Field(..., gt=0)
    changes: list[Change] = Field(
        default_factory=lambda: [],
    )
    created_at: datetime = Field(default_factory=datetime.now)

    @property
    def stats(self) -> dict[str, int]:
        """Get statistics about the changeset."""
        stats: dict[str, int] = {
            "total": len(self.changes),
            "creates": 0,
            "modifies": 0,
            "deletes": 0,
            "moves": 0,
            "applied": 0,
        }

        for change in self.changes:
            if change.operation == OperationType.CREATE:
                stats["creates"] += 1
            elif change.operation == OperationType.MODIFY:
                stats["modifies"] += 1
            elif change.operation == OperationType.DELETE:
                stats["deletes"] += 1
            elif change.operation == OperationType.MOVE:
                stats["moves"] += 1

            if change.applied:
                stats["applied"] += 1

        return stats

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


# Backward-compatible alias — existing code imports ``ChangeSet``
# from this module with ``plan_id: int``.
ChangeSet = LegacyChangeSet


# ------------------------------------------------------------------
# Spec-aligned ChangeSet domain model (D0.alpha)
# ------------------------------------------------------------------


def _new_ulid() -> str:
    """Generate a new ULID string."""
    return str(ULID())


class ChangeOperation(StrEnum):
    """Type of change operation recorded in a ChangeEntry."""

    CREATE = "create"
    MODIFY = "modify"
    DELETE = "delete"
    RENAME = "rename"


# Alias used by some implementation-plan references.
ChangeType = ChangeOperation


def normalize_change_path(path: str, repo_root: str = "") -> str:
    """Normalize a file path to a repo-relative POSIX path.

    Strips the *repo_root* prefix (if present) and converts to
    forward slashes.  The result never starts with ``/`` or ``./``.

    Note: ``..`` components are **not** resolved; callers should
    canonicalize paths before passing them in if ``..`` handling
    is required.
    """
    posix = PurePosixPath(path)
    if repo_root:
        root = PurePosixPath(repo_root)
        with contextlib.suppress(ValueError):
            posix = posix.relative_to(root)
    parts = [p for p in posix.parts if p not in (".", "/")]
    return "/".join(parts) if parts else "."


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

    Each entry captures exactly one mutation made by a tool during
    the Execute phase, including content hashes for deterministic
    diff and rollback.
    """

    entry_id: str = Field(
        default_factory=_new_ulid,
        description="ULID uniquely identifying this entry",
    )
    plan_id: str = Field(
        ...,
        description="ULID of the plan that owns this change",
    )
    resource_id: str = Field(
        ...,
        description="ULID of the resource affected",
    )
    tool_name: str = Field(
        ...,
        min_length=1,
        description="Namespaced tool name that caused the change",
    )
    operation: ChangeOperation = Field(
        ...,
        description="Type of change operation",
    )
    path: str = Field(
        ...,
        min_length=1,
        description="Repo-relative file path",
    )
    before_hash: str | None = Field(
        default=None,
        description="SHA-256 of file content before change",
    )
    after_hash: str | None = Field(
        default=None,
        description="SHA-256 of file content after change",
    )
    before_mode: int | None = Field(
        default=None,
        description="File mode before change",
    )
    after_mode: int | None = Field(
        default=None,
        description="File mode after change",
    )
    timestamp: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="UTC timestamp of the change",
    )

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

    @model_validator(mode="after")
    def _validate_hash_fields(self) -> "ChangeEntry":
        """Validate per-operation hash consistency.

        When hashes are provided, ensure they are consistent with
        the operation type:

        * CREATE must not have ``before_hash`` (no prior content).
        * DELETE must not have ``after_hash`` (no resulting content).

        Missing hashes are permitted for backward compatibility and
        for lightweight test construction.  MODIFY entries with
        identical ``before_hash``/``after_hash`` are allowed because
        mode-only changes produce the same content hash.
        """
        op = self.operation
        if op == ChangeOperation.CREATE and self.before_hash is not None:
            raise ValueError("CREATE entries must not include before_hash")
        if op == ChangeOperation.DELETE and self.after_hash is not None:
            raise ValueError("DELETE entries must not include after_hash")
        return self

    @property
    def has_integrity_hashes(self) -> bool:
        """Return whether this entry has full hash coverage.

        ``True`` when the hashes required for the operation type are
        all present:

        * CREATE: ``after_hash`` set
        * DELETE: ``before_hash`` set
        * MODIFY: both hashes set
        * RENAME: no hash requirement (always ``True``)
        """
        op = self.operation
        if op == ChangeOperation.CREATE:
            return self.after_hash is not None
        if op == ChangeOperation.DELETE:
            return self.before_hash is not None
        if op == ChangeOperation.MODIFY:
            return self.before_hash is not None and self.after_hash is not None
        return True


class SpecChangeSet(BaseModel):
    """Accumulated set of ChangeEntry records for a plan.

    This is the spec-aligned ChangeSet model introduced in D0.alpha.
    It uses ULID identifiers and provides computed summary properties.
    """

    changeset_id: str = Field(
        default_factory=_new_ulid,
        description="ULID uniquely identifying this changeset",
    )
    plan_id: str = Field(
        ...,
        description="ULID of the plan",
    )
    entries: list[ChangeEntry] = Field(
        default_factory=list,
        description="Ordered list of recorded change entries",
    )
    created_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="UTC timestamp when the changeset was created",
    )

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

    # -- computed summary properties ---------------------------------

    @property
    def creates(self) -> int:
        """Count of CREATE entries."""
        return sum(1 for e in self.entries if e.operation == ChangeOperation.CREATE)

    @property
    def modifies(self) -> int:
        """Count of MODIFY entries."""
        return sum(1 for e in self.entries if e.operation == ChangeOperation.MODIFY)

    @property
    def deletes(self) -> int:
        """Count of DELETE entries."""
        return sum(1 for e in self.entries if e.operation == ChangeOperation.DELETE)

    @property
    def renames(self) -> int:
        """Count of RENAME entries."""
        return sum(1 for e in self.entries if e.operation == ChangeOperation.RENAME)

    @property
    def paths_changed(self) -> set[str]:
        """Unique set of file paths affected."""
        return {e.path for e in self.entries}

    @property
    def resources_involved(self) -> set[str]:
        """Unique set of resource IDs involved."""
        return {e.resource_id for e in self.entries}

    def summary(self) -> dict[str, Any]:
        """Return summary counts as a dict (single pass)."""
        creates = modifies = deletes = renames = 0
        paths: set[str] = set()
        resources: set[str] = set()
        for e in self.entries:
            op = e.operation
            if op == ChangeOperation.CREATE:
                creates += 1
            elif op == ChangeOperation.MODIFY:
                modifies += 1
            elif op == ChangeOperation.DELETE:
                deletes += 1
            elif op == ChangeOperation.RENAME:
                renames += 1
            paths.add(e.path)
            resources.add(e.resource_id)
        return {
            "total": len(self.entries),
            "creates": creates,
            "modifies": modifies,
            "deletes": deletes,
            "renames": renames,
            "paths_changed": len(paths),
            "resources_involved": len(resources),
        }

    def sorted_entries(self) -> list[ChangeEntry]:
        """Return entries in deterministic order.

        Sort key: ``(resource_id, path, timestamp)``.
        """
        return sorted(
            self.entries,
            key=lambda e: (e.resource_id, e.path, e.timestamp),
        )

    def grouped_by_resource(self) -> dict[str, list[ChangeEntry]]:
        """Group entries by resource_id for plan diff output.

        Within each group, entries are sorted by ``(path, timestamp)``.
        """
        groups: dict[str, list[ChangeEntry]] = {}
        for entry in self.sorted_entries():
            groups.setdefault(entry.resource_id, []).append(entry)
        return groups

    def add_change(self, entry: ChangeEntry) -> None:
        """Append a change entry to this changeset.

        Raises ``ValueError`` if *entry.plan_id* does not match
        this changeset's *plan_id*.
        """
        if entry.plan_id != self.plan_id:
            raise ValueError(
                f"ChangeEntry plan_id '{entry.plan_id}' does not match "
                f"SpecChangeSet plan_id '{self.plan_id}'"
            )
        self.entries.append(entry)


# ------------------------------------------------------------------
# ToolInvocation and SkillInvocationTracker (C5.model)
# ------------------------------------------------------------------


class ToolInvocation(BaseModel):
    """Record of a single tool execution for auditability.

    Links a tool call to its plan, skill, arguments, result, and
    the ChangeEntry records it produced.
    """

    invocation_id: str = Field(
        default_factory=_new_ulid,
        description="ULID uniquely identifying this invocation",
    )
    plan_id: str = Field(
        ...,
        description="ULID of the plan that triggered this call",
    )
    tool_name: str = Field(
        ...,
        min_length=1,
        description="Namespaced tool name (namespace/short_name)",
    )
    skill_name: str | None = Field(
        default=None,
        description="Skill that provided the tool, if any",
    )
    arguments: dict[str, Any] = Field(
        default_factory=dict,
        description="Input parameters passed to the tool",
    )
    result: dict[str, Any] | None = Field(
        default=None,
        description="Output payload (None when failed before return)",
    )
    error: str | None = Field(
        default=None,
        description="Error message when the tool execution failed",
    )
    success: bool = Field(
        default=True,
        description="Whether execution completed without error",
    )
    duration_ms: float = Field(
        default=0.0,
        ge=0.0,
        description="Wall-clock execution time in milliseconds",
    )
    started_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="UTC timestamp when execution started",
    )
    completed_at: datetime | None = Field(
        default=None,
        description="UTC timestamp when execution finished",
    )
    change_ids: list[str] = Field(
        default_factory=list,
        description="ChangeEntry IDs produced by this invocation",
    )
    sequence_number: int = Field(
        default=0,
        ge=0,
        description="Deterministic ordering within a plan",
    )
    sandbox_path: str | None = Field(
        default=None,
        description="Sandbox root used during this invocation",
    )
    resource_refs: list[str] = Field(
        default_factory=list,
        description="Resource IDs involved in this invocation",
    )
    provider_metadata: dict[str, Any] | None = Field(
        default=None,
        description="Provider info (model name, latency, etc.)",
    )
    container_metadata: dict[str, Any] | None = Field(
        default=None,
        description=(
            "Container execution metadata when tool ran inside a container. "
            "Includes container_id, image, workspace_folder, exec_time_ms, "
            "exit_code, and timed_out."
        ),
    )

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


class InvocationTracker(Protocol):
    """Interface for tracking tool invocations."""

    def track(self, invocation: ToolInvocation) -> None:
        """Record a tool invocation."""
        ...

    def get_invocations(self, plan_id: str) -> list[ToolInvocation]:
        """Return all invocations for a plan."""
        ...

    def get_invocations_for_skill(
        self,
        plan_id: str,
        skill_name: str,
    ) -> list[ToolInvocation]:
        """Return invocations for a specific skill within a plan."""
        ...

    def get_changes(self, plan_id: str) -> list[str]:
        """Return all change IDs produced by invocations for a plan."""
        ...


class InMemoryInvocationTracker:
    """In-memory implementation of InvocationTracker.

    Suitable for tests and the M1 single-process runtime.
    Uses a ``defaultdict`` keyed by ``plan_id`` for O(k) per-plan
    lookups instead of O(N) full-list scans.
    """

    def __init__(self) -> None:
        self._by_plan: defaultdict[str, list[ToolInvocation]] = defaultdict(list)

    def track(self, invocation: ToolInvocation) -> None:
        """Record a tool invocation."""
        self._by_plan[invocation.plan_id].append(invocation)

    def get_invocations(self, plan_id: str) -> list[ToolInvocation]:
        """Return all invocations for a plan, ordered by sequence."""
        return sorted(
            self._by_plan[plan_id],
            key=lambda i: i.sequence_number,
        )

    def get_invocations_for_skill(
        self,
        plan_id: str,
        skill_name: str,
    ) -> list[ToolInvocation]:
        """Return invocations for a specific skill within a plan."""
        return sorted(
            [i for i in self._by_plan[plan_id] if i.skill_name == skill_name],
            key=lambda i: i.sequence_number,
        )

    def get_changes(self, plan_id: str) -> list[str]:
        """Return all change IDs produced by invocations for a plan."""
        change_ids: list[str] = []
        for inv in self.get_invocations(plan_id):
            change_ids.extend(inv.change_ids)
        return change_ids


# ------------------------------------------------------------------
# ChangeSetStore protocol and in-memory implementation
# ------------------------------------------------------------------


class ChangeSetStore(Protocol):
    """Interface for persisting and querying ChangeSet objects."""

    def start(self, plan_id: str) -> str:
        """Create a new empty ChangeSet for *plan_id*.

        Returns the changeset_id (ULID).
        """
        ...

    def record(self, changeset_id: str, entry: ChangeEntry) -> None:
        """Append *entry* to the changeset identified by
        *changeset_id*.
        """
        ...

    def get(self, changeset_id: str) -> SpecChangeSet | None:
        """Retrieve a ChangeSet by its ID, or ``None``."""
        ...

    def get_for_plan(self, plan_id: str) -> list[SpecChangeSet]:
        """Return all ChangeSets associated with *plan_id*."""
        ...

    def summarize(self, changeset_id: str) -> dict[str, Any]:
        """Return summary counts for a changeset."""
        ...


class InMemoryChangeSetStore:
    """In-memory ChangeSetStore for M1 milestone.

    Stores changeset data in a plain dict keyed by changeset_id.
    Not suitable for production — intended for tests and the M1
    single-process runtime.
    """

    def __init__(self) -> None:
        self._store: dict[str, SpecChangeSet] = {}

    def start(self, plan_id: str) -> str:
        """Create a new empty ChangeSet and return its ID."""
        cs = SpecChangeSet(plan_id=plan_id)
        self._store[cs.changeset_id] = cs
        return cs.changeset_id

    def record(self, changeset_id: str, entry: ChangeEntry) -> None:
        """Append an entry to the identified changeset."""
        cs = self._store.get(changeset_id)
        if cs is None:
            raise KeyError(f"ChangeSet '{changeset_id}' not found")
        cs.add_change(entry)

    def get(self, changeset_id: str) -> SpecChangeSet | None:
        """Return a changeset by ID or ``None``."""
        return self._store.get(changeset_id)

    def get_for_plan(self, plan_id: str) -> list[SpecChangeSet]:
        """Return all changesets for a plan."""
        return [cs for cs in self._store.values() if cs.plan_id == plan_id]

    def summarize(self, changeset_id: str) -> dict[str, Any]:
        """Return summary counts for a changeset."""
        cs = self._store.get(changeset_id)
        if cs is None:
            return {}
        return cs.summary()
