#!/usr/bin/env python3
"""
Brain module for Kimi Copilot.

Maintains session context, decides when to invoke the side model,
calls the side model, and writes valuable corrections to RAG.
"""

import difflib
import json
import logging
import os
import re
import string
from datetime import datetime
from pathlib import Path
from typing import Any

import requests
import yaml

from copilot_panel import HintPanel


logger = logging.getLogger(__name__)


def _resolve_env(value: Any) -> Any:
    """Replace ${VAR:default} placeholders in config values."""
    if not isinstance(value, str):
        return value
    pattern = re.compile(r"\$\{([^}:]+)(?::([^}]*))?\}")

    def repl(match):
        var_name = match.group(1)
        default = match.group(2) or ""
        return os.environ.get(var_name, default)

    return pattern.sub(repl, value)


def _deep_resolve_env(obj: Any) -> Any:
    if isinstance(obj, dict):
        return {k: _deep_resolve_env(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_deep_resolve_env(item) for item in obj]
    return _resolve_env(obj)


class Brain:
    def __init__(
        self,
        config_path: str = "config.yaml",
        panel: HintPanel = None,
        notify_callback=None,
    ):
        self.config = self._load_config(config_path)
        self.history: list[dict] = []
        self.pending_input = ""
        self.pending_output = ""
        self.round_count = 0
        self.last_trigger_round = 0
        self.panel = panel or HintPanel()
        # Callback for user-visible notifications. In a raw PTY wrapper this
        # should be wired to a queue so output is serialized with the main loop.
        self.notify_callback = notify_callback or (lambda msg: print(msg, end=""))

        # Current project identifier, used to scope RAG queries and tag writes.
        self.project = self._detect_project()
        if self.project:
            logger.info("Detected project: %s", self.project)

        # Track recent issues in the current session to detect stuck loops.
        self.recent_issues: list[dict] = []
        # Avoid spamming the same stuck alert within a session.
        self.stuck_alerts_shown: set[str] = set()
        # Throttle triggers to avoid spamming the side model.
        self.last_trigger_time = 0.0
        # Throttle real-time scans separately from deep triggers.
        self.last_scan_time = 0.0

        # Frustration detection state.
        self.frustration_score = 0
        self.last_frustration_time = 0.0
        self.pending_frustration_summary = False
        self.frustration_trigger_time = 0.0
        self.last_frustration_summary_time = 0.0
        self.last_frustration_check_time = 0.0
        self.frustration_notified = False

        self._setup_logging()

    def _load_config(self, path: str) -> dict:
        with open(path, "r", encoding="utf-8") as f:
            raw = yaml.safe_load(f)
        return _deep_resolve_env(raw)

    def _setup_logging(self):
        level = self.config.get("logging", {}).get("level", "INFO")
        log_path = self.config.get("logging", {}).get("file", "kimi-copilot.log")

        root = logging.getLogger()
        if root.handlers:
            return

        handler = logging.FileHandler(log_path, encoding="utf-8")
        handler.setFormatter(
            logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
        )
        root.setLevel(getattr(logging, level.upper(), logging.INFO))
        root.addHandler(handler)

    def _detect_project(self) -> str:
        """Determine the current project for scoping knowledge.

        Priority:
        1. COPILOT_PROJECT environment variable
        2. config.yaml ``project`` field
        3. The current working directory name
        """
        project = os.environ.get("COPILOT_PROJECT", "").strip()
        if project:
            return project

        project = self.config.get("project", "").strip()
        if project:
            return project

        cwd = os.getcwd().replace("\\", "/")
        parts = [p for p in cwd.split("/") if p]
        if parts:
            return parts[-1]
        return ""

    @staticmethod
    def strip_ansi(text: str) -> str:
        """Remove ANSI escape sequences including CSI and OSC (e.g. hyperlinks)."""
        # OSC must come before the generic [@-Z\-_] range because ']' is in that range.
        ansi_escape = re.compile(
            r"\x1B(?:"
            r"\][^\x07\x1b]*(?:\x07|\x1b\\)"  # OSC sequences terminated by BEL or ST
            r"|[@-Z\\-_]"  # single ESC sequences
            r"|\[[0-?]*[ -/]*[@-~]"  # CSI sequences
            r")"
        )
        return ansi_escape.sub("", text)

    @staticmethod
    def _matches_any(text: str, patterns: list[str]) -> bool:
        lowered = text.lower()
        for pattern in patterns:
            if pattern.lower() in lowered:
                return True
        return False

    @staticmethod
    def _matches_word_boundary(text: str, patterns: list[str]) -> bool:
        """Match patterns at a word boundary on the left side.

        This avoids firing on embedded substrings like "可能性" while still
        allowing Chinese phrases such as "我可能错了" to trigger.
        """
        lowered = text.lower()
        for pattern in patterns:
            needle = pattern.lower()
            start = 0
            while True:
                idx = lowered.find(needle, start)
                if idx == -1:
                    break
                # Check preceding character: must be start of string or a
                # non-alphanumeric/non-connector character.
                if idx > 0:
                    prev = lowered[idx - 1]
                    if prev.isalnum() or prev in "_-":
                        start = idx + len(needle)
                        continue
                return True
        return False

    def _should_trigger(self, cooldown_seconds: float = 5.0) -> bool:
        """Throttle side-model triggers to avoid spam."""
        now = datetime.now().timestamp()
        if now - self.last_trigger_time < cooldown_seconds:
            return False
        self.last_trigger_time = now
        return True

    def _should_scan(self, cooldown_seconds: float = 3.0) -> bool:
        """Throttle real-time scans to avoid spamming the side model."""
        now = datetime.now().timestamp()
        if now - self.last_scan_time < cooldown_seconds:
            return False
        self.last_scan_time = now
        return True

    def feed_user_input(self, text: str):
        """Feed a chunk of user input into the brain."""
        clean = self.strip_ansi(text)
        self.pending_input += clean
        if "\n" in self.pending_input or "\r" in self.pending_input:
            line = self.pending_input.strip()
            if line:
                logger.debug("User input: %r", line)
                self.history.append({"role": "user", "content": line})
                self._prune_history()
                self._check_frustration(line)
                self._check_trigger(line, source="user")
            self.pending_input = ""

    def feed_kimi_output(self, text: str):
        """Feed a chunk of Kimi output into the brain."""
        clean = self.strip_ansi(text)
        self.pending_output += clean
        if "\n" in self.pending_output or "\r" in self.pending_output:
            lines = re.split(r"[\r\n]+", self.pending_output)
            self.pending_output = lines[-1]
            for line in lines[:-1]:
                line = line.strip()
                if line:
                    logger.debug("Kimi output: %r", line)
                    self.history.append({"role": "kimi", "content": line})
                    self._prune_history()
                    self._check_resolution(line)
                    self._check_trigger(line, source="kimi")

    def feed_tool_output(self, text: str):
        """Feed explicit tool/command output (e.g. failure messages)."""
        clean = self.strip_ansi(text).strip()
        if clean:
            logger.debug("Tool output: %r", clean)
            self.history.append({"role": "tool", "content": clean})
            self._prune_history()
            self._check_trigger(clean, source="tool")

    def _prune_history(self):
        """Keep history bounded so memory and request size do not explode."""
        max_chars = self.config.get("copilot", {}).get("max_history_chars", 200000)
        total = sum(len(m.get("content", "")) for m in self.history)
        while total > max_chars and self.history:
            removed = self.history.pop(0)
            total -= len(removed.get("content", ""))

    def _check_trigger(self, text: str, source: str):
        """Decide whether to invoke the side model."""
        triggers = self.config.get("copilot", {}).get("triggers", {})
        cooldown = self.config.get("copilot", {}).get("trigger_cooldown_seconds", 5.0)

        # 1. User explicitly invokes the copilot.
        if triggers.get("user_invoke", True) and source == "user":
            invoke_keywords = self.config.get("copilot", {}).get("invoke_keywords", [])
            if self._matches_any(text, invoke_keywords):
                if self._should_trigger(cooldown):
                    logger.info("Triggered by user invoke: %r", text)
                    self._invoke_copilot()
                return

        # 2. Command / tool failure.
        if triggers.get("command_failure", True) and source in ("kimi", "tool"):
            failure_patterns = self.config.get("copilot", {}).get("failure_patterns", [])
            if self._matches_any(text, failure_patterns):
                if self._should_trigger(cooldown):
                    logger.info("Triggered by failure pattern: %r", text)
                    self._invoke_copilot()
                return

        # 3. Kimi expresses uncertainty.
        if triggers.get("uncertainty_keywords", True) and source == "kimi":
            uncertainty_patterns = self.config.get("copilot", {}).get("uncertainty_patterns", [])
            if self._matches_word_boundary(text, uncertainty_patterns):
                if self._should_trigger(cooldown):
                    logger.info("Triggered by uncertainty keyword: %r", text)
                    self._invoke_copilot()
                return

        # 4. Periodic trigger every N rounds.
        periodic = triggers.get("periodic_rounds", 0)
        if periodic > 0 and source == "user":
            self.round_count += 1
            if self.round_count - self.last_trigger_round >= periodic:
                if self._should_trigger(cooldown):
                    logger.info("Triggered periodically at round %d", self.round_count)
                    self._invoke_copilot()
                return

        # 5. Real-time scan as a fallback for semantic issues.
        self._scan_and_trigger()

    def _build_messages(self) -> list[dict]:
        system_path = self.config.get("prompts", {}).get("system", "prompts/copilot_system.txt")
        if os.path.exists(system_path):
            with open(system_path, "r", encoding="utf-8") as f:
                system_content = f.read()
        else:
            system_content = "You are a helpful coding assistant."

        messages = [{"role": "system", "content": system_content}]
        max_history = self.config.get("copilot", {}).get("max_history_lines", 100)
        recent_history = self.history[-max_history:] if len(self.history) > max_history else self.history
        for msg in recent_history:
            role = msg["role"]
            if role == "kimi":
                role = "assistant"
            elif role == "tool":
                role = "user"
            messages.append({"role": role, "content": msg["content"]})
        return messages

    def _build_scan_messages(self) -> list[dict]:
        """Build a short context for the lightweight real-time scan."""
        scan_path = self.config.get("prompts", {}).get("scan", "prompts/copilot_scan.txt")
        if os.path.exists(scan_path):
            with open(scan_path, "r", encoding="utf-8") as f:
                system_content = f.read()
        else:
            system_content = (
                "You are a helpful coding assistant. "
                "Review the conversation and output JSON with has_issue and risk_score (0-10)."
            )

        messages = [{"role": "system", "content": system_content}]
        scan_cfg = self.config.get("copilot", {}).get("real_time_scan", {})
        max_history = scan_cfg.get("max_history_lines", 20)
        recent_history = self.history[-max_history:] if len(self.history) > max_history else self.history
        for msg in recent_history:
            role = msg["role"]
            if role == "kimi":
                role = "assistant"
            elif role == "tool":
                role = "user"
            messages.append({"role": role, "content": msg["content"]})
        return messages

    def _invoke_copilot(self):
        """Call the side model and handle the result."""
        if not self.history:
            return

        self.last_trigger_round = self.round_count

        messages = self._build_messages()

        # Hard guardrails against oversized requests that cause 400/413.
        max_messages = self.config.get("copilot", {}).get("max_messages_hard", 120)
        max_request_chars = self.config.get("copilot", {}).get("max_request_chars", 120000)
        request_size = sum(len(m.get("content", "")) for m in messages)
        if len(messages) > max_messages:
            logger.warning(
                "Skipping side model call: too many messages (%d > %d)",
                len(messages),
                max_messages,
            )
            return
        if request_size > max_request_chars:
            logger.warning(
                "Skipping side model call: request too large (%d > %d chars)",
                request_size,
                max_request_chars,
            )
            return

        cfg = self.config.get("model", {})
        payload = {
            "model": cfg.get("name"),
            "messages": messages,
            "temperature": self.config.get("copilot", {}).get("temperature", 0.2),
            "max_tokens": self.config.get("copilot", {}).get("max_tokens", 4096),
        }

        logger.info("Calling side model with %d messages", len(messages))
        try:
            resp = requests.post(
                cfg.get("base_url"),
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {cfg.get('api_key', '')}",
                },
                json=payload,
                timeout=self.config.get("copilot", {}).get("timeout", 120),
            )
            if not resp.ok:
                logger.error(
                    "Side model request failed: %s %s - %s",
                    resp.status_code,
                    resp.reason,
                    resp.text[:500],
                )
                return
            resp.raise_for_status()
        except requests.RequestException as e:
            logger.error("Side model request failed: %s", e)
            return

        try:
            data = resp.json()
            content = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, json.JSONDecodeError) as e:
            logger.error("Failed to parse side model response: %s", e)
            return

        self._handle_copilot_result(content)

    def _invoke_scan(self) -> dict | None:
        """Call the side model for a lightweight risk scan."""
        if not self.history:
            return None

        messages = self._build_scan_messages()

        # Hard guardrails against oversized requests that cause 400/413.
        max_messages = self.config.get("copilot", {}).get("max_messages_hard", 120)
        max_request_chars = self.config.get("copilot", {}).get("max_request_chars", 120000)
        request_size = sum(len(m.get("content", "")) for m in messages)
        if len(messages) > max_messages:
            logger.warning(
                "Skipping real-time scan: too many messages (%d > %d)",
                len(messages),
                max_messages,
            )
            return None
        if request_size > max_request_chars:
            logger.warning(
                "Skipping real-time scan: request too large (%d > %d chars)",
                request_size,
                max_request_chars,
            )
            return None

        cfg = self.config.get("model", {})
        scan_cfg = self.config.get("copilot", {}).get("real_time_scan", {})
        payload = {
            "model": cfg.get("name"),
            "messages": messages,
            "temperature": self.config.get("copilot", {}).get("temperature", 0.2),
            "max_tokens": scan_cfg.get("max_tokens", 512),
        }

        logger.info("Calling side model for real-time scan with %d messages", len(messages))
        try:
            resp = requests.post(
                cfg.get("base_url"),
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {cfg.get('api_key', '')}",
                },
                json=payload,
                timeout=scan_cfg.get("timeout", 30),
            )
            if not resp.ok:
                logger.error(
                    "Real-time scan request failed: %s %s - %s",
                    resp.status_code,
                    resp.reason,
                    resp.text[:500],
                )
                return None
            resp.raise_for_status()
        except requests.RequestException as e:
            logger.error("Real-time scan request failed: %s", e)
            return None

        try:
            data = resp.json()
            content = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, json.JSONDecodeError) as e:
            logger.error("Failed to parse scan response: %s", e)
            return None

        result = self._extract_json(content)
        if not result:
            logger.warning("Scan did not return valid JSON: %r", content[:200])
            return None

        return result

    def _scan_and_trigger(self):
        """Lightweight qwen scan after each turn; triggers deep analysis if risky."""
        scan_cfg = self.config.get("copilot", {}).get("real_time_scan", {})
        if not scan_cfg.get("enabled", False):
            return

        cooldown = scan_cfg.get("cooldown_seconds", 3.0)
        if not self._should_scan(cooldown):
            return

        result = self._invoke_scan()
        if not result:
            return

        has_issue = result.get("has_issue", False)
        risk_score = result.get("risk_score", 0)
        threshold = scan_cfg.get("threshold", 5)

        logger.info(
            "Scan result: has_issue=%s, risk_score=%s, threshold=%s",
            has_issue,
            risk_score,
            threshold,
        )

        if not has_issue and risk_score < threshold:
            return

        logger.info("Scan triggered deep analysis")
        self._invoke_copilot()

    def _decay_frustration(self):
        """Reset frustration score if too much time has passed without new signals."""
        cfg = self.config.get("copilot", {}).get("frustration_detection", {})
        decay = cfg.get("decay_seconds", 300)
        now = datetime.now().timestamp()
        if now - self.last_frustration_time > decay:
            if self.frustration_score > 0:
                logger.debug("Frustration score decayed from %d to 0", self.frustration_score)
            self.frustration_score = 0
            self.frustration_notified = False

    def _check_frustration(self, text: str):
        """Use qwen to semantically detect user frustration and mark for later summarization."""
        cfg = self.config.get("copilot", {}).get("frustration_detection", {})
        if not cfg.get("enabled", False):
            return

        self._decay_frustration()

        now = datetime.now().timestamp()
        cooldown = cfg.get("cooldown_seconds", 2.0)
        if now - self.last_frustration_check_time < cooldown:
            return
        self.last_frustration_check_time = now

        result = self._invoke_frustration_check(text)
        if not result:
            return

        score = result.get("score", 0)
        if not isinstance(score, (int, float)) or score <= 0:
            return

        self.frustration_score += int(score)
        self.last_frustration_time = now
        logger.info(
            "Frustration check score=%s, accumulated=%d",
            score,
            self.frustration_score,
        )

        threshold = cfg.get("threshold", 6)
        if self.frustration_score >= threshold:
            self.pending_frustration_summary = True
            self.frustration_trigger_time = now
            if not self.frustration_notified:
                self.frustration_notified = True
                self._notify_frustration_detected()

    def _invoke_frustration_check(self, text: str) -> dict | None:
        """Call qwen to judge whether the user sounds frustrated."""
        messages = self._build_frustration_check_messages(text)

        max_messages = self.config.get("copilot", {}).get("max_messages_hard", 120)
        max_request_chars = self.config.get("copilot", {}).get("max_request_chars", 120000)
        request_size = sum(len(m.get("content", "")) for m in messages)
        if len(messages) > max_messages or request_size > max_request_chars:
            return None

        cfg = self.config.get("copilot", {}).get("frustration_detection", {})
        model_cfg = self.config.get("model", {})
        payload = {
            "model": model_cfg.get("name"),
            "messages": messages,
            "temperature": self.config.get("copilot", {}).get("temperature", 0.2),
            "max_tokens": cfg.get("max_tokens", 128),
        }

        try:
            resp = requests.post(
                model_cfg.get("base_url"),
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {model_cfg.get('api_key', '')}",
                },
                json=payload,
                timeout=cfg.get("timeout", 15),
            )
            if not resp.ok:
                logger.error(
                    "Frustration check request failed: %s %s - %s",
                    resp.status_code,
                    resp.reason,
                    resp.text[:500],
                )
                return None
            resp.raise_for_status()
        except requests.RequestException as e:
            logger.error("Frustration check request failed: %s", e)
            return None

        try:
            data = resp.json()
            content = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, json.JSONDecodeError) as e:
            logger.error("Failed to parse frustration check response: %s", e)
            return None

        result = self._extract_json(content)
        if not result:
            logger.warning("Frustration check did not return valid JSON: %r", content[:200])
            return None
        return result

    def _build_frustration_check_messages(self, text: str) -> list[dict]:
        """Build a short prompt asking qwen to judge user frustration.

        Includes the most recent conversation turns so qwen can understand
        context and distinguish a normal correction from real frustration.
        """
        prompt_path = self.config.get("prompts", {}).get(
            "frustration", "prompts/copilot_frustration.txt"
        )
        if os.path.exists(prompt_path):
            with open(prompt_path, "r", encoding="utf-8") as f:
                system_content = f.read()
        else:
            system_content = (
                "Judge whether the user input shows frustration or dissatisfaction. "
                "Output JSON with is_frustrated (bool) and score (0-10)."
            )

        messages = [{"role": "system", "content": system_content}]
        # Include recent turns for context; the final message is the one to judge.
        recent_history = self.history[-6:] if len(self.history) > 6 else self.history
        for msg in recent_history:
            role = msg["role"]
            if role == "kimi":
                role = "assistant"
            elif role == "tool":
                role = "user"
            messages.append({"role": role, "content": msg["content"]})
        messages.append({"role": "user", "content": f"【当前输入】{text}"})
        return messages

    def _check_resolution(self, text: str):
        """If the user was frustrated and Kimi now seems to have solved it, summarize to RAG."""
        if not self.pending_frustration_summary:
            return

        cfg = self.config.get("copilot", {}).get("frustration_detection", {})
        if not cfg.get("enabled", False):
            return

        now = datetime.now().timestamp()
        window = cfg.get("summary_window_seconds", 600)
        if now - self.frustration_trigger_time > window:
            logger.info("Frustration summary window expired, resetting")
            self._reset_frustration_state()
            return

        cooldown = cfg.get("summary_cooldown_seconds", 60)
        if now - self.last_frustration_summary_time < cooldown:
            return

        patterns = cfg.get("resolution_patterns", [])
        if not patterns:
            return

        if self._matches_any(text, patterns):
            logger.info("Resolution signal detected after frustration, triggering RAG summary")
            self._invoke_frustration_rag_summary()

    def _reset_frustration_state(self):
        self.frustration_score = 0
        self.pending_frustration_summary = False
        self.frustration_trigger_time = 0.0
        self.frustration_notified = False

    def _invoke_frustration_rag_summary(self):
        """Call the side model to summarize a frustration-resolution arc into RAG."""
        messages = self._build_frustration_rag_messages()

        max_messages = self.config.get("copilot", {}).get("max_messages_hard", 120)
        max_request_chars = self.config.get("copilot", {}).get("max_request_chars", 120000)
        request_size = sum(len(m.get("content", "")) for m in messages)
        if len(messages) > max_messages:
            logger.warning(
                "Skipping frustration RAG summary: too many messages (%d > %d)",
                len(messages),
                max_messages,
            )
            self._reset_frustration_state()
            return
        if request_size > max_request_chars:
            logger.warning(
                "Skipping frustration RAG summary: request too large (%d > %d chars)",
                request_size,
                max_request_chars,
            )
            self._reset_frustration_state()
            return

        cfg = self.config.get("model", {})
        payload = {
            "model": cfg.get("name"),
            "messages": messages,
            "temperature": self.config.get("copilot", {}).get("temperature", 0.2),
            "max_tokens": self.config.get("copilot", {}).get("max_tokens", 4096),
        }

        logger.info("Calling side model for frustration RAG summary with %d messages", len(messages))
        try:
            resp = requests.post(
                cfg.get("base_url"),
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {cfg.get('api_key', '')}",
                },
                json=payload,
                timeout=self.config.get("copilot", {}).get("timeout", 120),
            )
            if not resp.ok:
                logger.error(
                    "Frustration RAG summary request failed: %s %s - %s",
                    resp.status_code,
                    resp.reason,
                    resp.text[:500],
                )
                self._reset_frustration_state()
                return
            resp.raise_for_status()
        except requests.RequestException as e:
            logger.error("Frustration RAG summary request failed: %s", e)
            self._reset_frustration_state()
            return

        try:
            data = resp.json()
            content = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, json.JSONDecodeError) as e:
            logger.error("Failed to parse frustration RAG summary response: %s", e)
            self._reset_frustration_state()
            return

        self._handle_frustration_rag_result(content)

    def _build_frustration_rag_messages(self) -> list[dict]:
        """Build messages summarizing the full frustration arc."""
        prompt_path = self.config.get("prompts", {}).get(
            "frustration_rag", "prompts/copilot_frustration_rag.txt"
        )
        if os.path.exists(prompt_path):
            with open(prompt_path, "r", encoding="utf-8") as f:
                system_content = f.read()
        else:
            system_content = (
                "Summarize the following coding conversation into a structured RAG entry. "
                "Output JSON with worth_rag and rag_entry fields."
            )

        messages = [{"role": "system", "content": system_content}]
        for msg in self.history:
            role = msg["role"]
            if role == "kimi":
                role = "assistant"
            elif role == "tool":
                role = "user"
            messages.append({"role": role, "content": msg["content"]})
        return messages

    def _handle_frustration_rag_result(self, content: str):
        """Parse the side model output and write to RAG if valuable."""
        self.last_frustration_summary_time = datetime.now().timestamp()
        self._reset_frustration_state()

        result = self._extract_json(content)
        if not result:
            logger.warning("Frustration RAG summary did not return valid JSON: %r", content[:200])
            return

        if not result.get("worth_rag"):
            logger.info("Frustration RAG summary deemed not worth storing.")
            return

        rag_entry = result.get("rag_entry", {})
        if not rag_entry:
            logger.warning("Frustration RAG summary returned worth_rag=true but no rag_entry")
            return

        title = rag_entry.get("title", "未命名知识")
        logger.info("Frustration RAG summary produced entry: %s", title)
        self._write_rag(rag_entry)
        self._notify_frustration_rag_write(title)

    def _notify_frustration_detected(self):
        """Let the user know we'll auto-summarize once the issue is resolved."""
        lines = [
            "",
            "[Copilot] 😤 检测到你似乎对当前问题不太满意",
            "Kimi 解决后，我会自动把这段经验提炼成知识库条目，避免下次再走弯路。",
            "[Copilot]",
            "",
        ]
        self.notify_callback("\n".join(lines))

    def _notify_frustration_rag_write(self, title: str):
        """Confirm that a frustration-resolution arc was written to RAG."""
        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        lines = [
            "",
            "[Copilot] ✅ 已自动总结踩坑经验并写入 RAG",
            f"时间: {now}",
            f"标题: {title}",
            "[Copilot]",
            "",
        ]
        self.notify_callback("\n".join(lines))

    def _handle_copilot_result(self, content: str):
        """Parse the side model output and act on it."""
        result = self._extract_json(content)
        if not result:
            logger.warning("Side model did not return valid JSON: %r", content[:200])
            return

        has_issue = result.get("has_issue", False)
        summary = result.get("summary", "")
        suggestion = result.get("suggestion", "")
        severity = result.get("severity", "medium")
        tags = result.get("rag_entry", {}).get("tags", []) if result.get("rag_entry") else []

        if not has_issue:
            logger.info("Side model found no issue.")
            return

        logger.info("Side model found issue [%s]: %s", severity, summary)

        issue_record = {
            "summary": summary,
            "suggestion": suggestion,
            "tags": tags,
            "severity": severity,
        }
        self.recent_issues.append(issue_record)

        # Add to panel and detect repeated mistakes.
        self.panel.add_issue(summary, suggestion, tags, severity)
        is_repeated, related = self.panel.is_repeated(tags, summary)
        if is_repeated:
            logger.info("Repeated mistake detected: %s", summary)
            self._notify_repeated(summary, related)

        # Detect whether Kimi is stuck in a loop and needs intervention.
        if self._detect_stuck_state(issue_record):
            self._handle_stuck_state(issue_record, related)

        # Surface to the user inside the wrapped session.
        self._notify_user(summary, suggestion)

        if result.get("worth_rag"):
            rag_entry = result.get("rag_entry", {})
            if rag_entry:
                self._write_rag(rag_entry)

    def _extract_json(self, text: str) -> dict | None:
        """Best-effort JSON extraction from model output."""
        text = text.strip()
        if text.startswith("```"):
            text = text.strip("`")
            if text.lower().startswith("json"):
                text = text[4:].strip()
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            pass

        # Try to find a JSON object in the text.
        match = re.search(r"\{.*\}", text, re.DOTALL)
        if match:
            try:
                return json.loads(match.group(0))
            except json.JSONDecodeError:
                pass
        return None

    def _notify_user(self, summary: str, suggestion: str):
        """Emit a notice to the user in the wrapped terminal."""
        lines = ["", "[Copilot] ⚠️ 旁路模型发现潜在问题", f"摘要: {summary}"]
        if suggestion:
            lines.append(f"建议: {suggestion}")
        lines.extend(["[Copilot]", ""])
        self.notify_callback("\n".join(lines))

    def _notify_repeated(self, summary: str, related: list[dict]):
        """Warn the user when a similar issue has been seen before."""
        lines = [
            "",
            "[Copilot] 🔁 检测到重复踩坑",
            f"当前问题: {summary}",
            f"历史相关提示: {len(related)} 条",
            "运行 python3 copilot_panel.py --open 打开提示面板",
            "[Copilot]",
            "",
        ]
        self.notify_callback("\n".join(lines))

    def _notify_rag_write(self, title: str):
        """Emit a confirmation when a new entry is written to RAG."""
        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        lines = [
            "",
            "[Copilot] ✅ 知识已写入 RAG",
            f"时间: {now}",
            f"标题: {title}",
            "[Copilot]",
            "",
        ]
        self.notify_callback("\n".join(lines))

    def _enrich_tags(self, tags: list[str]) -> list[str]:
        """Add project and default scope tags to a RAG entry."""
        enriched = list(tags or [])
        if self.project:
            project_tag = f"project:{self.project}"
            if project_tag not in enriched:
                enriched.append(project_tag)
        for scope in self.config.get("rag", {}).get("default_scopes", []):
            if scope not in enriched:
                enriched.append(scope)
        return enriched

    @staticmethod
    def _is_similar_rag(a: dict, b: dict, threshold: float = 0.65) -> bool:
        """Return True if two RAG entries look like duplicates."""
        a_title = (a.get("title") or "").lower().strip()
        b_title = (b.get("title") or "").lower().strip()
        if a_title and b_title:
            if a_title == b_title:
                return True
            ratio = difflib.SequenceMatcher(None, a_title, b_title).ratio()
            if ratio >= threshold:
                return True

        a_question = (a.get("question") or "").lower().strip()
        b_question = (b.get("question") or "").lower().strip()
        if a_question and b_question:
            if a_question == b_question:
                return True
            ratio = difflib.SequenceMatcher(None, a_question, b_question).ratio()
            if ratio >= threshold:
                return True

        return False

    def _has_duplicate_in_panel(self, rag_entry: dict, lookback: int = 50) -> bool:
        """Check whether a similar RAG entry already exists in the local panel."""
        for record in self.panel.records[-lookback:]:
            if record.get("type") != "rag":
                continue
            if self._is_similar_rag(rag_entry, record):
                return True
        return False

    def _write_rag(self, rag_entry: dict):
        """Write a structured entry to the RAG knowledge base."""
        rag_cfg = self.config.get("rag", {})
        url = rag_cfg.get("url")
        if not url:
            logger.warning("RAG URL not configured, skipping write.")
            return

        title = rag_entry.get("title", "未命名知识")
        question = rag_entry.get("question", "")
        wrong = rag_entry.get("wrong_approach", "")
        correct = rag_entry.get("correct_approach", "")
        tags = self._enrich_tags(rag_entry.get("tags", []))

        # Local deduplication: skip if panel already has a very similar entry.
        if self._has_duplicate_in_panel(rag_entry):
            logger.info("Skipping duplicate RAG entry: %s", title)
            return

        content = (
            f"问题：{question}\n"
            f"错误方案：{wrong}\n"
            f"正确方案：{correct}\n"
            f"标签：{', '.join(tags)}"
        )
        payload = {"title": title, "content": content}

        logger.info("Writing to RAG: %s", title)
        try:
            resp = requests.post(url, json=payload, timeout=30)
            resp.raise_for_status()
            logger.info("RAG write OK: %s", resp.json())
            self.panel.add_rag(
                title=title,
                question=question,
                wrong=wrong,
                correct=correct,
                tags=tags,
            )
            self._notify_rag_write(title)
        except requests.RequestException as e:
            logger.error("RAG write failed: %s", e)

    def _is_similar_issue(self, a: dict, b: dict) -> bool:
        """Return True if two issues look like the same underlying problem."""
        a_tags = set(t.lower() for t in a.get("tags", []) if t)
        b_tags = set(t.lower() for t in b.get("tags", []) if t)
        if a_tags and b_tags and (a_tags & b_tags):
            return True
        a_summary = a.get("summary", "").lower()
        b_summary = b.get("summary", "").lower()
        if a_summary and b_summary and (a_summary in b_summary or b_summary in a_summary):
            return True
        return False

    def _detect_stuck_state(self, current_issue: dict) -> bool:
        """Check whether Kimi has failed multiple times on similar issues."""
        stuck_cfg = self.config.get("copilot", {}).get("stuck_detection", {})
        if not stuck_cfg.get("enabled", True):
            return False

        threshold = stuck_cfg.get("consecutive_similar_failures", 2)
        # Current issue counts as the first occurrence; count how many recent
        # similar issues preceded it without interruption.
        similar_count = 1
        for past in reversed(self.recent_issues[:-1]):  # exclude current
            if self._is_similar_issue(current_issue, past):
                similar_count += 1
            else:
                # Break on first non-similar issue to require *consecutive* similar failures.
                break

        return similar_count >= threshold

    def _query_rag(self, query: str) -> list[str]:
        """Query the RAG knowledge base. Tries a few common payload shapes."""
        query_url = self.config.get("rag", {}).get("query_url", "")
        if not query_url:
            return []

        candidates = [
            {"q": query},
            {"query": query},
            {"question": query},
        ]
        for payload in candidates:
            try:
                resp = requests.post(query_url, json=payload, timeout=15)
                resp.raise_for_status()
                data = resp.json()
                return self._normalize_rag_results(data)
            except requests.RequestException as e:
                logger.debug("RAG query attempt failed: %s", e)
                continue
            except Exception as e:
                logger.debug("RAG query parse failed: %s", e)
                continue
        return []

    @staticmethod
    def _normalize_rag_results(data: Any) -> list[str]:
        """Best-effort extraction of text snippets from various RAG response shapes."""
        if isinstance(data, str):
            return [data]
        if isinstance(data, list):
            snippets = []
            for item in data:
                if isinstance(item, str):
                    snippets.append(item)
                elif isinstance(item, dict):
                    for key in ["content", "text", "answer", "snippet", "document"]:
                        if key in item and item[key]:
                            snippets.append(str(item[key]))
                            break
            return snippets
        if isinstance(data, dict):
            for key in ["results", "data", "documents", "chunks", "answer", "response", "content"]:
                if key in data:
                    return Brain._normalize_rag_results(data[key])
            # If nothing matched, use stringified top-level dict as a last resort.
            return [str(data)]
        return [str(data)]

    def _scope_tags(self) -> set[str]:
        """Tags that are allowed for the current project context."""
        scopes = set()
        if self.project:
            scopes.add(f"project:{self.project}")
        for scope in self.config.get("rag", {}).get("default_scopes", []):
            scopes.add(scope)
        return scopes

    def _filter_rag_snippets_by_scope(self, snippets: list[str]) -> list[str]:
        """Drop RAG snippets that belong to a different project and are not common."""
        allowed = self._scope_tags()
        if not allowed:
            return snippets

        filtered = []
        for snippet in snippets:
            tags_in_snippet = set(
                re.findall(r"(?:project|scope):[\w-]+", snippet)
            )
            # Keep unclassified snippets (no project/scope tags) for safety.
            if not tags_in_snippet:
                filtered.append(snippet)
                continue
            if allowed & tags_in_snippet:
                filtered.append(snippet)
        return filtered

    def _query_local_knowledge(self, issue: dict) -> list[str]:
        """Use the local panel history as fallback knowledge."""
        related = self.panel.find_related(
            issue.get("tags", []),
            issue.get("summary", ""),
            limit=5,
            allowed_scopes=self._scope_tags(),
        )
        snippets = []
        for r in related:
            if r.get("type") == "rag":
                snippets.append(
                    f"标题：{r.get('title', '')}\n"
                    f"问题：{r.get('question', '')}\n"
                    f"正确方案：{r.get('correct_approach', '')}"
                )
            elif r.get("type") == "issue":
                snippets.append(
                    f"摘要：{r.get('summary', '')}\n"
                    f"建议：{r.get('suggestion', '')}"
                )
        return snippets

    def _build_stuck_messages(
        self,
        current_issue: dict,
        related_issues: list[dict],
        knowledge_snippets: list[str],
    ) -> list[dict]:
        system_path = self.config.get("prompts", {}).get(
            "stuck", "prompts/copilot_stuck.txt"
        )
        if os.path.exists(system_path):
            with open(system_path, "r", encoding="utf-8") as f:
                system_content = f.read()
        else:
            system_content = "You are a helpful coding assistant."

        messages = [{"role": "system", "content": system_content}]

        # Include recent session history for context.
        for msg in self.history:
            role = msg["role"]
            if role == "kimi":
                role = "assistant"
            elif role == "tool":
                role = "user"
            messages.append({"role": role, "content": msg["content"]})

        # Add the stuck analysis request as a user message.
        parts = [
            "=== 当前反复出现的问题 ===",
            f"摘要：{current_issue.get('summary', '')}",
            f"建议：{current_issue.get('suggestion', '')}",
            f"标签：{', '.join(current_issue.get('tags', []))}",
            "",
            "=== 本次会话中相似的历史问题 ===",
        ]
        for idx, issue in enumerate(related_issues[-5:], 1):
            parts.append(f"{idx}. {issue.get('summary', '')}")
            if issue.get("suggestion"):
                parts.append(f"   建议：{issue.get('suggestion', '')}")

        if knowledge_snippets:
            parts.extend(["", "=== 相关知识 ==="])
            for idx, snippet in enumerate(knowledge_snippets[:5], 1):
                parts.append(f"{idx}. {snippet}")

        messages.append({"role": "user", "content": "\n".join(parts)})
        return messages

    def _invoke_stuck_analysis(
        self,
        current_issue: dict,
        related_issues: list[dict],
    ) -> dict | None:
        """Call the side model with RAG/local knowledge for stuck analysis."""
        query = current_issue.get("summary", "")
        if self.project:
            query = f"{self.project} 相关：{query}"
        rag_snippets = self._query_rag(query)
        rag_snippets = self._filter_rag_snippets_by_scope(rag_snippets)
        local_snippets = self._query_local_knowledge(current_issue)
        knowledge_snippets = rag_snippets + local_snippets

        messages = self._build_stuck_messages(current_issue, related_issues, knowledge_snippets)
        cfg = self.config.get("model", {})
        payload = {
            "model": cfg.get("name"),
            "messages": messages,
            "temperature": self.config.get("copilot", {}).get("temperature", 0.2),
            "max_tokens": self.config.get("copilot", {}).get("max_tokens", 4096),
        }

        logger.info("Calling side model for stuck analysis with %d messages", len(messages))
        try:
            resp = requests.post(
                cfg.get("base_url"),
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {cfg.get('api_key', '')}",
                },
                json=payload,
                timeout=self.config.get("copilot", {}).get("timeout", 120),
            )
            if not resp.ok:
                logger.error(
                    "Stuck analysis request failed: %s %s - %s",
                    resp.status_code,
                    resp.reason,
                    resp.text[:500],
                )
                return None
            resp.raise_for_status()
        except requests.RequestException as e:
            logger.error("Stuck analysis request failed: %s", e)
            return None

        try:
            data = resp.json()
            content = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, json.JSONDecodeError) as e:
            logger.error("Failed to parse stuck analysis response: %s", e)
            return None

        return self._extract_json(content)

    def _handle_stuck_state(self, current_issue: dict, related_panel_issues: list[dict]):
        """Produce an intervention hint when Kimi is stuck in a loop."""
        summary = current_issue.get("summary", "")
        alert_key = summary.lower()[:80]
        if alert_key in self.stuck_alerts_shown:
            return
        self.stuck_alerts_shown.add(alert_key)

        # Gather similar issues from the current session.
        session_related = [
            issue for issue in self.recent_issues[:-1]
            if self._is_similar_issue(current_issue, issue)
        ]

        result = self._invoke_stuck_analysis(current_issue, session_related)
        if not result:
            return

        if not result.get("is_stuck"):
            logger.info("Side model does not consider the issue a stuck loop.")
            return

        root_cause = result.get("root_cause", "")
        intervention_prompt = result.get("intervention_prompt", "")
        sources = result.get("sources", [])
        if not intervention_prompt:
            logger.warning("Stuck analysis returned empty intervention prompt.")
            return

        self.panel.add_intervention(
            summary=summary,
            root_cause=root_cause,
            intervention_prompt=intervention_prompt,
            sources=sources,
            tags=current_issue.get("tags", []),
        )
        self._notify_stuck(summary, root_cause)

    def _notify_stuck(self, summary: str, root_cause: str):
        """Warn the user that Kimi appears to be stuck and an intervention hint is ready."""
        lines = [
            "",
            "[Copilot] 🚨 Kimi 似乎陷入了循环",
            f"当前问题: {summary}",
        ]
        if root_cause:
            lines.append(f"根因: {root_cause}")
        lines.extend(["运行 python3 copilot_panel.py --open 查看干预建议", "[Copilot]", ""])
        self.notify_callback("\n".join(lines))
