"""Observer 框架 — per-tool-call LLM 观察 + session-end 回顾

数据流（PostToolUse）：
  raw tool data → XML prompt → LLM → XML parse → dedup → DB

5 provider 降级链（按 config.observer_provider_priority 顺序）：
  Claude API → Gemini → OpenRouter → OpenClaw → Ollama → 跳过
"""

from __future__ import annotations

import datetime
import importlib
import json
import logging
import pathlib
from typing import TYPE_CHECKING

from ..dedup import check_duplicate
from ..rules import sanitize_fact_for_prompt

if TYPE_CHECKING:
    from ..db_cog import CogDatabase
    from ...config import Config

logger = logging.getLogger(__name__)

# ── Per-tool-call XML prompt（code.json 风格）──────────────────────

TOOL_OBSERVER_PROMPT = """\
You are a coding session observer. Create a searchable memory entry for what just happened.

OBSERVATION TYPES (pick exactly one):
- bugfix: Fixed a problem (error resolved, crash fixed)
- feature: New functionality added
- refactor: Code restructured, behavior unchanged
- change: General modification (docs, config, dependency)
- discovery: Learned about existing system
- decision: Architecture/design choice with rationale

SKIP (reply with just <observation/>) if:
- Simple file read with no notable findings
- Empty status check or health ping
- Routine package listing
- Command that produced no meaningful output
- The tool output is just "ok" or a success confirmation with no details

IMPORTANT rules for facts:
- Each fact must be SPECIFIC and SELF-CONTAINED (include file names, function names, values)
- Do NOT write generic facts like "Tool was used" or "A file was modified"
- Extract WHAT specifically changed, WHY it matters, or WHAT was discovered
- If you cannot write specific facts, use <observation/> to skip

Reply ONLY with XML (no other text):
<observation>
  <type>exactly one of the 6 types</type>
  <title>Short descriptive title</title>
  <narrative>2-3 sentences: what happened, why, and the impact</narrative>
  <facts>
    <fact>Specific fact with concrete details</fact>
  </facts>
  <concepts>
    <concept>how-it-works OR why-it-exists OR what-changed OR problem-solution OR gotcha OR pattern OR trade-off</concept>
  </concepts>
  <files>
    <file>affected/file/path</file>
  </files>
</observation>

TOOL CALL:
Tool: {tool_name}
Input: {tool_input_summary}
Output (truncated): {tool_output_summary}"""

# observation type → index1 fact category
_OBS_TYPE_TO_CATEGORY = {
    "bugfix": "root_cause",
    "feature": "code",
    "refactor": "architecture",
    "change": "code",
    "discovery": "observation",
    "decision": "decision",
}

# 只对"行动"工具调用 LLM（Read/Grep/Glob 跳过）
# 保留用于向后兼容，实际门控已移至 hooks.py importance gate (#145 Phase B)
OBSERVED_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "Bash"})

# ── Session-end JSON prompt（旧格式，maintenance 用）────────────────

OBSERVER_PROMPT = """You are a coding session observer. Extract high-value observations
that a future AI assistant would need to resume this work.

Session facts (what happened):
{facts_text}

Context: Collection={collection}

Extract ONLY these types (skip "Modified file" or test results):
- constraint: Hard rules or requirements
- preference: Stated preferences for tools, patterns, or approaches
- decision: Technical decisions with rationale
- tradeoff: Trade-offs discussed
- architecture: System design insights
- root_cause: Bug root cause analysis

For each observation, assess confidence as p(x) between 0.7-0.95.
Prioritize: surprising information > frequently reinforced patterns.

Reply as JSON array (max 5 items):
[{{"assertion": "1-2 sentence fact", "category": "...", "confidence": 0.85, "reasoning": "why this matters"}}]

If no high-value observations found, reply: []"""


# ── 截断参数（per-tool 差异化, #145 Phase A）─────────────────────
# 注意: 此处截断是 LLM prompt 优化（保留语义最密的部分），
# 与 db_cog.truncate_payload 的存储截断（保留调试信息）策略不同。
_PROMPT_TOTAL_LIMIT = 8192  # 总上限 8KB，防成本爆炸


def _truncate(s: str, n: int = 800) -> str:
    return s[:n] + "..." if len(s) > n else s


def build_tool_prompt(tool_name: str, tool_input: dict, tool_output: str) -> str:
    """从工具调用数据构建 XML prompt

    #145 Phase A 截断策略：
    - Edit: tool_input 完整保留（diff 是数据），tool_response 截断 500c
    - Bash: command 500c, output 2KB
    - Write: content head 2KB
    - 其他: json 1KB
    - 总上限 8KB
    """
    if tool_name in ("Edit", "MultiEdit"):
        file_path = tool_input.get("file_path", "?")
        old = tool_input.get("old_string", "")
        new = tool_input.get("new_string", "")
        if old:
            # diff 是核心数据，尽量完整保留（各 3KB 上限防极端情况）
            input_summary = f"file: {file_path}, replacing: {_truncate(old, 3072)} → {_truncate(new, 3072)}"
        else:
            input_summary = f"file: {file_path}"
        # Edit tool_response 只是确认文本，价值低
        output_summary = _truncate(tool_output, 500)
    elif tool_name == "Write":
        file_path = tool_input.get("file_path", "?")
        content = tool_input.get("content", "")
        input_summary = f"file: {file_path}, content (head): {_truncate(content, 2048)}"
        output_summary = _truncate(tool_output, 500)
    elif tool_name == "Bash":
        input_summary = f"command: {_truncate(tool_input.get('command', ''), 500)}"
        output_summary = _truncate(tool_output, 2048)
    else:
        input_summary = _truncate(json.dumps(tool_input, ensure_ascii=False), 1024)
        output_summary = _truncate(tool_output, 1024)

    prompt = TOOL_OBSERVER_PROMPT.format(
        tool_name=tool_name,
        tool_input_summary=input_summary,
        tool_output_summary=output_summary,
    )
    # 总上限防护
    if len(prompt) > _PROMPT_TOTAL_LIMIT:
        prompt = prompt[:_PROMPT_TOTAL_LIMIT] + "\n... (truncated)"
    return prompt


def process_raw_observation(
    db_cog: CogDatabase,
    raw: str,
    collection: str,
    session_id: str | None,
    provider_name: str,
    tool_input: dict,
    *,
    model_name: str = "",
) -> list[dict]:
    """XML 解析 → 去重 → DB 写入（独立函数，不需要 SessionObserver 实例）

    用于 hooks._observe_via_api 直调 API 后的 parse+save，
    避免 create_observer() 重建全部 provider 连接。
    """
    from .parse import parse_xml_observation

    obs = parse_xml_observation(raw, provider_name)
    if obs is None:
        return []

    obs_type = obs.get("type", "change")
    title = obs.get("title", "")
    narrative = obs.get("narrative")
    facts_list = obs.get("facts", [])
    concepts = obs.get("concepts", [])
    files = obs.get("files", [])

    # 从 tool_input 补充 files（LLM 可能遗漏）
    file_path = tool_input.get("file_path", "")
    if file_path and file_path not in files:
        files.append(file_path)

    category = _OBS_TYPE_TO_CATEGORY.get(obs_type, "observation")
    source = f"observer_{provider_name}"
    confidence = _PROVIDER_CONFIDENCE.get(provider_name, 0.75)

    # 过滤空泛 facts + 模板回声（复用 sanitize 统一入口）
    useful_facts = [
        f for f in facts_list
        if len(f) >= 15
        and not f.lower().startswith(("tool ", "the tool "))
        and sanitize_fact_for_prompt(f) is not None
    ]

    if not useful_facts:
        if title and len(title) >= 15:
            useful_facts = [title]
        else:
            return []

    # #241: 先写 observation 记录
    observation_id: int | None = None
    try:
        obs_record = {
            "session_id": session_id,
            "collection": collection,
            "obs_type": obs_type,
            "title": title,
            "narrative": narrative,
            "concepts": json.dumps(concepts, ensure_ascii=False),
            "files": json.dumps(files[:10], ensure_ascii=False),
            "provider": provider_name,
            "model": model_name or None,
            "source": source,
        }
        observation_id = db_cog.insert_observation(obs_record)
    except Exception as e:
        logger.warning("Observation 写入失败（facts 照常写入）: %s", e)

    saved: list[dict] = []
    scope_files = json.dumps(files[:10], ensure_ascii=False)
    ctx_dict: dict = {
        "obs_title": title, "obs_type": obs_type,
        "concepts": concepts, "provider": provider_name,
    }
    if model_name:
        ctx_dict["model"] = model_name
    context = json.dumps(ctx_dict, ensure_ascii=False)

    for fact_text in useful_facts[:5]:
        assertion = fact_text.strip()[:500]
        if len(assertion) < 10:
            continue

        try:
            dup = check_duplicate(
                assertion=assertion,
                collection=collection,
                session_id=session_id,
                db_cog=db_cog,
            )
            if dup.is_dup:
                continue
        except Exception as e:
            logger.debug("去重失败: %s", e)

        fact = {
            "collection": collection,
            "session_id": session_id,
            "assertion": assertion,
            "category": category,
            "confidence": confidence,
            "source": source,
            "status": "active",
            "scope_files": scope_files,
            "scope_modules": "[]",
            "scope_entities": "[]",
            "context_json": context,
            "observation_id": observation_id,
        }

        try:
            db_cog.insert_fact(fact)
            saved.append(fact)
        except Exception as e:
            logger.debug("Observer fact 写入失败: %s", e)

    if saved:
        logger.info(
            "Observer(%s) %s: %d facts [%s] %s",
            provider_name, obs_type, len(saved), title[:40], collection,
        )
    return saved


class SessionObserver:
    """Observer 调度器 — per-tool-call + session-end"""

    def __init__(
        self,
        providers: list,
        db_cog: CogDatabase,
        config: Config,
    ):
        self.providers = providers
        self.db_cog = db_cog
        self.config = config

    # ── Per-tool-call observation（新，XML 格式）─────────────────

    async def observe_tool_call(
        self,
        tool_name: str,
        tool_input: dict,
        tool_output: str,
        collection: str,
        session_id: str | None,
    ) -> list[dict] | None:
        """Per-tool-call LLM observation — XML prompt + XML parse

        遍历 providers（L2 Claude → L1 Ollama），首个可用的执行。
        #145 Phase A: breadcrumbs 注入最近 2 事件摘要提供上下文。

        Returns:
            list[dict] — 成功写入的 fact 列表（可能为空）
            None — 全部 provider 尝试后失败（调用方应 record_failure）

        #237 Phase 1: None 语义区分全部失败 vs 无可用 provider（[]）
        """
        # #145 Phase B: OBSERVED_TOOLS 门控已移至 hooks.py importance gate
        # observe_tool_call 现在接受所有工具（由 caller 负责门控）

        prompt = build_tool_prompt(tool_name, tool_input, tool_output)

        # Breadcrumbs: 注入最近事件摘要，帮助 LLM 理解上下文 (#145 Phase A)
        breadcrumbs = self._get_breadcrumbs(session_id, collection)
        if breadcrumbs:
            prompt = f"RECENT CONTEXT (last actions):\n{breadcrumbs}\n\n{prompt}"
            # 重新检查总上限
            if len(prompt) > _PROMPT_TOTAL_LIMIT:
                prompt = prompt[:_PROMPT_TOTAL_LIMIT] + "\n... (truncated)"

        attempted = 0
        for provider in self.providers:
            if not provider.available:
                continue

            attempted += 1
            try:
                raw = await provider.generate(prompt)
                if raw is None:
                    _log_provider_failure(provider.name, "returned None")
                    continue
                _model = getattr(provider, "model_name", "")
                return self._process_tool_result(
                    raw, collection, session_id, provider.name, tool_input,
                    model_name=_model if isinstance(_model, str) else "",
                )
            except Exception as e:
                _log_provider_failure(provider.name, str(e)[:200])
                logger.warning("Observer provider %s 失败: %s", provider.name, e)
                continue

        if attempted == 0:
            return []   # 没有可用 provider（都在冷却），不算失败
        return None     # 尝试了但全部失败

    def _get_breadcrumbs(self, session_id: str | None, collection: str) -> str:
        """从 raw_events.summary 获取最近 2 个事件摘要 (#145 Phase A)

        提供上下文帮助 LLM 更好地理解当前操作在做什么。
        失败时静默返回空字符串 — breadcrumbs 是增强，不是必需。
        """
        if not session_id:
            return ""
        try:
            events = self.db_cog.get_recent_raw_events(
                session_id=session_id,
                collection=collection,
                limit=2,
            )
            if not events:
                return ""
            lines: list[str] = []
            for e in reversed(events):  # chronological order
                summary = e.get("summary", "")
                if summary:
                    lines.append(f"- [{e.get('tool_name', '?')}] {summary}")
            return "\n".join(lines)
        except Exception as e:
            logger.debug("breadcrumbs 获取失败: %s", e)
            return ""

    def _process_tool_result(
        self,
        raw: str,
        collection: str,
        session_id: str | None,
        provider_name: str,
        tool_input: dict,
        *,
        model_name: str = "",
    ) -> list[dict]:
        """XML 解析 → 每个 fact 独立去重写入（委托 process_raw_observation）"""
        return process_raw_observation(
            db_cog=self.db_cog,
            raw=raw,
            collection=collection,
            session_id=session_id,
            provider_name=provider_name,
            tool_input=tool_input,
            model_name=model_name,
        )

    # ── Session-end observation（旧，JSON 格式）─────────────────

    async def observe(
        self,
        session_facts: list[dict],
        collection: str,
        session_id: str | None,
    ) -> list[dict]:
        """Session-end 批量观察（旧接口，maintenance 子进程用）"""
        if not session_facts:
            return []

        facts_lines = []
        for f in session_facts[:20]:
            assertion = sanitize_fact_for_prompt(f.get("assertion", ""))
            if assertion is None:
                continue
            cat = f.get("category", "unknown")
            facts_lines.append(f"- [{cat}] {assertion}")
        facts_text = "\n".join(facts_lines)

        prompt = OBSERVER_PROMPT.format(
            facts_text=facts_text,
            collection=collection,
        )

        for provider in self.providers:
            if not provider.available:
                continue
            try:
                raw_facts = await provider.observe_session(prompt)
                if raw_facts is None:
                    continue
                _model = getattr(provider, "model_name", "")
                return self._process_results(
                    raw_facts, collection, session_id, provider.name,
                    model_name=_model if isinstance(_model, str) else "",
                )
            except Exception as e:
                logger.warning("Observer provider %s 失败: %s", provider.name, e)
                continue
        return []

    def _process_results(
        self,
        raw_facts: list[dict],
        collection: str,
        session_id: str | None,
        provider_name: str,
        *,
        model_name: str = "",
    ) -> list[dict]:
        """旧接口：JSON 解析 → 去重 → 写入"""
        saved: list[dict] = []
        for rf in raw_facts[:5]:
            assertion = rf.get("assertion", "").strip()
            if not assertion or len(assertion) < 10:
                continue

            # 模板回声过滤（复用 sanitize 统一入口）
            if sanitize_fact_for_prompt(assertion) is None:
                continue

            category = rf.get("category", "observation")
            confidence = rf.get("confidence", 0.8)
            if not isinstance(confidence, (int, float)):
                confidence = 0.8
            confidence = max(0.5, min(0.95, confidence))

            try:
                dup = check_duplicate(
                    assertion=assertion,
                    collection=collection,
                    session_id=session_id,
                    db_cog=self.db_cog,
                )
                if dup.is_dup:
                    continue
            except Exception as e:
                logger.debug("去重检查失败: %s", e)

            source = f"observer_{provider_name}"
            reasoning = rf.get("reasoning", "")

            fact = {
                "collection": collection,
                "session_id": session_id,
                "assertion": assertion[:500],
                "category": category,
                "confidence": confidence,
                "source": source,
                "status": "active",
                "scope_files": "[]",
                "scope_modules": "[]",
                "scope_entities": "[]",
                "context_json": json.dumps(
                    {k: v for k, v in {
                        "origin": "observer", "provider": provider_name,
                        "model": model_name or None, "reasoning": reasoning,
                    }.items() if v is not None},
                    ensure_ascii=False,
                ),
            }

            try:
                self.db_cog.insert_fact(fact)
                saved.append(fact)
            except Exception as e:
                logger.debug("Observer fact 写入失败: %s", e)

        logger.info(
            "Observer(%s) 提取 %d/%d facts for %s",
            provider_name, len(saved), len(raw_facts), collection,
        )
        return saved


# ── Provider 注册表 ──────────────────────────────────────────────

_PROVIDER_REGISTRY: dict[str, tuple[str, str]] = {
    "claude":     (".claude",     "ClaudeObserverProvider"),
    "gemini":     (".gemini",     "GeminiObserverProvider"),
    "openrouter": (".openrouter", "OpenRouterObserverProvider"),
    "openclaw":   (".openclaw",   "OpenClawObserverProvider"),
    "ollama":     (".ollama",     "OllamaObserverProvider"),
}

# 按 provider 语义理解能力区分置信度
_PROVIDER_CONFIDENCE: dict[str, float] = {
    "claude":     0.85,
    "gemini":     0.80,
    "openrouter": 0.80,
    "openclaw":   0.75,
    "ollama":     0.40,  # 实测：90% facts 模糊无具体信息，可用率仅 ~10%（#248）
}

_DEFAULT_PRIORITY = ["claude", "gemini", "openrouter", "openclaw", "ollama"]


# ── Phase 0 最简失败日志 (#237) ────────────────────────────────────
# 一行 append，零依赖，日志本身不能阻塞 observe。
# 观察期用：确认 CLAUDECODE 清除后各 provider 恢复状况。

def _get_failure_log_path() -> pathlib.Path:
    """返回失败日志路径，确保目录存在。"""
    log_dir = pathlib.Path.home() / ".claude-index1"
    try:
        log_dir.mkdir(parents=True, exist_ok=True)
    except OSError:
        pass
    return log_dir / "observer_failures.log"


_FAIL_LOG = _get_failure_log_path()


def _log_provider_failure(provider_name: str, reason: str) -> None:
    """Phase 0 最简日志 — 一行 append，失败静默。"""
    try:
        with open(_FAIL_LOG, "a") as f:
            ts = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
            f.write(f"{ts} {provider_name}: {reason}\n")
    except OSError:
        pass


def create_observer(
    db_cog: CogDatabase,
    config: Config,
    skip_cli: bool = False,
) -> SessionObserver | None:
    """工厂函数 — 按 config.observer_provider_priority 动态组装 providers

    Args:
        skip_cli: Worker 内使用 — 跳过 Claude CLI spawn（SessionManager 已处理）

    Returns:
        SessionObserver 或 None（如果 observer 未启用）
    """
    if not getattr(config, "observer_enabled", True):
        return None

    priority = getattr(config, "observer_provider_priority", _DEFAULT_PRIORITY)
    providers: list = []

    for name in priority:
        entry = _PROVIDER_REGISTRY.get(name)
        if not entry:
            logger.debug("未知 observer provider: %s，跳过", name)
            continue
        try:
            mod = importlib.import_module(entry[0], package=__package__)
            cls = getattr(mod, entry[1])
            # Claude provider 在 Worker 内跳过 CLI（SessionManager 已处理）
            if name == "claude" and skip_cli:
                providers.append(cls(config, skip_cli=True))
            else:
                providers.append(cls(config))
        except Exception as e:
            logger.debug("Provider %s 初始化失败: %s", name, e)

    if not providers:
        logger.debug("没有可用的 observer provider")
        return None

    return SessionObserver(
        providers=providers,
        db_cog=db_cog,
        config=config,
    )
