"""Delta context buffer: watch code2llm output, send only diffs.

The buffer maintains a hash-indexed store of previously sent file snapshots.
When the context is requested (via API or watch mode), it computes a unified
diff against the last-sent version and sends only the changed portions.

Integration with code2llm:
  code2llm generates project analysis files (YAML, JSON, Markdown) in a
  ``project/`` directory.  This module watches that directory and, on each
  change, computes a compact delta payload that fits within the LLM context
  window.

Wire format (sent as system message or context block):
  ```
  <context_delta version="3" files_changed="2" files_total="12">
  --- src/auth/service.py (modified, +15 -3)
  @@ -42,6 +42,18 @@
  +    async def validate_token(self, token: str) -> bool:
  +        ...
  --- src/auth/models.py (new file)
  +from pydantic import BaseModel
  +class TokenPayload(BaseModel):
  +    ...
  </context_delta>
  ```
"""

from __future__ import annotations

import hashlib
import os
import time
from dataclasses import dataclass, field
from difflib import unified_diff
from pathlib import Path

import structlog
import xxhash

logger = structlog.get_logger()


@dataclass
class FileSnapshot:
    """Immutable snapshot of a single file."""

    path: str
    content: str
    hash: str
    size: int
    mtime: float

    @staticmethod
    def from_path(path: Path) -> FileSnapshot | None:
        try:
            content = path.read_text(encoding="utf-8", errors="replace")
            return FileSnapshot(
                path=str(path),
                content=content,
                hash=xxhash.xxh64(content.encode()).hexdigest(),
                size=len(content),
                mtime=path.stat().st_mtime,
            )
        except (OSError, PermissionError) as e:
            logger.warning("snapshot_failed", path=str(path), error=str(e))
            return None


@dataclass
class DeltaPayload:
    """Computed delta between two context snapshots."""

    version: int
    files_changed: int
    files_added: int
    files_removed: int
    files_total: int
    diff_text: str
    full_context: str   # fallback: full context if delta too large
    token_estimate: int
    is_full_refresh: bool = False
    timestamp: float = field(default_factory=time.time)

    @property
    def payload(self) -> str:
        """Return the payload to inject into the LLM context."""
        if self.is_full_refresh:
            return (
                f'<context version="{self.version}" files="{self.files_total}" '
                f'type="full">\n{self.full_context}\n</context>'
            )
        return (
            f'<context_delta version="{self.version}" '
            f'files_changed="{self.files_changed}" '
            f'files_added="{self.files_added}" '
            f'files_removed="{self.files_removed}" '
            f'files_total="{self.files_total}">\n'
            f'{self.diff_text}\n'
            f'</context_delta>'
        )


class DeltaBuffer:
    """Maintains file snapshots and computes minimal diffs.

    Parameters
    ----------
    watch_dir : str
        Directory containing code2llm output files.
    max_context_tokens : int
        Maximum tokens to include in context.
    delta_threshold : float
        If more than this fraction of files changed, send full context.
    extensions : set[str]
        File extensions to watch.
    """

    TOKENS_PER_CHAR = 0.25  # rough estimate for code

    def __init__(
        self,
        watch_dir: str = "./project",
        max_context_tokens: int = 120_000,
        delta_threshold: float = 0.15,
        extensions: set[str] | None = None,
    ):
        self.watch_dir = Path(watch_dir)
        self.max_context_tokens = max_context_tokens
        self.delta_threshold = delta_threshold
        self.extensions = extensions or {
            ".py", ".ts", ".js", ".tsx", ".jsx", ".rs", ".go",
            ".java", ".yaml", ".yml", ".json", ".md", ".toml",
            ".sql", ".sh", ".css", ".html", ".xml",
        }
        self._snapshots: dict[str, FileSnapshot] = {}
        self._version: int = 0
        self._initialized: bool = False

    def scan(self) -> dict[str, FileSnapshot]:
        """Scan watch_dir and return current file snapshots."""
        current: dict[str, FileSnapshot] = {}
        if not self.watch_dir.exists():
            return current

        for path in self.watch_dir.rglob("*"):
            if not path.is_file():
                continue
            if path.suffix not in self.extensions:
                continue
            # Skip hidden dirs and common noise
            parts = path.relative_to(self.watch_dir).parts
            if any(p.startswith(".") or p in ("node_modules", "__pycache__", ".git", "venv") for p in parts):
                continue

            snap = FileSnapshot.from_path(path)
            if snap:
                rel_path = str(path.relative_to(self.watch_dir))
                current[rel_path] = snap

        return current

    def compute_delta(self) -> DeltaPayload:
        """Compare current files against last-sent snapshots, return delta."""
        current = self.scan()
        self._version += 1

        if not self._initialized:
            self._initialized = True
            self._snapshots = current
            return self._make_full_payload(current, 0, len(current), 0)

        added, removed, modified = self._classify_changes(self._snapshots, current)
        total_changes = len(added) + len(removed) + len(modified)
        all_count = max(len(set(self._snapshots) | set(current)), 1)

        # Too many changes or no changes → full refresh
        if total_changes / all_count > self.delta_threshold or total_changes == 0:
            self._snapshots = current
            return self._make_full_payload(current, len(modified), len(added), len(removed))

        # Build compact diff
        diff_output = self._build_diff_text(added, removed, modified,
                                            self._snapshots, current)

        # Token overflow → fall back to full refresh
        diff_tokens = self._estimate_tokens(diff_output)
        if diff_tokens > self.max_context_tokens:
            self._snapshots = current
            return self._make_full_payload(current, len(modified), len(added), len(removed))

        self._snapshots = current

        logger.info(
            "delta_computed",
            version=self._version,
            added=len(added),
            removed=len(removed),
            modified=len(modified),
            diff_tokens=diff_tokens,
        )

        return DeltaPayload(
            version=self._version,
            files_changed=len(modified),
            files_added=len(added),
            files_removed=len(removed),
            files_total=len(current),
            diff_text=diff_output,
            full_context=self._build_full_context(current),
            token_estimate=diff_tokens,
        )

    def _make_full_payload(
        self,
        current: dict[str, FileSnapshot],
        modified: int,
        added: int,
        removed: int,
    ) -> DeltaPayload:
        """Build a full-refresh DeltaPayload."""
        full_text = self._build_full_context(current)
        return DeltaPayload(
            version=self._version,
            files_changed=modified,
            files_added=added,
            files_removed=removed,
            files_total=len(current),
            diff_text="",
            full_context=full_text,
            token_estimate=self._estimate_tokens(full_text),
            is_full_refresh=True,
        )

    @staticmethod
    def _classify_changes(
        previous: dict[str, FileSnapshot],
        current: dict[str, FileSnapshot],
    ) -> tuple[list[str], list[str], list[str]]:
        """Classify paths into added, removed, modified."""
        added, removed, modified = [], [], []
        for path in sorted(set(previous) | set(current)):
            prev = previous.get(path)
            curr = current.get(path)
            if prev is None and curr is not None:
                added.append(path)
            elif prev is not None and curr is None:
                removed.append(path)
            elif prev is not None and curr is not None and prev.hash != curr.hash:
                modified.append(path)
        return added, removed, modified

    @staticmethod
    def _build_diff_text(
        added: list[str],
        removed: list[str],
        modified: list[str],
        previous: dict[str, FileSnapshot],
        current: dict[str, FileSnapshot],
    ) -> str:
        """Build unified diff output from classified changes."""
        diff_parts: list[str] = []

        for path in removed:
            diff_parts.append(f"--- {path} (removed)")

        for path in added:
            snap = current[path]
            lines = snap.content.splitlines()
            preview = "\n".join(f"+{line}" for line in lines[:50])
            if len(lines) > 50:
                preview += f"\n... (+{len(lines) - 50} more lines)"
            diff_parts.append(f"--- {path} (new file, {len(lines)} lines)\n{preview}")

        for path in modified:
            prev_lines = previous[path].content.splitlines(keepends=True)
            curr_lines = current[path].content.splitlines(keepends=True)
            diff = list(unified_diff(
                prev_lines, curr_lines,
                fromfile=f"a/{path}", tofile=f"b/{path}",
                n=3,
            ))
            if diff:
                diff_text = "".join(diff)
                if len(diff_text) > 5000:
                    diff_text = diff_text[:5000] + "\n... (diff truncated)"
                diff_parts.append(diff_text)

        return "\n".join(diff_parts)

    def _build_full_context(self, snapshots: dict[str, FileSnapshot]) -> str:
        """Build full context string, truncated to fit token budget."""
        parts: list[str] = []
        remaining_chars = int(self.max_context_tokens / self.TOKENS_PER_CHAR)

        # Sort by importance: .py > .ts > .yaml > others
        priority = {".py": 0, ".ts": 1, ".js": 2, ".yaml": 3, ".yml": 3, ".md": 4}
        sorted_paths = sorted(
            snapshots.keys(),
            key=lambda p: (priority.get(Path(p).suffix, 9), p),
        )

        for path in sorted_paths:
            snap = snapshots[path]
            header = f"=== {path} ({snap.size} bytes) ===\n"
            entry = header + snap.content + "\n"

            if len(entry) > remaining_chars:
                # Truncate this file
                available = remaining_chars - len(header) - 30
                if available > 200:
                    entry = header + snap.content[:available] + "\n... (truncated)\n"
                    parts.append(entry)
                break

            parts.append(entry)
            remaining_chars -= len(entry)

        return "".join(parts)

    def _estimate_tokens(self, text: str) -> int:
        return int(len(text) * self.TOKENS_PER_CHAR)

    def get_stats(self) -> dict:
        return {
            "version": self._version,
            "files_tracked": len(self._snapshots),
            "total_chars": sum(s.size for s in self._snapshots.values()),
            "estimated_tokens": sum(
                self._estimate_tokens(s.content) for s in self._snapshots.values()
            ),
            "watch_dir": str(self.watch_dir),
            "initialized": self._initialized,
        }
