"""Loop detection engine for peon-mcp agent sessions.

Detects and handles repetitive agent behavior by tracking tool calls in a
sliding window and identifying known pathological patterns.
"""

from __future__ import annotations

import hashlib
import json
import logging
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
from time import time

logger = logging.getLogger(__name__)

# Tool categories used by the polling-without-progress detector.
# Read-like tools: agent is observing / checking state.
# Note: `bash` is intentionally excluded — it can perform writes (git commit,
# npm install, etc.) so it cannot be unconditionally treated as read-only.
# Unknown tools (including bash) are ignored by the polling detector; they
# neither advance nor reset a read streak.
READ_TOOLS: frozenset[str] = frozenset(
    {
        "read",
        "glob",
        "grep",
        "task_output",
        "get_task",
        "list_tasks",
        "get_project",
        "list_projects",
        "get_feature",
        "list_features",
        "list_work_logs",
        "list_review_sessions",
        "get_review_session",
        "recall_memories",
        "list_memories",
    }
)

# Write-like tools: agent is making progress / changing state.
WRITE_TOOLS: frozenset[str] = frozenset(
    {
        "write",
        "edit",
        "notebookedit",
        "create_task",
        "update_task",
        "complete_feature",
        "create_feature",
        "update_feature",
        "create_project",
        "update_project",
        "log_work",
        "save_memory",
        "forget_memory",
        "submit_review_finding",
        "complete_review_perspective",
        "create_review_session",
        "update_task_progress",
    }
)


class LoopSeverity(str, Enum):
    WARNING = "warning"
    CRITICAL = "critical"
    CIRCUIT_BREAKER = "circuit_breaker"


@dataclass
class ToolCall:
    """A single recorded tool call entry in the sliding window."""

    tool_name: str
    param_hash: str
    timestamp: float


@dataclass
class LoopDetectionResult:
    """Result from a single loop-pattern check."""

    detected: bool
    severity: LoopSeverity | None = None
    pattern: str | None = None
    message: str | None = None
    consecutive_count: int = 0


@dataclass
class LoopDetectorConfig:
    """Configuration for the LoopDetector.

    All threshold fields accept positive integers.  Setting ``enabled`` to
    ``False`` disables all detection while still recording calls so that the
    detector can be re-enabled later without losing history.
    """

    enabled: bool = True

    # Generic repeat thresholds
    warning_threshold: int = 5
    critical_threshold: int = 10
    circuit_breaker_threshold: int = 15

    # Polling without progress thresholds (consecutive read-like calls).
    # Each level fires exactly once per streak (debounced); escalates as the
    # streak grows without any intervening write.
    polling_threshold: int = 8           # WARNING fires once here
    polling_critical_threshold: int = 16  # CRITICAL fires once here
    polling_circuit_breaker_threshold: int = 24  # CIRCUIT_BREAKER fires here

    # Ping-pong detection (number of complete A→B cycles to trigger)
    ping_pong_cycles: int = 4

    # Sliding window size (max tool calls to remember)
    window_size: int = 50

    # Per-detector enable flags
    detectors: dict[str, bool] = field(
        default_factory=lambda: {
            "generic_repeat": True,
            "polling_without_progress": True,
            "ping_pong": True,
        }
    )


# Severity ordering for selecting the worst result from multiple detections.
_SEVERITY_RANK: dict[LoopSeverity, int] = {
    LoopSeverity.WARNING: 0,
    LoopSeverity.CRITICAL: 1,
    LoopSeverity.CIRCUIT_BREAKER: 2,
}


class LoopDetector:
    """Tracks tool calls in a sliding window and identifies loop patterns.

    Usage::

        detector = LoopDetector()
        result = detector.record_tool_call("read", {"file_path": "/foo.py"})
        if result.detected:
            print(result.message)
            if result.severity == LoopSeverity.CIRCUIT_BREAKER:
                # terminate agent
                ...
    """

    def __init__(self, config: LoopDetectorConfig | None = None) -> None:
        self.config = config or LoopDetectorConfig()
        self._window: deque[ToolCall] = deque(maxlen=self.config.window_size)
        # Debounce state: tracks the read-streak length at which the last
        # polling_without_progress event was emitted.  Resets to 0 whenever
        # the streak is broken by a write.  This ensures each severity level
        # fires exactly once per distinct polling streak.
        self._last_polling_streak_length: int = 0

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def record_tool_call(
        self,
        tool_name: str,
        params: dict | None = None,
    ) -> LoopDetectionResult:
        """Record a tool call and return the result of pattern checks.

        If detection is disabled the call is still recorded (window is kept
        up-to-date) but ``LoopDetectionResult(detected=False)`` is returned.
        """
        entry = ToolCall(
            tool_name=tool_name.lower(),
            param_hash=_hash_params(params or {}),
            timestamp=time(),
        )
        self._window.append(entry)

        if not self.config.enabled:
            return LoopDetectionResult(detected=False)

        result = self._check_patterns()

        if result.detected:
            self._emit(result)

        return result

    def reset(self) -> None:
        """Clear the call window (e.g. when a new task begins)."""
        self._window.clear()
        self._last_polling_streak_length = 0

    @property
    def call_count(self) -> int:
        """Number of calls currently in the sliding window."""
        return len(self._window)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _check_patterns(self) -> LoopDetectionResult:
        """Run all enabled detectors and return the highest-severity result."""
        detectors_cfg = self.config.detectors
        candidates: list[LoopDetectionResult] = []

        if detectors_cfg.get("generic_repeat", True):
            r = self._check_generic_repeat()
            if r.detected:
                candidates.append(r)

        if detectors_cfg.get("ping_pong", True):
            r = self._check_ping_pong()
            if r.detected:
                candidates.append(r)

        if detectors_cfg.get("polling_without_progress", True):
            r = self._check_polling_without_progress()
            if r.detected:
                candidates.append(r)

        if not candidates:
            return LoopDetectionResult(detected=False)

        # Return the result with the highest severity.
        return max(candidates, key=lambda r: _SEVERITY_RANK.get(r.severity, -1))  # type: ignore[arg-type]

    def _check_generic_repeat(self) -> LoopDetectionResult:
        """Detect the same (tool_name, param_hash) called N times in a row."""
        if len(self._window) < 2:
            return LoopDetectionResult(detected=False)

        window_list = list(self._window)
        last = window_list[-1]
        count = 0
        for call in reversed(window_list):
            if call.tool_name == last.tool_name and call.param_hash == last.param_hash:
                count += 1
            else:
                break

        cfg = self.config
        if count >= cfg.circuit_breaker_threshold:
            return LoopDetectionResult(
                detected=True,
                severity=LoopSeverity.CIRCUIT_BREAKER,
                pattern="generic_repeat",
                message=(
                    f"Circuit breaker triggered: '{last.tool_name}' called {count} "
                    f"consecutive times with identical parameters. Terminating agent."
                ),
                consecutive_count=count,
            )
        if count >= cfg.critical_threshold:
            return LoopDetectionResult(
                detected=True,
                severity=LoopSeverity.CRITICAL,
                pattern="generic_repeat",
                message=(
                    f"You appear to be stuck in a loop — '{last.tool_name}' has been "
                    f"called {count} consecutive times with identical parameters. "
                    f"Try a completely different approach."
                ),
                consecutive_count=count,
            )
        if count >= cfg.warning_threshold:
            return LoopDetectionResult(
                detected=True,
                severity=LoopSeverity.WARNING,
                pattern="generic_repeat",
                message=(
                    f"You appear to be stuck in a loop — '{last.tool_name}' has been "
                    f"called {count} consecutive times with identical parameters. "
                    f"Consider a different approach."
                ),
                consecutive_count=count,
            )

        return LoopDetectionResult(detected=False)

    def _check_polling_without_progress(self) -> LoopDetectionResult:
        """Detect read-only calls with no intervening write/edit calls.

        Counts the true consecutive read streak from the end of the full window
        (not capped at threshold) so that escalation thresholds are reachable.

        Debounced: each severity level fires exactly once per streak.  The
        streak resets to zero whenever a write tool is encountered, which also
        clears the debounce state so a new streak can trigger all levels again.
        """
        cfg = self.config

        # Count the actual consecutive read streak from the tail of the window.
        consecutive_reads = 0
        for call in reversed(self._window):
            if call.tool_name in WRITE_TOOLS:
                break
            if call.tool_name in READ_TOOLS:
                consecutive_reads += 1
            # Unknown tools (e.g. bash) are neutral — don't advance or reset.

        # If the streak shrank since last check a write has intervened; reset.
        if consecutive_reads < self._last_polling_streak_length:
            self._last_polling_streak_length = 0

        # Determine the severity the current count warrants.
        if consecutive_reads >= cfg.polling_circuit_breaker_threshold:
            current_severity: LoopSeverity | None = LoopSeverity.CIRCUIT_BREAKER
        elif consecutive_reads >= cfg.polling_critical_threshold:
            current_severity = LoopSeverity.CRITICAL
        elif consecutive_reads >= cfg.polling_threshold:
            current_severity = LoopSeverity.WARNING
        else:
            # Below warning threshold — update streak state and return clean.
            self._last_polling_streak_length = consecutive_reads
            return LoopDetectionResult(detected=False)

        # Determine what severity was warranted at the last reported count.
        prev = self._last_polling_streak_length
        if prev >= cfg.polling_circuit_breaker_threshold:
            prev_severity: LoopSeverity | None = LoopSeverity.CIRCUIT_BREAKER
        elif prev >= cfg.polling_critical_threshold:
            prev_severity = LoopSeverity.CRITICAL
        elif prev >= cfg.polling_threshold:
            prev_severity = LoopSeverity.WARNING
        else:
            prev_severity = None

        # Update debounce state before deciding whether to emit.
        self._last_polling_streak_length = consecutive_reads

        # Only emit when the severity level has increased (new threshold crossed).
        if current_severity == prev_severity:
            return LoopDetectionResult(detected=False)

        if current_severity == LoopSeverity.CIRCUIT_BREAKER:
            return LoopDetectionResult(
                detected=True,
                severity=LoopSeverity.CIRCUIT_BREAKER,
                pattern="polling_without_progress",
                message=(
                    f"Circuit breaker triggered: {consecutive_reads} consecutive "
                    f"read/check calls with no writes or edits. Terminating agent."
                ),
                consecutive_count=consecutive_reads,
            )
        if current_severity == LoopSeverity.CRITICAL:
            return LoopDetectionResult(
                detected=True,
                severity=LoopSeverity.CRITICAL,
                pattern="polling_without_progress",
                message=(
                    f"You appear to be stuck reading without acting — "
                    f"{consecutive_reads} consecutive read/check calls with no writes or "
                    f"edits. You must take action or escalate."
                ),
                consecutive_count=consecutive_reads,
            )
        # LoopSeverity.WARNING
        return LoopDetectionResult(
            detected=True,
            severity=LoopSeverity.WARNING,
            pattern="polling_without_progress",
            message=(
                f"You appear to be polling without making progress — "
                f"{consecutive_reads} consecutive read/check calls with no writes or "
                f"edits. Consider acting on what you've observed."
            ),
            consecutive_count=consecutive_reads,
        )

    def _check_ping_pong(self) -> LoopDetectionResult:
        """Detect A→B→A→B alternation with the same params (N complete cycles)."""
        cycles = self.config.ping_pong_cycles
        min_calls = cycles * 2

        if len(self._window) < min_calls:
            return LoopDetectionResult(detected=False)

        window_list = list(self._window)
        recent = window_list[-min_calls:]

        # Build key tuples for even and odd positions separately.
        even_keys = {(c.tool_name, c.param_hash) for i, c in enumerate(recent) if i % 2 == 0}
        odd_keys = {(c.tool_name, c.param_hash) for i, c in enumerate(recent) if i % 2 == 1}

        if len(even_keys) == 1 and len(odd_keys) == 1 and even_keys != odd_keys:
            tool_a = next(iter(even_keys))[0]
            tool_b = next(iter(odd_keys))[0]
            return LoopDetectionResult(
                detected=True,
                severity=LoopSeverity.WARNING,
                pattern="ping_pong",
                message=(
                    f"Ping-pong loop detected: alternating between '{tool_a}' and "
                    f"'{tool_b}' with identical parameters ({cycles} cycles). "
                    f"Your edits may not be taking effect — verify the change."
                ),
                consecutive_count=cycles,
            )

        return LoopDetectionResult(detected=False)

    def _emit(self, result: LoopDetectionResult) -> None:
        """Log the detection event at the appropriate level."""
        if result.severity == LoopSeverity.CIRCUIT_BREAKER:
            logger.error(
                "loop_detection circuit_breaker pattern=%s count=%d: %s",
                result.pattern,
                result.consecutive_count,
                result.message,
            )
        elif result.severity == LoopSeverity.CRITICAL:
            logger.error(
                "loop_detection critical pattern=%s count=%d: %s",
                result.pattern,
                result.consecutive_count,
                result.message,
            )
        else:
            logger.warning(
                "loop_detection warning pattern=%s count=%d: %s",
                result.pattern,
                result.consecutive_count,
                result.message,
            )


# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------


def _hash_params(params: dict) -> str:
    """Return a short, stable hex hash of a params dict."""
    try:
        serialized = json.dumps(params, sort_keys=True, default=str)
    except Exception:
        serialized = str(params)
    return hashlib.sha1(serialized.encode()).hexdigest()[:12]
