"""Diff review domain models for CleverAgents.

Builds reviewable diff artifacts from SpecChangeSet records,
grouping changes by resource and producing unified diff text
for CLI display in rich, plain, and JSON formats.
"""

from __future__ import annotations

import difflib
import json
from datetime import UTC, datetime
from difflib import SequenceMatcher
from typing import Any

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

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

DEFAULT_MAX_DIFF_SIZE: int = 50_000
"""Maximum total diff text characters before truncation."""

RENAME_SIMILARITY_THRESHOLD: float = 0.6
"""Minimum path similarity ratio for rename detection."""


def _new_ulid() -> str:
    return str(ULID())


def _is_binary(content: str | None) -> bool:
    """Detect binary content by checking for null bytes."""
    if content is None:
        return False
    return "\x00" in content


def _unified_diff(
    before: str | None,
    after: str | None,
    path: str,
    *,
    context_lines: int = 3,
) -> str:
    """Generate a unified diff string between before and after content."""
    before_lines = (before or "").splitlines(keepends=True)
    after_lines = (after or "").splitlines(keepends=True)
    diff_lines = difflib.unified_diff(
        before_lines,
        after_lines,
        fromfile=f"a/{path}",
        tofile=f"b/{path}",
        n=context_lines,
    )
    return "".join(diff_lines)


class DiffEntry(BaseModel):
    """A single diff entry for CLI review."""

    resource_id: str = Field(..., description="Resource ULID")
    resource_name: str = Field(
        default="",
        description="Human-readable resource name",
    )
    path: str = Field(..., min_length=1, description="File path")
    change_type: str = Field(
        ...,
        description="Operation type (create/modify/delete/rename)",
    )
    before_content: str | None = Field(
        default=None,
        description="Content before the change",
    )
    after_content: str | None = Field(
        default=None,
        description="Content after the change",
    )
    before_hash: str | None = Field(
        default=None,
        description="SHA-256 before",
    )
    after_hash: str | None = Field(
        default=None,
        description="SHA-256 after",
    )
    file_mode: int | None = Field(
        default=None,
        description="File permission mode",
    )
    is_binary: bool = Field(
        default=False,
        description="Whether the file is binary",
    )
    is_rename: bool = Field(
        default=False,
        description="Whether this is a rename/move",
    )
    rename_from: str | None = Field(
        default=None,
        description="Original path for renames",
    )
    rename_to: str | None = Field(
        default=None,
        description="Destination path for renames",
    )
    diff_text: str = Field(
        default="",
        description="Unified diff text",
    )
    truncated: bool = Field(
        default=False,
        description="Whether the diff text was truncated",
    )

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


class ResourceDiffGroup(BaseModel):
    """Group of diff entries for a single resource."""

    resource_id: str = Field(..., description="Resource ULID")
    resource_name: str = Field(
        default="",
        description="Human-readable resource name",
    )
    entries: list[DiffEntry] = Field(
        default_factory=list,
        description="Diff entries for this resource",
    )

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

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

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

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

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


class ReviewArtifact(BaseModel):
    """Complete diff review artifact for a plan."""

    artifact_id: str = Field(
        default_factory=_new_ulid,
        description="ULID for this review artifact",
    )
    plan_id: str = Field(..., description="Plan ULID")
    changeset_id: str = Field(..., description="ChangeSet ULID")
    created_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="When this artifact was created",
    )
    groups: list[ResourceDiffGroup] = Field(
        default_factory=list,
        description="Diff groups by resource",
    )
    total_changes: int = Field(
        default=0,
        description="Total number of change entries",
    )
    truncated: bool = Field(
        default=False,
        description="Whether any diff was truncated",
    )
    max_diff_size: int = Field(
        default=DEFAULT_MAX_DIFF_SIZE,
        description="Max diff text chars before truncation",
    )

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


class DiffBuilder:
    """Builds a ReviewArtifact from a SpecChangeSet.

    Takes optional content maps (``before_contents`` and
    ``after_contents``) keyed by file path to generate unified
    diffs.  A ``resource_names`` map provides human-readable names
    for resource IDs.
    """

    def __init__(
        self,
        *,
        max_diff_size: int = DEFAULT_MAX_DIFF_SIZE,
        context_lines: int = 3,
    ) -> None:
        self._max_diff_size = max_diff_size
        self._context_lines = context_lines

    def build(
        self,
        changeset: SpecChangeSet,
        *,
        before_contents: dict[str, str] | None = None,
        after_contents: dict[str, str] | None = None,
        resource_names: dict[str, str] | None = None,
    ) -> ReviewArtifact:
        """Build a ReviewArtifact from a changeset."""
        before_map = before_contents or {}
        after_map = after_contents or {}
        name_map = resource_names or {}

        grouped = changeset.grouped_by_resource()
        total_diff_size = 0
        artifact_truncated = False
        groups: list[ResourceDiffGroup] = []

        # Detect renames across all entries
        rename_pairs = self._detect_renames(
            changeset.entries,
            before_map,
            after_map,
        )
        consumed_entries: set[str] = set()
        for del_id, cre_id in rename_pairs:
            consumed_entries.add(del_id)
            consumed_entries.add(cre_id)

        for resource_id in sorted(grouped.keys()):
            entries_for_resource = grouped[resource_id]
            res_name = name_map.get(resource_id, resource_id)
            diff_entries: list[DiffEntry] = []

            for entry in entries_for_resource:
                if entry.entry_id in consumed_entries:
                    # Skip individually consumed rename entries
                    continue

                before = before_map.get(entry.path)
                after = after_map.get(entry.path)
                binary = _is_binary(before) or _is_binary(after)

                diff_text = ""
                if binary:
                    diff_text = "[binary file]"
                else:
                    diff_text = _unified_diff(
                        before,
                        after,
                        entry.path,
                        context_lines=self._context_lines,
                    )

                entry_truncated = False
                remaining = self._max_diff_size - total_diff_size
                if remaining <= 0:
                    diff_text = "[truncated]"
                    entry_truncated = True
                    artifact_truncated = True
                elif len(diff_text) > remaining:
                    diff_text = diff_text[:remaining] + "\n[truncated]"
                    entry_truncated = True
                    artifact_truncated = True

                total_diff_size += len(diff_text)

                diff_entries.append(
                    DiffEntry(
                        resource_id=resource_id,
                        resource_name=res_name,
                        path=entry.path,
                        change_type=entry.operation,
                        before_content=before,
                        after_content=after,
                        before_hash=entry.before_hash,
                        after_hash=entry.after_hash,
                        file_mode=entry.after_mode or entry.before_mode,
                        is_binary=binary,
                        is_rename=False,
                        diff_text=diff_text,
                        truncated=entry_truncated,
                    )
                )

            # Add rename entries
            for del_id, cre_id in rename_pairs:
                del_entry = self._find_entry(entries_for_resource, del_id)
                cre_entry = self._find_entry(entries_for_resource, cre_id)
                if del_entry is None or cre_entry is None:
                    continue

                after = after_map.get(cre_entry.path)
                binary = _is_binary(after)
                diff_text = f"renamed: {del_entry.path} -> {cre_entry.path}"

                entry_truncated = False
                remaining = self._max_diff_size - total_diff_size
                if remaining <= 0:
                    diff_text = "[truncated]"
                    entry_truncated = True
                    artifact_truncated = True
                elif len(diff_text) > remaining:
                    diff_text = diff_text[:remaining] + "\n[truncated]"
                    entry_truncated = True
                    artifact_truncated = True

                total_diff_size += len(diff_text)

                diff_entries.append(
                    DiffEntry(
                        resource_id=resource_id,
                        resource_name=res_name,
                        path=cre_entry.path,
                        change_type="rename",
                        before_content=None,
                        after_content=after,
                        before_hash=del_entry.before_hash,
                        after_hash=cre_entry.after_hash,
                        file_mode=cre_entry.after_mode,
                        is_binary=binary,
                        is_rename=True,
                        rename_from=del_entry.path,
                        rename_to=cre_entry.path,
                        diff_text=diff_text,
                        truncated=entry_truncated,
                    )
                )

            # Sort entries by (path, change_type)
            diff_entries.sort(key=lambda e: (e.path, e.change_type))

            groups.append(
                ResourceDiffGroup(
                    resource_id=resource_id,
                    resource_name=res_name,
                    entries=diff_entries,
                )
            )

        return ReviewArtifact(
            plan_id=changeset.plan_id,
            changeset_id=changeset.changeset_id,
            groups=groups,
            total_changes=len(changeset.entries),
            truncated=artifact_truncated,
            max_diff_size=self._max_diff_size,
        )

    def _detect_renames(
        self,
        entries: list[ChangeEntry],
        before_map: dict[str, str],
        after_map: dict[str, str],
    ) -> list[tuple[str, str]]:
        """Detect rename pairs using path similarity + content hash."""
        deletes = [e for e in entries if e.operation == ChangeOperation.DELETE]
        creates = [e for e in entries if e.operation == ChangeOperation.CREATE]

        pairs: list[tuple[str, str]] = []
        used_creates: set[str] = set()

        for del_entry in deletes:
            best_ratio = 0.0
            best_create: ChangeEntry | None = None

            for cre_entry in creates:
                if cre_entry.entry_id in used_creates:
                    continue
                if cre_entry.resource_id != del_entry.resource_id:
                    continue

                # Check content hash match
                del_hash = del_entry.before_hash
                cre_hash = cre_entry.after_hash
                if del_hash is None or cre_hash is None:
                    continue
                if del_hash != cre_hash:
                    continue

                ratio = SequenceMatcher(
                    None,
                    del_entry.path,
                    cre_entry.path,
                ).ratio()
                if ratio > best_ratio:
                    best_ratio = ratio
                    best_create = cre_entry

            if best_create is not None and best_ratio >= RENAME_SIMILARITY_THRESHOLD:
                pairs.append((del_entry.entry_id, best_create.entry_id))
                used_creates.add(best_create.entry_id)

        return pairs

    @staticmethod
    def _find_entry(
        entries: list[ChangeEntry],
        entry_id: str,
    ) -> ChangeEntry | None:
        """Find a ChangeEntry by ID in a list."""
        for e in entries:
            if e.entry_id == entry_id:
                return e
        return None


class DiffSerializer:
    """Serialize a ReviewArtifact to various output formats."""

    @staticmethod
    def to_plain(artifact: ReviewArtifact) -> str:
        """Render as plain text."""
        lines: list[str] = []
        lines.append(
            f"Review: plan={artifact.plan_id} changeset={artifact.changeset_id}"
        )
        lines.append(f"Total changes: {artifact.total_changes}")
        if artifact.truncated:
            lines.append("[some diffs truncated]")
        lines.append("")

        for group in artifact.groups:
            lines.append(f"Resource: {group.resource_name} ({group.resource_id})")
            lines.append(
                f"  +{group.added} ~{group.modified} -{group.deleted} r{group.renamed}"
            )
            for entry in group.entries:
                lines.append(f"  --- {entry.path} [{entry.change_type}]")
                if entry.is_binary:
                    lines.append("  [binary file]")
                elif entry.diff_text:
                    for diff_line in entry.diff_text.splitlines():
                        lines.append(f"  {diff_line}")
            lines.append("")

        return "\n".join(lines)

    @staticmethod
    def to_json(artifact: ReviewArtifact) -> str:
        """Render as JSON string."""
        data: dict[str, Any] = {
            "artifact_id": artifact.artifact_id,
            "plan_id": artifact.plan_id,
            "changeset_id": artifact.changeset_id,
            "created_at": artifact.created_at.isoformat(),
            "total_changes": artifact.total_changes,
            "truncated": artifact.truncated,
            "groups": [],
        }
        for group in artifact.groups:
            group_data: dict[str, Any] = {
                "resource_id": group.resource_id,
                "resource_name": group.resource_name,
                "added": group.added,
                "modified": group.modified,
                "deleted": group.deleted,
                "renamed": group.renamed,
                "entries": [],
            }
            for entry in group.entries:
                entry_data: dict[str, Any] = {
                    "path": entry.path,
                    "change_type": entry.change_type,
                    "is_binary": entry.is_binary,
                    "is_rename": entry.is_rename,
                    "truncated": entry.truncated,
                    "diff_text": entry.diff_text,
                }
                if entry.before_hash:
                    entry_data["before_hash"] = entry.before_hash
                if entry.after_hash:
                    entry_data["after_hash"] = entry.after_hash
                if entry.file_mode is not None:
                    entry_data["file_mode"] = entry.file_mode
                if entry.rename_from:
                    entry_data["rename_from"] = entry.rename_from
                if entry.rename_to:
                    entry_data["rename_to"] = entry.rename_to
                group_data["entries"].append(entry_data)
            data["groups"].append(group_data)

        return json.dumps(data, indent=2)

    @staticmethod
    def to_rich(artifact: ReviewArtifact) -> str:
        """Render as rich-formatted text with color markers."""
        lines: list[str] = []
        lines.append(f"[bold]Review[/bold]: plan={artifact.plan_id}")
        lines.append(f"Changes: {artifact.total_changes}")
        if artifact.truncated:
            lines.append("[yellow]Some diffs truncated[/yellow]")
        lines.append("")

        for group in artifact.groups:
            lines.append(f"[cyan]{group.resource_name}[/cyan] ({group.resource_id})")
            lines.append(
                f"  [green]+{group.added}[/green] "
                f"[yellow]~{group.modified}[/yellow] "
                f"[red]-{group.deleted}[/red] "
                f"r{group.renamed}"
            )
            for entry in group.entries:
                if entry.is_binary:
                    lines.append(f"  {entry.path} [dim][binary][/dim]")
                else:
                    lines.append(f"  {entry.path} [{entry.change_type}]")
                    if entry.diff_text:
                        for dl in entry.diff_text.splitlines():
                            if dl.startswith("+"):
                                lines.append(f"  [green]{dl}[/green]")
                            elif dl.startswith("-"):
                                lines.append(f"  [red]{dl}[/red]")
                            else:
                                lines.append(f"  {dl}")
            lines.append("")

        return "\n".join(lines)
