"""Merge strategies for resolving sandbox conflicts.

When multiple sandboxes modify the same resource (e.g. parallel subplans),
a merge strategy determines how to combine the changes.

Three strategies are provided:

- :class:`GitMergeStrategy` -- three-way merge using ``git merge-file``
- :class:`SequentialMergeStrategy` -- last-write-wins (theirs always takes
  precedence)
- :class:`JsonMergeStrategy` -- recursive deep-merge for JSON documents

Stage B3.8 of the implementation plan.
"""

from __future__ import annotations

import json
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any, Protocol

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class MergeResult:
    """Outcome of a three-way merge operation.

    Attributes:
        success: ``True`` if the merge completed without unresolved conflicts.
        content: The merged content (may contain conflict markers if
            ``has_conflicts`` is ``True``).
        has_conflicts: Whether unresolved conflicts exist in ``content``.
        conflict_markers: List of ``(start_line, end_line)`` tuples
            indicating regions that contain conflict markers.
    """

    success: bool
    content: str
    has_conflicts: bool = False
    conflict_markers: list[tuple[int, int]] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Protocol
# ---------------------------------------------------------------------------


class MergeStrategy(Protocol):
    """Protocol for merge strategy implementations."""

    def merge(self, base: str, ours: str, theirs: str) -> MergeResult:
        """Perform a three-way merge.

        Args:
            base: The common ancestor content.
            ours: The content from the first (current) branch of changes.
            theirs: The content from the second (incoming) branch of changes.

        Returns:
            A :class:`MergeResult` describing the merged content and any
            conflicts.
        """
        ...


# ---------------------------------------------------------------------------
# Implementations
# ---------------------------------------------------------------------------


class GitMergeStrategy:
    """Three-way merge using ``git merge-file``.

    Shells out to ``git merge-file -p ours base theirs`` and parses the
    output for conflict markers (``<<<<<<<``, ``=======``, ``>>>>>>>``).
    """

    def merge(self, base: str, ours: str, theirs: str) -> MergeResult:
        """Merge using git merge-file.

        Args:
            base: Common ancestor content.
            ours: Current branch content.
            theirs: Incoming branch content.

        Returns:
            :class:`MergeResult` with merged content.  If ``git merge-file``
            is not available, falls back to :class:`SequentialMergeStrategy`.
        """
        if not base and not ours and not theirs:
            return MergeResult(success=True, content="")

        tmp_dir: str | None = None
        try:
            tmp_dir = tempfile.mkdtemp(prefix="ca_merge_")
            base_path = os.path.join(tmp_dir, "base")
            ours_path = os.path.join(tmp_dir, "ours")
            theirs_path = os.path.join(tmp_dir, "theirs")

            with open(base_path, "w", encoding="utf-8") as f:
                f.write(base)
            with open(ours_path, "w", encoding="utf-8") as f:
                f.write(ours)
            with open(theirs_path, "w", encoding="utf-8") as f:
                f.write(theirs)

            result = subprocess.run(
                ["git", "merge-file", "-p", ours_path, base_path, theirs_path],
                capture_output=True,
                text=True,
                check=False,
            )

            merged_content = result.stdout

            # Return codes:
            #   0 = clean merge
            #  >0 = number of conflicts (but output is still produced)
            #  <0 = error
            if result.returncode < 0:
                logger.warning(
                    "git merge-file returned negative exit code %d; "
                    "falling back to sequential merge",
                    result.returncode,
                )
                return SequentialMergeStrategy().merge(base, ours, theirs)

            has_conflicts = result.returncode > 0
            conflict_markers = self._find_conflict_markers(merged_content)

            return MergeResult(
                success=not has_conflicts,
                content=merged_content,
                has_conflicts=has_conflicts,
                conflict_markers=conflict_markers,
            )

        except FileNotFoundError:
            logger.warning("git not found on PATH; falling back to sequential merge")
            return SequentialMergeStrategy().merge(base, ours, theirs)
        finally:
            if tmp_dir is not None:
                import shutil

                shutil.rmtree(tmp_dir, ignore_errors=True)

    @staticmethod
    def _find_conflict_markers(content: str) -> list[tuple[int, int]]:
        """Scan *content* for git conflict marker regions.

        Args:
            content: The merged file content.

        Returns:
            List of ``(start_line, end_line)`` 1-indexed tuples for each
            conflict region.
        """
        markers: list[tuple[int, int]] = []
        lines = content.splitlines()
        start: int | None = None

        for i, line in enumerate(lines):
            if line.startswith("<<<<<<<"):
                start = i + 1  # 1-indexed
            elif line.startswith(">>>>>>>") and start is not None:
                markers.append((start, i + 1))
                start = None

        return markers


class SequentialMergeStrategy:
    """Last-write-wins merge: *theirs* always takes precedence.

    Suitable for resources that do not support content-level merging
    (e.g. binary files, configuration snapshots).
    """

    def merge(self, base: str, ours: str, theirs: str) -> MergeResult:
        """Return *theirs* as the merged content.

        Args:
            base: Ignored.
            ours: Ignored.
            theirs: The incoming content that wins.

        Returns:
            :class:`MergeResult` with ``success=True`` and ``content=theirs``.
        """
        return MergeResult(
            success=True,
            content=theirs,
            has_conflicts=False,
        )


class JsonMergeStrategy:
    """Deep-merge for JSON documents.

    Recursively merges JSON objects (dicts).  For arrays, the default
    behaviour is *theirs-wins* (replace the entire array).  Set
    ``array_mode="concat"`` to concatenate arrays instead.
    """

    def __init__(self, array_mode: str = "replace") -> None:
        """Configure array handling.

        Args:
            array_mode: How to handle arrays during merge.
                - ``"replace"`` (default): *theirs* array replaces *ours*.
                - ``"concat"``: Arrays are concatenated (ours + theirs).

        Raises:
            ValueError: If *array_mode* is not a recognised value.
        """
        if array_mode not in ("replace", "concat"):
            raise ValueError(
                f"array_mode must be 'replace' or 'concat', got {array_mode!r}"
            )
        self._array_mode: str = array_mode

    def merge(self, base: str, ours: str, theirs: str) -> MergeResult:
        """Merge two JSON documents against a common base.

        Args:
            base: Common ancestor JSON (used for context but not currently
                for three-way diffing -- future enhancement).
            ours: Current JSON document.
            theirs: Incoming JSON document.

        Returns:
            :class:`MergeResult` with the merged JSON as ``content``.
            If parsing fails, returns a failed result with the error.
        """
        try:
            ours_obj: Any = json.loads(ours) if ours else {}
            theirs_obj: Any = json.loads(theirs) if theirs else {}
        except json.JSONDecodeError:
            return MergeResult(
                success=False,
                content=theirs if theirs else ours,
                has_conflicts=True,
                conflict_markers=[],
            )

        merged = self._deep_merge(ours_obj, theirs_obj)
        merged_json = json.dumps(merged, indent=2, ensure_ascii=False)

        return MergeResult(
            success=True,
            content=merged_json,
            has_conflicts=False,
        )

    def _deep_merge(self, ours: Any, theirs: Any) -> Any:
        """Recursively merge two values.

        Args:
            ours: The current value.
            theirs: The incoming value.

        Returns:
            The merged value.
        """
        if isinstance(ours, dict) and isinstance(theirs, dict):
            merged: dict[str, Any] = dict(ours)
            for key, value in theirs.items():
                if key in merged:
                    merged[key] = self._deep_merge(merged[key], value)
                else:
                    merged[key] = value
            return merged

        if isinstance(ours, list) and isinstance(theirs, list):
            if self._array_mode == "concat":
                return ours + theirs
            # replace mode: theirs wins
            return theirs

        # For scalars and type mismatches: theirs wins
        return theirs
