"""L3 AgentAnalyzer — MVP (single-turn) and standard (multi-turn) modes.

Design basis: 11-long-term-evolution-vision.md section 3 (Phase 5.2)

MVP mode (enable_multi_turn=False):
  trigger -> select skill -> collect min context -> single LLM call -> L2Result

Standard mode (enable_multi_turn=True):
  same entry; LLM drives tool selection each turn via structured JSON protocol.
  Each turn: LLM returns {thought, tool_call, done} or final {risk_level, findings, confidence}.
  Hard constraints: MAX_TOOL_CALLS budget, max_reasoning_turns, hard_cap_ms.

Fail-safe: any error / timeout / budget exhaustion -> degrade to l1_snapshot level, confidence=0.
"""

from __future__ import annotations

import asyncio
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional

from .l3_runtime import L3ReasonCode
from .l3_trigger import L3TriggerPolicy
from .llm_provider import LLMProvider
from .models import CanonicalEvent, DecisionContext, DecisionTier, RiskLevel, RiskSnapshot
from .review_skills import ReviewSkill, SkillRegistry
from .review_toolkit import ReadOnlyToolkit, ToolCallBudgetExhausted
from .semantic_analyzer import L2Result, _max_risk_level


# Whitelist of toolkit methods callable by LLM in multi-turn mode
_ALLOWED_TOOL_CALLS: dict[str, str] = {
    "read_trajectory": "read_trajectory",
    "read_trajectory_page": "read_trajectory_page",
    "read_file": "read_file",
    "read_transcript": "read_transcript",
    "read_session_risk": "read_session_risk",
    "search_codebase": "search_codebase",
    "query_git_diff": "query_git_diff",
    "list_directory": "list_directory",
}


@dataclass
class AgentAnalyzerConfig:
    provider_timeout_ms: float = 120_000.0
    hard_cap_ms: float = 120_000.0
    l3_budget_ms: Optional[float] = None  # User-configurable L3 budget; None = use passed budget
    max_reasoning_turns: int = 8
    initial_trajectory_limit: int = 20
    max_findings: int = 10
    enable_multi_turn: bool = False


class AgentAnalyzer:
    """L3 review analyzer implementing the SemanticAnalyzer-compatible interface."""

    def __init__(
        self,
        provider: LLMProvider,
        toolkit: ReadOnlyToolkit,
        skill_registry: SkillRegistry,
        trigger_policy: Optional[L3TriggerPolicy] = None,
        config: Optional[AgentAnalyzerConfig] = None,
        trajectory_store: Any = None,
        session_registry: Any = None,
    ) -> None:
        self._provider = provider
        self._toolkit = toolkit
        self._skill_registry = skill_registry
        self._trigger_policy = trigger_policy or L3TriggerPolicy()
        self._config = config or AgentAnalyzerConfig()
        self._trajectory_store = trajectory_store
        self._session_registry = session_registry

    @property
    def analyzer_id(self) -> str:
        return "agent-reviewer"

    @staticmethod
    def _infer_l3_reason_code(
        *,
        trigger_reason: str,
        degraded: bool,
        degradation_reason: Optional[str],
    ) -> str | None:
        """Infer stable L3 reason code for operator-facing runtime reporting.

        Note: This intentionally avoids brittle substring matching by relying on
        exact / prefix matches for AgentAnalyzer-emitted reasons.
        """

        normalized_trigger = str(trigger_reason or "").strip()
        if normalized_trigger == "trigger_not_matched":
            return L3ReasonCode.TRIGGER_NOT_MATCHED.value

        if not degraded:
            return None

        reason = str(degradation_reason or "").strip()
        if not reason:
            return L3ReasonCode.UNKNOWN_DEGRADED.value

        # Exact matches (AgentAnalyzer emitted)
        exact: dict[str, str] = {
            "L3 hard cap exceeded": L3ReasonCode.HARD_CAP_EXCEEDED.value,
            "L3 LLM call failed": L3ReasonCode.LLM_CALL_FAILED.value,
            "L3 max reasoning turns exceeded": L3ReasonCode.MAX_TURNS_EXCEEDED.value,
            "L3 response parse failed": L3ReasonCode.LLM_RESPONSE_PARSE_FAILED.value,
            "L3 response unresolvable risk level": L3ReasonCode.LLM_RESPONSE_UNRESOLVABLE_RISK_LEVEL.value,
            "L3 format retry failed": L3ReasonCode.FORMAT_RETRY_FAILED.value,
            "L3 tool call budget exhausted": L3ReasonCode.TOOL_CALL_BUDGET_EXHAUSTED.value,
            "L3 trigger not matched": L3ReasonCode.TRIGGER_NOT_MATCHED.value,
        }
        mapped = exact.get(reason)
        if mapped is not None:
            return mapped

        # Prefix matches (AgentAnalyzer emitted with details)
        if reason.startswith("L3 requested non-whitelisted tool:"):
            return L3ReasonCode.REQUESTED_NON_WHITELISTED_TOOL.value
        if reason.startswith("L3 analysis degraded"):
            return L3ReasonCode.ANALYSIS_EXCEPTION.value

        return L3ReasonCode.UNKNOWN_DEGRADED.value

    def _build_trace(
        self,
        *,
        trigger_reason: str,
        trigger_detail: Optional[str],
        skill_selected: Optional[str],
        mode: Optional[str],
        turns: list[dict],
        final_verdict: Optional[dict],
        evidence_summary: Optional[dict[str, Any]],
        start: float,
        degraded: bool,
        degradation_reason: Optional[str] = None,
        l3_reason_code: Optional[str] = None,
    ) -> dict:
        """Build a structured trace dict capturing the L3 reasoning process."""
        tool_calls_used = sum(1 for t in turns if t.get("type") == "tool_call")
        computed_reason_code = (
            str(l3_reason_code).strip() if l3_reason_code is not None else None
        )
        if not computed_reason_code:
            computed_reason_code = self._infer_l3_reason_code(
                trigger_reason=trigger_reason,
                degraded=degraded,
                degradation_reason=degradation_reason,
            )
        return {
            "trigger_reason": trigger_reason,
            "trigger_detail": trigger_detail,
            "skill_selected": skill_selected,
            "mode": mode,
            "turns": turns,
            "final_verdict": final_verdict,
            "total_latency_ms": round((time.monotonic() - start) * 1000, 3),
            "tool_calls_used": tool_calls_used,
            "degraded": degraded,
            "degradation_reason": degradation_reason,
            "l3_reason_code": computed_reason_code,
            "evidence_summary": evidence_summary or {},
        }

    @staticmethod
    def _tool_name_to_evidence_source(tool_name: str) -> Optional[str]:
        mapping = {
            "read_trajectory": "trajectory",
            "read_trajectory_page": "trajectory",
            "read_session_risk": "session_risk",
            "read_transcript": "transcript",
            "read_file": "file",
            "search_codebase": "codebase",
            "query_git_diff": "git_diff",
            "list_directory": "directory",
        }
        return mapping.get(tool_name)

    @staticmethod
    def _count_initial_evidence_sources(
        trajectory: list[dict],
        session_risk_history: list,
    ) -> int:
        return int(bool(trajectory)) + int(bool(session_risk_history))

    def _toolkit_budget_cap(
        self,
        *,
        mode: str,
        trajectory: list[dict],
        session_risk_history: list,
    ) -> int:
        # Keep toolkit budgeting deterministic and analyzer-owned. We tune how
        # much evidence L3 may gather based on the initial evidence already
        # available to the analyzer, without turning ReadOnlyToolkit itself into
        # an adaptive scheduler.
        source_count = self._count_initial_evidence_sources(
            trajectory,
            session_risk_history,
        )
        if mode == "multi_turn":
            return min(self._toolkit.MAX_TOOL_CALLS, 4 + source_count)
        if mode == "single_turn":
            return min(self._toolkit.MAX_TOOL_CALLS, 2 + source_count)
        return self._toolkit.MAX_TOOL_CALLS

    def _build_evidence_summary(
        self,
        *,
        toolkit: ReadOnlyToolkit | None,
        trajectory: list[dict],
        session_risk_history: list,
        workspace_context: dict[str, Any],
        turns: list[dict],
        effective_budget_ms: float,
        start: float,
        toolkit_budget_mode: Optional[str] = None,
        toolkit_budget_cap: Optional[int] = None,
    ) -> dict[str, Any]:
        retained_sources: list[str] = []
        tool_calls: list[dict[str, Any]] = []
        toolkit_calls_remaining = toolkit.calls_remaining if toolkit is not None else None
        toolkit_budget_exhausted: bool | None = None
        if (
            isinstance(toolkit_budget_cap, int)
            and toolkit_budget_cap > 0
            and isinstance(toolkit_calls_remaining, int)
        ):
            toolkit_budget_exhausted = toolkit_calls_remaining <= 0

        def _add_source(source: Optional[str]) -> None:
            if source and source not in retained_sources:
                retained_sources.append(source)

        if trajectory:
            _add_source("trajectory")
        if session_risk_history:
            _add_source("session_risk_history")

        for turn in turns:
            if turn.get("type") != "tool_call":
                continue
            tool_name = str(turn.get("tool_name") or "")
            source = self._tool_name_to_evidence_source(tool_name)
            _add_source(source)
            tool_calls.append(
                {
                    "tool_name": tool_name,
                    "evidence_source": source,
                    "tool_result_length": turn.get("tool_result_length"),
                    "latency_ms": turn.get("latency_ms"),
                }
            )

        remaining_ms = max(0.0, effective_budget_ms - (time.monotonic() - start) * 1000)
        return {
            "retained_sources": retained_sources,
            "tool_calls": tool_calls,
            "trajectory_records": len(trajectory),
            "session_risk_history_records": len(session_risk_history),
            "workspace_context": {
                "workspace_root_bound": bool(workspace_context.get("workspace_root")),
                "transcript_bound": bool(workspace_context.get("transcript_path")),
                "session_bound": bool(workspace_context.get("session_id")),
            },
            "toolkit_budget_mode": toolkit_budget_mode,
            "toolkit_budget_cap": toolkit_budget_cap,
            "toolkit_budget_exhausted": toolkit_budget_exhausted,
            "budget_remaining_ms": round(remaining_ms, 3),
            "toolkit_calls_remaining": toolkit_calls_remaining,
        }

    async def analyze(
        self,
        event: CanonicalEvent,
        context: Optional[DecisionContext],
        l1_snapshot: RiskSnapshot,
        budget_ms: float,
    ) -> L2Result:
        start = time.monotonic()
        workspace_context = self._workspace_context(event)

        # Fetch session risk history for cumulative trigger evaluation
        session_risk_history: list = []
        if self._trajectory_store is not None and event.session_id:
            try:
                session_risk_history = self._trajectory_store.replay_session(
                    event.session_id, limit=50
                )
            except Exception:
                pass  # Degrade gracefully; empty history = stricter trigger threshold

        trigger_metadata = self._trigger_policy.trigger_metadata(
            event, context, l1_snapshot, session_risk_history,
        )
        trigger_reason = None if trigger_metadata is None else trigger_metadata["trigger_reason"]
        trigger_detail = None if trigger_metadata is None else trigger_metadata.get("trigger_detail")
        if trigger_reason is None:
            result = self._degraded(l1_snapshot, start, "L3 trigger not matched")
            trace = self._build_trace(
                trigger_reason="trigger_not_matched",
                trigger_detail=None,
                skill_selected=None, mode=None, turns=[],
                final_verdict=None, start=start,
                degraded=True, degradation_reason="L3 trigger not matched",
                evidence_summary=self._build_evidence_summary(
                    toolkit=None,
                    trajectory=[],
                    session_risk_history=session_risk_history,
                    workspace_context=workspace_context,
                    turns=[],
                    effective_budget_ms=budget_ms,
                    start=start,
                ),
            )
            return L2Result(
                target_level=result.target_level, reasons=result.reasons,
                confidence=result.confidence, analyzer_id=result.analyzer_id,
                latency_ms=result.latency_ms, trace=trace,
                decision_tier=DecisionTier.L1,
            )

        try:
            workspace_root = workspace_context.get("workspace_root")
            transcript_path = workspace_context.get("transcript_path")
            analysis_toolkit = self._toolkit.fork(
                workspace_root=Path(workspace_root) if workspace_root else None,
                transcript_path=transcript_path,
                session_id=event.session_id,
            )
            analysis_toolkit.reset_budget()
            skill = self._skill_registry.select_skill(event, event.risk_hints or [])
            trajectory = await analysis_toolkit.read_trajectory(
                event.session_id,
                limit=self._config.initial_trajectory_limit,
            )
            toolkit_budget_mode = "multi_turn" if self._config.enable_multi_turn else "single_turn"
            toolkit_budget_cap = self._toolkit_budget_cap(
                mode=toolkit_budget_mode,
                trajectory=trajectory,
                session_risk_history=session_risk_history,
            )
            analysis_toolkit.set_calls_remaining(toolkit_budget_cap)
            base_budget = self._config.l3_budget_ms if self._config.l3_budget_ms is not None else budget_ms
            effective_budget = min(
                base_budget, budget_ms, self._config.provider_timeout_ms, self._config.hard_cap_ms
            )

            if self._config.enable_multi_turn:
                return await self._run_multi_turn(
                    analysis_toolkit,
                    event,
                    context,
                    l1_snapshot,
                    skill,
                    trajectory,
                    workspace_context,
                    effective_budget,
                    start,
                    trigger_reason,
                    trigger_detail,
                    session_risk_history,
                    toolkit_budget_mode,
                    toolkit_budget_cap,
                )
            else:
                return await self._run_single_turn(
                    analysis_toolkit,
                    event,
                    l1_snapshot,
                    skill,
                    trajectory,
                    workspace_context,
                    effective_budget,
                    start,
                    trigger_reason,
                    trigger_detail,
                    session_risk_history,
                    toolkit_budget_mode,
                    toolkit_budget_cap,
                )
        except (Exception, asyncio.CancelledError):
            result = self._degraded(
                l1_snapshot, start,
                "L3 analysis degraded; falling back to prior risk assessment",
            )
            trace = self._build_trace(
                trigger_reason=trigger_reason or "triggered",
                trigger_detail=trigger_detail,
                skill_selected=None, mode=None, turns=[],
                final_verdict=None, start=start,
                degraded=True,
                degradation_reason="L3 analysis degraded; falling back to prior risk assessment",
                evidence_summary=self._build_evidence_summary(
                    toolkit=None,
                    trajectory=[],
                    session_risk_history=session_risk_history,
                    workspace_context=workspace_context,
                    turns=[],
                    effective_budget_ms=budget_ms,
                    start=start,
                    toolkit_budget_mode=None,
                    toolkit_budget_cap=None,
                ),
            )
            return L2Result(
                target_level=result.target_level, reasons=result.reasons,
                confidence=result.confidence, analyzer_id=result.analyzer_id,
                latency_ms=result.latency_ms, trace=trace,
                decision_tier=DecisionTier.L1,
            )

    # ------------------------------------------------------------------
    # Single-turn (MVP)
    # ------------------------------------------------------------------

    # Minimum remaining budget (ms) required to attempt a format-correction retry
    _FORMAT_RETRY_MIN_BUDGET_MS: float = 3000.0

    _FORMAT_CORRECTION_PROMPT: str = (
        "Your previous response could not be parsed. "
        "Respond with ONLY a JSON object (no markdown, no explanation) in this exact format:\n"
        '{"risk_level": "low|medium|high|critical", "findings": ["short finding"], "confidence": 0.8}'
    )

    async def _run_single_turn(
        self,
        toolkit: ReadOnlyToolkit,
        event: CanonicalEvent,
        l1_snapshot: RiskSnapshot,
        skill: ReviewSkill,
        trajectory: list[dict],
        workspace_context: dict[str, Any],
        effective_budget: float,
        start: float,
        trigger_reason: str,
        trigger_detail: Optional[str],
        session_risk_history: list,
        toolkit_budget_mode: str,
        toolkit_budget_cap: int,
    ) -> L2Result:
        prompt = self._build_initial_prompt(
            event, l1_snapshot, skill, trajectory, workspace_context
        )

        llm_start = time.monotonic()
        raw = await asyncio.wait_for(
            self._provider.complete(
                skill.system_prompt,
                prompt,
                timeout_ms=effective_budget,
                max_tokens=256,
            ),
            timeout=effective_budget / 1000,
        )
        llm_latency = (time.monotonic() - llm_start) * 1000

        result = self._parse_final_response(raw, l1_snapshot, start)

        turns = [{
            "turn": 1,
            "type": "llm_call",
            "prompt_length": len(prompt),
            "response_raw": raw,
            "latency_ms": round(llm_latency, 3),
        }]

        # Format-correction retry: if first parse degraded and budget allows
        if result.confidence == 0.0:
            remaining_ms = effective_budget - (time.monotonic() - start) * 1000
            if remaining_ms >= self._FORMAT_RETRY_MIN_BUDGET_MS:
                try:
                    retry_start = time.monotonic()
                    retry_raw = await asyncio.wait_for(
                        self._provider.complete(
                            skill.system_prompt,
                            self._FORMAT_CORRECTION_PROMPT,
                            timeout_ms=remaining_ms,
                            max_tokens=256,
                        ),
                        timeout=remaining_ms / 1000,
                    )
                    retry_latency = (time.monotonic() - retry_start) * 1000
                    retry_result = self._parse_final_response(retry_raw, l1_snapshot, start)
                    turns.append({
                        "turn": 2,
                        "type": "format_retry",
                        "prompt_length": len(self._FORMAT_CORRECTION_PROMPT),
                        "response_raw": retry_raw,
                        "latency_ms": round(retry_latency, 3),
                    })
                    if retry_result.confidence > 0.0:
                        result = retry_result
                    else:
                        result = self._degraded(
                            l1_snapshot,
                            start,
                            "L3 format retry failed",
                        )
                except (asyncio.TimeoutError, Exception):
                    pass  # Retry failed; keep original degraded result

        final_verdict: Optional[dict] = None
        if result.confidence > 0.0:
            final_verdict = {
                "risk_level": result.target_level.value,
                "findings": list(result.reasons),
                "confidence": result.confidence,
            }

        trace = self._build_trace(
            trigger_reason=trigger_reason,
            trigger_detail=trigger_detail,
            skill_selected=skill.name,
            mode="single_turn",
            turns=turns,
            final_verdict=final_verdict,
            evidence_summary=self._build_evidence_summary(
                toolkit=toolkit,
                trajectory=trajectory,
                session_risk_history=session_risk_history,
                workspace_context=workspace_context,
                turns=turns,
                effective_budget_ms=effective_budget,
                start=start,
                toolkit_budget_mode=toolkit_budget_mode,
                toolkit_budget_cap=toolkit_budget_cap,
            ),
            start=start,
            degraded=result.confidence == 0.0,
            degradation_reason=(
                result.reasons[0] if result.confidence == 0.0 and result.reasons else None
            ),
        )

        return L2Result(
            target_level=result.target_level, reasons=result.reasons,
            confidence=result.confidence, analyzer_id=result.analyzer_id,
            latency_ms=result.latency_ms, trace=trace,
            decision_tier=result.decision_tier,
        )

    # ------------------------------------------------------------------
    # Multi-turn (standard)
    # ------------------------------------------------------------------

    async def _run_multi_turn(
        self,
        toolkit: ReadOnlyToolkit,
        event: CanonicalEvent,
        context: Optional[DecisionContext],
        l1_snapshot: RiskSnapshot,
        skill: ReviewSkill,
        trajectory: list[dict],
        workspace_context: dict[str, Any],
        effective_budget: float,
        start: float,
        trigger_reason: str,
        trigger_detail: Optional[str],
        session_risk_history: list,
        toolkit_budget_mode: str,
        toolkit_budget_cap: int,
    ) -> L2Result:
        system_prompt = self._build_multi_turn_system_prompt(skill)
        messages: list[dict[str, str]] = [
            {
                "role": "user",
                "content": self._build_initial_prompt(
                    event,
                    l1_snapshot,
                    skill,
                    trajectory,
                    workspace_context,
                ),
            }
        ]

        turns: list[dict] = []
        turn_counter = 0

        def _attach_trace(
            result: L2Result,
            final_verdict: Optional[dict] = None,
            degraded: bool = False,
            degradation_reason: Optional[str] = None,
        ) -> L2Result:
            trace = self._build_trace(
                trigger_reason=trigger_reason,
                trigger_detail=trigger_detail,
                skill_selected=skill.name,
                mode="multi_turn",
                turns=turns,
                final_verdict=final_verdict,
                evidence_summary=self._build_evidence_summary(
                    toolkit=toolkit,
                    trajectory=trajectory,
                    session_risk_history=session_risk_history,
                    workspace_context=workspace_context,
                    turns=turns,
                    effective_budget_ms=effective_budget,
                    start=start,
                    toolkit_budget_mode=toolkit_budget_mode,
                    toolkit_budget_cap=toolkit_budget_cap,
                ),
                start=start,
                degraded=degraded,
                degradation_reason=degradation_reason,
            )
            return L2Result(
                target_level=result.target_level, reasons=result.reasons,
                confidence=result.confidence, analyzer_id=result.analyzer_id,
                latency_ms=result.latency_ms, trace=trace,
                decision_tier=result.decision_tier,
            )

        for _turn in range(self._config.max_reasoning_turns):
            elapsed = (time.monotonic() - start) * 1000
            remaining = effective_budget - elapsed
            if remaining <= 0:
                result = self._degraded(l1_snapshot, start, "L3 hard cap exceeded")
                return _attach_trace(
                    result, degraded=True,
                    degradation_reason="L3 hard cap exceeded",
                )

            msg_json = json.dumps(messages, ensure_ascii=False)
            llm_start = time.monotonic()
            try:
                raw = await asyncio.wait_for(
                    self._provider.complete(
                        system_prompt,
                        msg_json,
                        timeout_ms=min(remaining, self._config.provider_timeout_ms),
                        max_tokens=512,
                    ),
                    timeout=min(remaining, self._config.provider_timeout_ms) / 1000,
                )
            except (asyncio.TimeoutError, Exception):
                result = self._degraded(l1_snapshot, start, "L3 LLM call failed")
                return _attach_trace(
                    result, degraded=True,
                    degradation_reason="L3 LLM call failed",
                )

            llm_latency = (time.monotonic() - llm_start) * 1000
            turn_counter += 1
            turns.append({
                "turn": turn_counter,
                "type": "llm_call",
                "prompt_length": len(msg_json),
                "response_raw": raw,
                "latency_ms": round(llm_latency, 3),
            })

            # Try to parse as tool_call or final response
            parsed = self._parse_tool_call_response(raw)
            if parsed is None:
                # Not a valid tool_call response -- try as final
                result = self._parse_final_response(raw, l1_snapshot, start)
                final_verdict = (
                    {"risk_level": result.target_level.value,
                     "findings": list(result.reasons),
                     "confidence": result.confidence}
                    if result.confidence > 0.0 else None
                )
                return _attach_trace(
                    result, final_verdict=final_verdict,
                    degraded=result.confidence == 0.0,
                    degradation_reason=(
                        result.reasons[0]
                        if result.confidence == 0.0 and result.reasons else None
                    ),
                )

            tool_name, tool_args, done = parsed
            if done:
                # done=True in tool_call response means final without tool
                result = self._parse_final_response(raw, l1_snapshot, start)
                final_verdict = (
                    {"risk_level": result.target_level.value,
                     "findings": list(result.reasons),
                     "confidence": result.confidence}
                    if result.confidence > 0.0 else None
                )
                return _attach_trace(
                    result, final_verdict=final_verdict,
                    degraded=result.confidence == 0.0,
                )

            # Validate tool name against whitelist
            if tool_name not in _ALLOWED_TOOL_CALLS:
                reason = f"L3 requested non-whitelisted tool: {tool_name}"
                result = self._degraded(l1_snapshot, start, reason)
                return _attach_trace(
                    result, degraded=True, degradation_reason=reason,
                )

            # Execute the toolkit call
            tool_start = time.monotonic()
            try:
                tool_result = await self._execute_tool(toolkit, tool_name, tool_args)
            except ToolCallBudgetExhausted:
                reason = "L3 tool call budget exhausted"
                result = self._degraded(l1_snapshot, start, reason)
                return _attach_trace(
                    result, degraded=True, degradation_reason=reason,
                )
            tool_latency = (time.monotonic() - tool_start) * 1000
            turn_counter += 1
            tool_result_str = (
                json.dumps(tool_result)
                if not isinstance(tool_result, str) else tool_result
            )
            turns.append({
                "turn": turn_counter,
                "type": "tool_call",
                "tool_name": tool_name,
                "tool_args": tool_args,
                "tool_result_length": len(tool_result_str),
                "latency_ms": round(tool_latency, 3),
            })

            messages.append({"role": "assistant", "content": raw})
            messages.append({"role": "user", "content": json.dumps({"tool_result": tool_result})})

        result = self._degraded(l1_snapshot, start, "L3 max reasoning turns exceeded")
        return _attach_trace(
            result, degraded=True,
            degradation_reason="L3 max reasoning turns exceeded",
        )

    async def _execute_tool(
        self,
        toolkit: ReadOnlyToolkit,
        tool_name: str,
        tool_args: dict[str, Any],
    ) -> Any:
        try:
            method = getattr(toolkit, tool_name)
            return await method(**tool_args)
        except ToolCallBudgetExhausted:
            raise
        except Exception as exc:
            return {"error": str(exc)}

    # ------------------------------------------------------------------
    # Prompt builders
    # ------------------------------------------------------------------

    def _workspace_context(self, event: CanonicalEvent) -> dict[str, Any]:
        payload = event.payload if isinstance(event.payload, dict) else {}
        workspace_root = str(
            payload.get("cwd")
            or payload.get("working_directory")
            or payload.get("workspace_root")
            or ""
        )
        transcript_path = str(payload.get("transcript_path") or "")
        if (not workspace_root or not transcript_path) and self._session_registry is not None:
            try:
                session_stats = self._session_registry.get_session_stats(event.session_id)
            except Exception:
                session_stats = {}
            if not workspace_root:
                workspace_root = str(session_stats.get("workspace_root") or "")
            if not transcript_path:
                transcript_path = str(session_stats.get("transcript_path") or "")
        return {
            "session_id": event.session_id,
            "agent_id": event.agent_id,
            "source_framework": event.source_framework,
            "workspace_root": workspace_root,
            "transcript_path": transcript_path,
        }

    def _build_initial_prompt(
        self,
        event: CanonicalEvent,
        l1_snapshot: RiskSnapshot,
        skill: ReviewSkill,
        trajectory: list[dict],
        workspace_context: dict[str, Any],
    ) -> str:
        trajectory_summary = [
            {
                "recorded_at": item.get("recorded_at"),
                "tool_name": item.get("event", {}).get("tool_name"),
                "event_type": item.get("event", {}).get("event_type"),
                "risk_hints": item.get("event", {}).get("risk_hints", []),
                "risk_level": item.get("risk_level"),
            }
            for item in trajectory
        ]
        payload = {
            "skill": {
                "name": skill.name,
                "description": skill.description,
                "evaluation_criteria": skill.evaluation_criteria,
            },
            "event": event.model_dump(mode="json"),
            "workspace_context": workspace_context,
            "l1_snapshot": l1_snapshot.model_dump(mode="json"),
            "trajectory_summary": trajectory_summary,
            "constraints": {
                "must_not_downgrade_below_l1": True,
                "final_response_format": {
                    "risk_level": "low|medium|high|critical",
                    "findings": ["short finding"],
                    "confidence": 0.0,
                },
            },
        }
        return json.dumps(payload, ensure_ascii=False, sort_keys=True)

    def _build_multi_turn_system_prompt(self, skill: ReviewSkill) -> str:
        return (
            skill.system_prompt
            + "\n\n"
            + "You may call read-only tools to gather more evidence. "
            + "Each intermediate response must be JSON: "
            + '{"thought": "...", "tool_call": {"name": "<tool>", "arguments": {...}}, "done": false}. '
            + "Available tools: read_trajectory, read_trajectory_page, read_file, read_transcript, read_session_risk, "
            + "search_codebase, query_git_diff, list_directory. "
            + "When you have enough information, respond with the final JSON ONLY: "
            + '{"risk_level": "low|medium|high|critical", "findings": ["..."], "confidence": 0.0}.'
        )

    # ------------------------------------------------------------------
    # Response parsers
    # ------------------------------------------------------------------

    def _parse_tool_call_response(
        self, raw: str
    ) -> Optional[tuple[str, dict[str, Any], bool]]:
        """Return (tool_name, tool_args, done) if raw is a tool-call response, else None."""
        try:
            data = json.loads(raw)
            if not isinstance(data, dict):
                return None
            done = bool(data.get("done", False))
            tool_call = data.get("tool_call")
            if tool_call is None:
                return None
            if not isinstance(tool_call, dict):
                return None
            tool_name = str(tool_call.get("name") or "")
            tool_args = tool_call.get("arguments") or {}
            if not isinstance(tool_args, dict):
                tool_args = {}
            if not tool_name:
                return None
            return tool_name, tool_args, done
        except (json.JSONDecodeError, TypeError):
            return None

    # Mapping of non-standard risk level strings to RiskLevel values
    _RISK_LEVEL_ALIASES: dict[str, RiskLevel] = {
        "none": RiskLevel.LOW,
        "safe": RiskLevel.LOW,
        "informational": RiskLevel.LOW,
        "info": RiskLevel.LOW,
        "minor": RiskLevel.LOW,
        "moderate": RiskLevel.MEDIUM,
        "warning": RiskLevel.MEDIUM,
        "severe": RiskLevel.HIGH,
        "danger": RiskLevel.HIGH,
        "dangerous": RiskLevel.CRITICAL,
        "fatal": RiskLevel.CRITICAL,
    }

    # Regex to strip markdown code block wrappers
    _MARKDOWN_CODE_BLOCK_RE = re.compile(
        r"^\s*```(?:json)?\s*\n?(.*?)\n?\s*```\s*$", re.DOTALL
    )

    @staticmethod
    def _strip_markdown(raw: str) -> str:
        """Strip markdown code block wrappers (```json ... ```)."""
        m = AgentAnalyzer._MARKDOWN_CODE_BLOCK_RE.match(raw.strip())
        return m.group(1).strip() if m else raw.strip()

    @staticmethod
    def _extract_risk_level_from_data(data: dict) -> str | None:
        """Search for risk level in common JSON structures."""
        # Direct field: {"risk_level": "high"}
        if "risk_level" in data:
            return str(data["risk_level"]).lower()
        # Nested: {"risk_assessment": {"level": "high"}}
        for key in ("risk_assessment", "risk", "assessment", "result"):
            nested = data.get(key)
            if isinstance(nested, dict):
                for field in ("level", "risk_level", "severity", "risk"):
                    if field in nested:
                        return str(nested[field]).lower()
        # Top-level "level" or "severity"
        for field in ("level", "severity", "risk"):
            if field in data:
                return str(data[field]).lower()
        return None

    @staticmethod
    def _extract_findings_from_data(data: dict) -> list[str]:
        """Search for findings/reasons in common JSON structures."""
        for key in ("findings", "reasons", "issues", "concerns", "analysis"):
            val = data.get(key)
            if isinstance(val, list):
                return [str(item) for item in val]
            if isinstance(val, str):
                return [val]
            if isinstance(val, dict):
                # e.g. {"analysis": {"description": "..."}}
                desc = val.get("description") or val.get("summary") or val.get("detail")
                if desc:
                    return [str(desc)]
        return []

    def _resolve_risk_level(self, raw_level: str | None) -> RiskLevel | None:
        """Resolve a raw risk level string to RiskLevel, handling aliases."""
        if raw_level is None:
            return None
        try:
            return RiskLevel(raw_level)
        except ValueError:
            return self._RISK_LEVEL_ALIASES.get(raw_level)

    def _parse_final_response(
        self,
        raw: str,
        l1_snapshot: RiskSnapshot,
        start: float,
    ) -> L2Result:
        elapsed_ms = (time.monotonic() - start) * 1000
        try:
            cleaned = self._strip_markdown(raw)
            try:
                data = json.loads(cleaned)
            except (json.JSONDecodeError, TypeError, ValueError):
                return self._degraded(
                    l1_snapshot,
                    start,
                    "L3 response parse failed",
                )
            if not isinstance(data, dict):
                return self._degraded(
                    l1_snapshot,
                    start,
                    "L3 response parse failed",
                )

            raw_level = self._extract_risk_level_from_data(data)
            risk_level = self._resolve_risk_level(raw_level)
            if risk_level is None:
                return self._degraded(
                    l1_snapshot,
                    start,
                    "L3 response unresolvable risk level",
                )

            findings = self._extract_findings_from_data(data)
            confidence = float(data.get("confidence", 0.7))
            confidence = max(0.0, min(1.0, confidence))
            target_level = _max_risk_level(risk_level, l1_snapshot.risk_level)
            return L2Result(
                target_level=target_level,
                reasons=[str(item) for item in findings[: self._config.max_findings]],
                confidence=confidence,
                analyzer_id=self.analyzer_id,
                latency_ms=round(elapsed_ms, 3),
                decision_tier=DecisionTier.L3,
            )
        except Exception:
            return self._degraded(
                l1_snapshot, start,
                "L3 analysis degraded; falling back to prior risk assessment",
            )

    def _degraded(self, l1_snapshot: RiskSnapshot, start: float, reason: str) -> L2Result:
        elapsed_ms = (time.monotonic() - start) * 1000
        return L2Result(
            target_level=l1_snapshot.risk_level,
            reasons=[reason],
            confidence=0.0,
            analyzer_id=self.analyzer_id,
            latency_ms=round(elapsed_ms, 3),
            decision_tier=DecisionTier.L1,
        )
