"""File change aggregation and conflict resolution for multi-tool execution.

When multiple tools (Claude, OpenCode) run concurrently on the same codebase,
they may produce overlapping file edits. This module detects, reports, and
resolves these conflicts.

Example:
    aggregator = FileChangeAggregator(working_dir=Path.cwd())

    # Track changes from each tool
    aggregator.capture_before()  # Snapshot before execution
    # ... tool1 executes ...
    aggregator.capture_after("claude")
    aggregator.capture_before()
    # ... tool2 executes ...
    aggregator.capture_after("opencode")

    # Analyze and resolve conflicts
    result = aggregator.resolve(strategy=MergeStrategy.PREFER_FIRST)
"""

from __future__ import annotations

import difflib
import hashlib
import logging
import re
import subprocess
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any


logger = logging.getLogger(__name__)


class MergeStrategy(Enum):
    """Strategy for resolving file conflicts between tools."""

    PREFER_FIRST = "prefer_first"  # Keep first tool's changes
    PREFER_LAST = "prefer_last"  # Keep last tool's changes
    UNION = "union"  # Keep all non-conflicting changes, mark conflicts
    INTERACTIVE = "interactive"  # Prompt user for each conflict
    SMART_MERGE = "smart_merge"  # Attempt semantic merge based on change type
    ABORT = "abort"  # Abort on any conflict


class ConflictType(Enum):
    """Type of conflict between file changes."""

    NONE = "none"  # No conflict
    OVERLAPPING_LINES = "overlapping_lines"  # Both modified same lines
    DIVERGENT_LOGIC = "divergent_logic"  # Different approaches to same problem
    STRUCTURAL = "structural"  # Incompatible structural changes
    DELETE_MODIFY = "delete_modify"  # One deleted, other modified


@dataclass
class FileSnapshot:
    """Snapshot of a file's state."""

    path: Path
    content: str
    hash: str
    exists: bool
    mtime: float = 0.0

    @classmethod
    def capture(cls, path: Path) -> FileSnapshot:
        """Capture current state of a file."""
        if not path.exists():
            return cls(
                path=path,
                content="",
                hash="",
                exists=False,
            )

        content = path.read_text(encoding="utf-8", errors="replace")
        file_hash = hashlib.sha256(content.encode()).hexdigest()[:16]

        return cls(
            path=path,
            content=content,
            hash=file_hash,
            exists=True,
            mtime=path.stat().st_mtime,
        )


@dataclass
class FileChange:
    """A change made to a file by a tool."""

    tool: str
    path: Path
    before: FileSnapshot
    after: FileSnapshot
    diff_lines: list[str] = field(default_factory=list)
    change_type: str = "modified"  # created, modified, deleted

    @property
    def is_creation(self) -> bool:
        return not self.before.exists and self.after.exists

    @property
    def is_deletion(self) -> bool:
        return self.before.exists and not self.after.exists

    @property
    def is_modification(self) -> bool:
        return self.before.exists and self.after.exists and self.before.hash != self.after.hash

    @property
    def has_changes(self) -> bool:
        return self.before.hash != self.after.hash

    def get_diff(self) -> str:
        """Get unified diff of the change."""
        if not self.diff_lines:
            before_lines = self.before.content.splitlines(keepends=True)
            after_lines = self.after.content.splitlines(keepends=True)
            self.diff_lines = list(
                difflib.unified_diff(
                    before_lines,
                    after_lines,
                    fromfile=f"{self.path} (before {self.tool})",
                    tofile=f"{self.path} (after {self.tool})",
                    n=3,
                )
            )
        return "".join(self.diff_lines)


@dataclass
class FileConflict:
    """A conflict between changes from different tools."""

    path: Path
    conflict_type: ConflictType
    changes: list[FileChange]
    description: str
    resolution: str | None = None
    resolved_content: str | None = None

    def get_conflict_markers(self) -> str:
        """Generate git-style conflict markers."""
        if len(self.changes) < 2:
            return ""

        lines = []
        lines.append(f"<<<<<<< {self.changes[0].tool}")
        lines.append(self.changes[0].after.content)
        lines.append("=======")
        lines.append(self.changes[1].after.content)
        lines.append(f">>>>>>> {self.changes[1].tool}")
        return "\n".join(lines)


@dataclass
class AggregationResult:
    """Result of file change aggregation."""

    success: bool
    strategy_used: MergeStrategy
    files_changed: list[Path]
    conflicts: list[FileConflict]
    merged_changes: dict[Path, str]  # path -> resolved content
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def has_conflicts(self) -> bool:
        return len(self.conflicts) > 0

    @property
    def unresolved_conflicts(self) -> list[FileConflict]:
        return [c for c in self.conflicts if c.resolved_content is None]


class FileChangeAggregator:
    """Aggregates and resolves file changes from multiple tools.

    Tracks file states before and after each tool execution, detects conflicts,
    and provides resolution strategies.
    """

    def __init__(
        self,
        working_dir: Path | None = None,
        track_patterns: list[str] | None = None,
        ignore_patterns: list[str] | None = None,
    ) -> None:
        """Initialize aggregator.

        Args:
            working_dir: Working directory to track (default: cwd)
            track_patterns: Glob patterns to track (default: common code files)
            ignore_patterns: Glob patterns to ignore (default: node_modules, .git, etc.)
        """
        self.working_dir = working_dir or Path.cwd()
        self.track_patterns = track_patterns or [
            "**/*.py",
            "**/*.js",
            "**/*.ts",
            "**/*.tsx",
            "**/*.json",
            "**/*.yaml",
            "**/*.yml",
            "**/*.md",
            "**/*.html",
            "**/*.css",
            "**/*.scss",
        ]
        self.ignore_patterns = ignore_patterns or [
            "**/node_modules/**",
            "**/.git/**",
            "**/__pycache__/**",
            "**/.venv/**",
            "**/venv/**",
            "**/.env/**",
            "**/dist/**",
            "**/build/**",
            "**/*.pyc",
        ]

        self._snapshots: dict[str, dict[Path, FileSnapshot]] = {}
        self._changes: dict[str, list[FileChange]] = {}
        self._current_snapshot: dict[Path, FileSnapshot] = {}

    def _should_track(self, path: Path) -> bool:
        """Check if a path should be tracked."""
        rel_path = str(path.relative_to(self.working_dir))

        # Check ignore patterns first
        for pattern in self.ignore_patterns:
            if path.match(pattern):
                return False

        # Check track patterns
        for pattern in self.track_patterns:
            if path.match(pattern):
                return True

        return False

    def _get_tracked_files(self) -> list[Path]:
        """Get list of all tracked files in working directory."""
        files = []
        for pattern in self.track_patterns:
            for path in self.working_dir.glob(pattern):
                if path.is_file() and self._should_track(path):
                    files.append(path)
        return sorted(set(files))

    def capture_before(self) -> None:
        """Capture file states before tool execution."""
        self._current_snapshot = {}
        for path in self._get_tracked_files():
            self._current_snapshot[path] = FileSnapshot.capture(path)

    def capture_after(self, tool: str) -> list[FileChange]:
        """Capture file states after tool execution and compute changes.

        Args:
            tool: Name of the tool that made changes

        Returns:
            List of FileChange objects for files that changed
        """
        if tool not in self._changes:
            self._changes[tool] = []
            self._snapshots[tool] = {}

        changes = []
        current_files = set(self._get_tracked_files())
        before_files = set(self._current_snapshot.keys())

        # Check modified and deleted files
        for path in before_files:
            before = self._current_snapshot[path]
            after = FileSnapshot.capture(path)

            if before.hash != after.hash:
                change = FileChange(
                    tool=tool,
                    path=path,
                    before=before,
                    after=after,
                    change_type="deleted" if not after.exists else "modified",
                )
                changes.append(change)

            self._snapshots[tool][path] = after

        # Check for new files
        new_files = current_files - before_files
        for path in new_files:
            after = FileSnapshot.capture(path)
            before = FileSnapshot(
                path=path,
                content="",
                hash="",
                exists=False,
            )
            change = FileChange(
                tool=tool,
                path=path,
                before=before,
                after=after,
                change_type="created",
            )
            changes.append(change)
            self._snapshots[tool][path] = after

        self._changes[tool].extend(changes)
        return changes

    def capture_git_changes(self, tool: str) -> list[FileChange]:
        """Capture changes using git diff (more accurate for git repos).

        Args:
            tool: Name of the tool that made changes

        Returns:
            List of FileChange objects for files that changed
        """
        try:
            result = subprocess.run(
                ["git", "diff", "--name-status"],
                cwd=self.working_dir,
                capture_output=True,
                text=True,
                timeout=10,
            )
            if result.returncode != 0:
                return self.capture_after(tool)

            changes = []
            for line in result.stdout.strip().split("\n"):
                if not line:
                    continue
                parts = line.split("\t")
                if len(parts) < 2:
                    continue

                status, *paths = parts
                path = self.working_dir / paths[0]

                if not self._should_track(path):
                    continue

                before = self._current_snapshot.get(
                    path,
                    FileSnapshot(
                        path=path,
                        content="",
                        hash="",
                        exists=False,
                    ),
                )
                after = FileSnapshot.capture(path)

                change_type = {
                    "M": "modified",
                    "A": "created",
                    "D": "deleted",
                    "R": "renamed",
                }.get(status[0], "modified")

                change = FileChange(
                    tool=tool,
                    path=path,
                    before=before,
                    after=after,
                    change_type=change_type,
                )
                changes.append(change)

            if tool not in self._changes:
                self._changes[tool] = []
            self._changes[tool].extend(changes)
            return changes

        except (subprocess.TimeoutExpired, FileNotFoundError):
            return self.capture_after(tool)

    def detect_conflicts(self) -> list[FileConflict]:
        """Detect conflicts between changes from different tools.

        Returns:
            List of FileConflict objects describing detected conflicts
        """
        conflicts = []

        # Group changes by file path
        changes_by_path: dict[Path, list[FileChange]] = {}
        for tool_changes in self._changes.values():
            for change in tool_changes:
                if change.path not in changes_by_path:
                    changes_by_path[change.path] = []
                changes_by_path[change.path].append(change)

        # Check each file with multiple changes
        for path, file_changes in changes_by_path.items():
            if len(file_changes) < 2:
                continue

            # Check for actual conflicts
            conflict = self._analyze_conflict(path, file_changes)
            if conflict.conflict_type != ConflictType.NONE:
                conflicts.append(conflict)

        return conflicts

    def _analyze_conflict(
        self,
        path: Path,
        changes: list[FileChange],
    ) -> FileConflict:
        """Analyze changes to a single file for conflicts."""
        # Check for delete/modify conflicts
        deletions = [c for c in changes if c.is_deletion]
        modifications = [c for c in changes if c.is_modification]

        if deletions and modifications:
            return FileConflict(
                path=path,
                conflict_type=ConflictType.DELETE_MODIFY,
                changes=changes,
                description=f"Conflict: {deletions[0].tool} deleted, {modifications[0].tool} modified",
            )

        # If only one tool made actual changes, no conflict
        actual_changes = [c for c in changes if c.has_changes]
        if len(actual_changes) <= 1:
            return FileConflict(
                path=path,
                conflict_type=ConflictType.NONE,
                changes=changes,
                description="No conflict",
            )

        # Check if changes are identical
        contents = [c.after.content for c in actual_changes]
        if len(set(contents)) == 1:
            return FileConflict(
                path=path,
                conflict_type=ConflictType.NONE,
                changes=changes,
                description="Identical changes from multiple tools",
            )

        # Analyze overlapping lines
        overlap = self._find_overlapping_changes(actual_changes)
        if overlap:
            return FileConflict(
                path=path,
                conflict_type=ConflictType.OVERLAPPING_LINES,
                changes=changes,
                description=f"Tools modified overlapping lines: {overlap}",
            )

        # Different non-overlapping changes - might be mergeable
        return FileConflict(
            path=path,
            conflict_type=ConflictType.DIVERGENT_LOGIC,
            changes=changes,
            description="Tools made different changes to the same file",
        )

    def _find_overlapping_changes(
        self,
        changes: list[FileChange],
    ) -> str | None:
        """Find overlapping line changes between tools."""
        if len(changes) < 2:
            return None

        # Get changed line ranges for each tool
        change_ranges: dict[str, set[int]] = {}
        for change in changes:
            before_lines = change.before.content.splitlines()
            after_lines = change.after.content.splitlines()

            matcher = difflib.SequenceMatcher(None, before_lines, after_lines)
            changed_lines: set[int] = set()

            for tag, i1, i2, j1, j2 in matcher.get_opcodes():
                if tag != "equal":
                    changed_lines.update(range(i1, i2))

            change_ranges[change.tool] = changed_lines

        # Check for overlaps
        tools = list(change_ranges.keys())
        for i, tool1 in enumerate(tools):
            for tool2 in tools[i + 1 :]:
                overlap = change_ranges[tool1] & change_ranges[tool2]
                if overlap:
                    return f"{tool1} & {tool2} both modified lines {sorted(overlap)[:5]}"

        return None

    def resolve(
        self,
        strategy: MergeStrategy = MergeStrategy.PREFER_FIRST,
    ) -> AggregationResult:
        """Resolve conflicts and produce final merged state.

        Args:
            strategy: Strategy to use for conflict resolution

        Returns:
            AggregationResult with merged changes
        """
        conflicts = self.detect_conflicts()
        merged_changes: dict[Path, str] = {}
        files_changed: list[Path] = []

        # Collect all changed files
        all_changes: dict[Path, list[FileChange]] = {}
        for tool_changes in self._changes.values():
            for change in tool_changes:
                if change.path not in all_changes:
                    all_changes[change.path] = []
                all_changes[change.path].append(change)
                if change.path not in files_changed:
                    files_changed.append(change.path)

        # Resolve each file
        for path, changes in all_changes.items():
            # Find if there's a conflict for this path
            conflict = next((c for c in conflicts if c.path == path), None)

            if conflict and conflict.conflict_type != ConflictType.NONE:
                resolved = self._resolve_conflict(conflict, strategy)
                if resolved is not None:
                    merged_changes[path] = resolved
                    conflict.resolved_content = resolved
            else:
                # No conflict, use the change
                actual_changes = [c for c in changes if c.has_changes]
                if actual_changes:
                    merged_changes[path] = actual_changes[0].after.content

        # Check for unresolved conflicts
        unresolved = [
            c
            for c in conflicts
            if c.conflict_type != ConflictType.NONE and c.resolved_content is None
        ]

        return AggregationResult(
            success=len(unresolved) == 0,
            strategy_used=strategy,
            files_changed=files_changed,
            conflicts=conflicts,
            merged_changes=merged_changes,
            metadata={
                "total_conflicts": len(conflicts),
                "resolved_conflicts": len(conflicts) - len(unresolved),
                "tools_involved": list(self._changes.keys()),
            },
        )

    def _resolve_conflict(
        self,
        conflict: FileConflict,
        strategy: MergeStrategy,
    ) -> str | None:
        """Resolve a single conflict using the specified strategy."""
        if strategy == MergeStrategy.ABORT:
            return None

        actual_changes = [c for c in conflict.changes if c.has_changes]
        if not actual_changes:
            return None

        if strategy == MergeStrategy.PREFER_FIRST:
            return actual_changes[0].after.content

        if strategy == MergeStrategy.PREFER_LAST:
            return actual_changes[-1].after.content

        if strategy == MergeStrategy.UNION:
            return self._merge_union(conflict)

        if strategy == MergeStrategy.SMART_MERGE:
            return self._smart_merge(conflict)

        return None

    def _merge_union(self, conflict: FileConflict) -> str:
        """Merge changes, keeping all non-overlapping edits."""
        actual_changes = [c for c in conflict.changes if c.has_changes]
        if len(actual_changes) < 2:
            return actual_changes[0].after.content if actual_changes else ""

        # Start with the original content
        base_content = actual_changes[0].before.content
        base_lines = base_content.splitlines(keepends=True)

        # Apply non-overlapping changes from each tool
        # This is a simplified implementation - a full 3-way merge would be more robust
        result_lines = list(base_lines)

        for change in actual_changes:
            after_lines = change.after.content.splitlines(keepends=True)
            matcher = difflib.SequenceMatcher(None, base_lines, after_lines)

            # Track insertions to apply
            insertions: list[tuple[int, list[str]]] = []

            for tag, i1, i2, j1, j2 in matcher.get_opcodes():
                if tag == "insert":
                    insertions.append((i1, after_lines[j1:j2]))
                elif tag == "replace":
                    # For replacements, add conflict markers
                    if i1 < len(result_lines):
                        marker = [
                            f"<<<<<<< {change.tool}\n",
                            *after_lines[j1:j2],
                            "=======\n",
                            *base_lines[i1:i2],
                            ">>>>>>> original\n",
                        ]
                        result_lines[i1:i2] = marker

            # Apply insertions (in reverse order to preserve indices)
            for idx, new_lines in reversed(insertions):
                result_lines[idx:idx] = new_lines

        return "".join(result_lines)

    def _smart_merge(self, conflict: FileConflict) -> str:
        """Attempt intelligent merge based on change semantics."""
        actual_changes = [c for c in conflict.changes if c.has_changes]
        if len(actual_changes) < 2:
            return actual_changes[0].after.content if actual_changes else ""

        # Try 3-way merge using git merge-file if available
        try:
            return self._git_merge_file(conflict)
        except Exception:
            pass

        # Fall back to union merge
        return self._merge_union(conflict)

    def _git_merge_file(self, conflict: FileConflict) -> str:
        """Use git merge-file for 3-way merge."""
        import tempfile

        actual_changes = [c for c in conflict.changes if c.has_changes]
        if len(actual_changes) < 2:
            raise ValueError("Need at least 2 changes for merge")

        # Create temp files for merge
        with tempfile.NamedTemporaryFile(mode="w", suffix=".base", delete=False) as base_f:
            base_f.write(actual_changes[0].before.content)
            base_path = base_f.name

        with tempfile.NamedTemporaryFile(mode="w", suffix=".ours", delete=False) as ours_f:
            ours_f.write(actual_changes[0].after.content)
            ours_path = ours_f.name

        with tempfile.NamedTemporaryFile(mode="w", suffix=".theirs", delete=False) as theirs_f:
            theirs_f.write(actual_changes[1].after.content)
            theirs_path = theirs_f.name

        try:
            result = subprocess.run(
                ["git", "merge-file", "-p", ours_path, base_path, theirs_path],
                capture_output=True,
                text=True,
                timeout=10,
            )
            return result.stdout
        finally:
            # Clean up temp files
            for p in [base_path, ours_path, theirs_path]:
                Path(p).unlink(missing_ok=True)

    def apply_merged_changes(
        self,
        result: AggregationResult,
        dry_run: bool = False,
    ) -> dict[Path, bool]:
        """Apply merged changes to the filesystem.

        Args:
            result: AggregationResult from resolve()
            dry_run: If True, don't actually write files

        Returns:
            Dict mapping paths to success status
        """
        applied: dict[Path, bool] = {}

        for path, content in result.merged_changes.items():
            try:
                if not dry_run:
                    path.parent.mkdir(parents=True, exist_ok=True)
                    path.write_text(content, encoding="utf-8")
                applied[path] = True
            except Exception as e:
                logger.error(f"Failed to apply changes to {path}: {e}")
                applied[path] = False

        return applied

    def get_summary(self) -> str:
        """Get a human-readable summary of all changes."""
        lines = ["# File Change Summary", ""]

        for tool, changes in self._changes.items():
            lines.append(f"## {tool}")
            if not changes:
                lines.append("  No changes")
                continue

            for change in changes:
                status = "+" if change.is_creation else "-" if change.is_deletion else "M"
                lines.append(f"  {status} {change.path.relative_to(self.working_dir)}")

        conflicts = self.detect_conflicts()
        if conflicts:
            lines.append("")
            lines.append("## Conflicts")
            for conflict in conflicts:
                if conflict.conflict_type != ConflictType.NONE:
                    tools = ", ".join(c.tool for c in conflict.changes)
                    lines.append(f"  ! {conflict.path.relative_to(self.working_dir)}")
                    lines.append(f"    Type: {conflict.conflict_type.value}")
                    lines.append(f"    Tools: {tools}")
                    lines.append(f"    {conflict.description}")

        return "\n".join(lines)

    def reset(self) -> None:
        """Reset aggregator state for a new execution cycle."""
        self._snapshots.clear()
        self._changes.clear()
        self._current_snapshot.clear()
