"""Diff tracking and git snapshot undo for the LangGraph agent.

Provides:
- TurnDiffTracker: captures file baselines before modifications,
  generates unified diffs of all changes in a turn.
- SnapshotManager: creates ghost commits on detached refs for undo.
  Uses git plumbing commands (write-tree, commit-tree, update-ref) to
  avoid Bus errors on corrupted repos.

Ported from Codex CLI's turn_diff_tracker.rs and ghost_snapshot.rs.
"""

from __future__ import annotations

import asyncio
import difflib
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

SNAPSHOT_REF_PREFIX = "refs/hikaflow/snapshots/"


@dataclass
class FileBaseline:
    """Snapshot of a file's content before modification."""
    path: Path
    content: Optional[str]   # None = file didn't exist (creation)
    encoding: str = "utf-8"


@dataclass
class FileChange:
    """Summary of a single file change."""
    path: str
    change_type: str   # "created" | "modified" | "deleted"
    additions: int = 0
    deletions: int = 0


class TurnDiffTracker:
    """Track file changes within a single agent turn.

    Call snapshot() before each write/edit to capture the baseline.
    Call get_unified_diff() after the turn to see all changes.
    """

    def __init__(self, project_path: str = "") -> None:
        self._baselines: dict[str, FileBaseline] = {}
        self._project_path = project_path

    def snapshot(self, path) -> None:
        """Capture file content before modification. Only captures once per file per turn."""
        path = Path(path) if not isinstance(path, Path) else path
        key = str(path)
        if key in self._baselines:
            return  # Already captured
        try:
            if path.exists():
                content = path.read_text(encoding="utf-8", errors="replace")
            else:
                content = None
            self._baselines[key] = FileBaseline(path=path, content=content)
        except Exception as e:
            logger.debug("Failed to snapshot %s: %s", path, e)

    def get_unified_diff(self) -> str:
        """Generate unified diff of all file changes this turn."""
        diffs = []
        for key, baseline in self._baselines.items():
            path = baseline.path
            try:
                if path.exists():
                    current = path.read_text(encoding="utf-8", errors="replace")
                else:
                    current = None
            except Exception:
                current = None

            old = baseline.content or ""
            new = current or ""

            if old == new:
                continue

            old_lines = old.splitlines(keepends=True)
            new_lines = new.splitlines(keepends=True)

            rel_path = key
            diff = difflib.unified_diff(
                old_lines, new_lines,
                fromfile=f"a/{rel_path}",
                tofile=f"b/{rel_path}",
                lineterm="",
            )
            diff_text = "\n".join(diff)
            if diff_text:
                diffs.append(diff_text)

        return "\n".join(diffs) if diffs else "(no changes)"

    def get_file_diff(self, path) -> str:
        """Get unified diff for a single file."""
        path = Path(path) if not isinstance(path, Path) else path
        key = str(path)
        baseline = self._baselines.get(key)
        if not baseline:
            return "(no baseline captured)"

        old = baseline.content or ""
        try:
            new = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
        except Exception:
            new = ""

        if old == new:
            return "(no changes)"

        diff = difflib.unified_diff(
            old.splitlines(keepends=True),
            new.splitlines(keepends=True),
            fromfile=f"a/{key}",
            tofile=f"b/{key}",
            lineterm="",
        )
        return "\n".join(diff)

    def get_file_changes(self) -> list[FileChange]:
        """Get structured change list for all modified files."""
        changes = []
        for key, baseline in self._baselines.items():
            path = baseline.path
            try:
                current = path.read_text(encoding="utf-8", errors="replace") if path.exists() else None
            except Exception:
                current = None

            old = baseline.content
            if old == current:
                continue

            if old is None and current is not None:
                change_type = "created"
            elif old is not None and current is None:
                change_type = "deleted"
            else:
                change_type = "modified"

            old_lines = (old or "").splitlines()
            new_lines = (current or "").splitlines()
            additions = max(0, len(new_lines) - len(old_lines))
            deletions = max(0, len(old_lines) - len(new_lines))

            changes.append(FileChange(
                path=key,
                change_type=change_type,
                additions=additions,
                deletions=deletions,
            ))
        return changes

    def reset(self) -> None:
        """Clear baselines for next turn."""
        self._baselines.clear()


@dataclass
class GhostSnapshot:
    """A ghost commit snapshot for undo."""
    commit_sha: str
    timestamp: float
    files_changed: list[str]
    message: str


class SnapshotManager:
    """Manage git-based undo snapshots using plumbing commands.

    Creates ghost commits on detached refs (refs/hikaflow/snapshots/*)
    that don't pollute branch history but can be used to restore state.
    """

    def __init__(self, project_path: Path) -> None:
        self._project_path = project_path
        self._snapshots: list[GhostSnapshot] = []

    async def _run_git(self, *args: str) -> tuple[int, str]:
        """Run a git command and return (returncode, stdout)."""
        proc = await asyncio.create_subprocess_exec(
            "git", *args,
            cwd=str(self._project_path),
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        return proc.returncode, stdout.decode("utf-8", errors="replace").strip()

    async def _is_git_repo(self) -> bool:
        """Check if project is a git repo."""
        code, _ = await self._run_git("rev-parse", "--git-dir")
        return code == 0

    async def create_snapshot(self, message: str) -> Optional[GhostSnapshot]:
        """Create a ghost commit on a detached ref.

        Uses plumbing commands to avoid Bus errors:
        1. git add -A
        2. git write-tree
        3. git commit-tree (creates commit object without updating any branch)
        4. git update-ref refs/hikaflow/snapshots/{N} {sha}
        """
        if not await self._is_git_repo():
            return None

        # Check for changes
        code, status = await self._run_git("status", "--porcelain")
        if code != 0 or not status.strip():
            return None  # No changes

        files_changed = [
            line[3:].strip() for line in status.splitlines()
            if line.strip()
        ]

        try:
            # Stage all changes
            await self._run_git("add", "-A")

            # Create tree object from staging area
            code, tree_sha = await self._run_git("write-tree")
            if code != 0 or not tree_sha:
                return None

            # Get current HEAD for parent (if exists)
            code, head_sha = await self._run_git("rev-parse", "HEAD")
            parent_args = ["-p", head_sha] if code == 0 and head_sha else []

            # Create commit object
            code, commit_sha = await self._run_git(
                "commit-tree", tree_sha,
                *parent_args,
                "-m", f"hikaflow snapshot: {message}",
            )
            if code != 0 or not commit_sha:
                return None

            # Store ref
            turn_num = len(self._snapshots)
            ref = f"{SNAPSHOT_REF_PREFIX}{turn_num}"
            code, _ = await self._run_git("update-ref", ref, commit_sha)
            if code != 0:
                return None

            # Reset staging area to avoid leaving things staged
            await self._run_git("reset", "HEAD")

            snapshot = GhostSnapshot(
                commit_sha=commit_sha,
                timestamp=time.time(),
                files_changed=files_changed,
                message=message,
            )
            self._snapshots.append(snapshot)
            logger.info("Created snapshot %s: %s", commit_sha[:8], message)
            return snapshot

        except Exception as e:
            logger.warning("Failed to create snapshot: %s", e)
            return None

    async def restore_latest(self) -> Optional[GhostSnapshot]:
        """Restore from the most recent snapshot."""
        if not self._snapshots:
            return None

        snapshot = self._snapshots.pop()
        try:
            code, _ = await self._run_git(
                "checkout", snapshot.commit_sha, "--", ".",
            )
            if code != 0:
                logger.warning("Failed to restore snapshot %s", snapshot.commit_sha[:8])
                self._snapshots.append(snapshot)  # Put it back
                return None

            logger.info("Restored snapshot %s", snapshot.commit_sha[:8])
            return snapshot

        except Exception as e:
            logger.warning("Failed to restore snapshot: %s", e)
            self._snapshots.append(snapshot)
            return None

    def list_snapshots(self) -> list[GhostSnapshot]:
        """Return snapshot stack (newest first)."""
        return list(reversed(self._snapshots))
