"""Rules Extractor — 从 ~/.claude/rules/*.md 和 ~/.claude/skills/*/SKILL.md 提取用户偏好

L0 纯正则提取（永远可用）+ L1 LLM 增强（可选，提升隐含偏好覆盖）。
Hash-based 增量检测，只提取变更文件。

数据流：
  scan rule files → hash 增量检测 → L0 regex 提取 → (L1 LLM 提取) → dedup → insert facts

宪法合规：
- 原则 2（离线自治）：L0 永远可用，L1 是增强非依赖
- 原则 3（最坏假设）：LLM 全挂时只用 L0 结果
- 原则 4（运行时反推）：跑在 maintenance 子进程，无延迟预算限制
"""

from __future__ import annotations

import hashlib
import json
import logging
import re
from pathlib import Path

from ..config import Config
from ..resilience import ServiceCooldown, safe_db_write
from .auto_classify import classify_category, estimate_confidence, extract_scope
from .dedup import check_duplicate

logger = logging.getLogger(__name__)

# L1 Ollama 调用冷却保护（模块级单例，maintenance 子进程生命周期内有效）
_l1_cooldown = ServiceCooldown("rules_l1_ollama", base_cooldown=30.0, max_cooldown=120.0)

# ── L0 提取正则 ──────────────────────────────────────────────

# 强制规则 → constraint
_CONSTRAINT_RE = re.compile(
    r"(?:必须|禁止|不[允准许]许|强制|不可|绝不|一定要|"
    r"MUST(?:\s+NOT)?|NEVER|ALWAYS|FORBIDDEN|REQUIRED|SHALL\s+NOT|"
    r"\*\*禁止\*\*|\*\*必须\*\*)",
    re.IGNORECASE,
)

# 偏好规则 → preference
_PREFERENCE_RE = re.compile(
    r"(?:优先|偏好|推荐|建议|倾向|"
    r"prefer(?:red)?|recommend(?:ed)?|suggest(?:ed)?|"
    r"\*\*优先\*\*|\*\*推荐\*\*)",
    re.IGNORECASE,
)

# 世界观规则 → worldview（隐含价值判断）
_WORLDVIEW_RE = re.compile(
    r"(?:更好的|最[佳好]的?|正确的|核心原则|"
    r"best\s+practice|the\s+right\s+way|golden\s+rule|"
    r"I\s+(?:usually|always|generally)|"
    r"(?:from\s+now\s+on|going\s+forward))",
    re.IGNORECASE,
)

# Markdown section header
_SECTION_RE = re.compile(r"^(#{1,4})\s+(.+)$", re.MULTILINE)

# 列表项
_LIST_ITEM_RE = re.compile(r"^\s*[-*]\s+(.+)$", re.MULTILINE)

# 表格行（| col1 | col2 | ...）
_TABLE_ROW_RE = re.compile(r"^\s*\|(.+)\|\s*$", re.MULTILINE)

# 表格分隔行（|---|---|）
_TABLE_SEP_RE = re.compile(r"^\s*\|[\s:|-]+\|\s*$")

# ── 常量 ──────────────────────────────────────────────────

MAX_ASSERTION_LEN = 200  # 规则文本最大长度
SOURCE_TAG = "rules_extraction"


def _compute_file_hash(path: Path) -> str:
    """SHA256 文件 hash"""
    try:
        return hashlib.sha256(path.read_bytes()).hexdigest()
    except OSError:
        return ""


def _scan_rule_files(config: Config) -> list[Path]:
    """扫描所有规则文件路径

    ~/.claude/rules/*.md + ~/.claude/skills/*/SKILL.md
    """
    files: list[Path] = []
    for raw_path in config.user_config_paths:
        base = Path(raw_path).expanduser()
        if not base.is_dir():
            continue
        if base.name == "rules":
            # ~/.claude/rules/*.md
            for f in sorted(base.glob("*.md")):
                if f.is_file() and not f.name.startswith("_disabled"):
                    files.append(f)
        elif base.name == "skills":
            # ~/.claude/skills/*/SKILL.md
            for skill_dir in sorted(base.iterdir()):
                skill_file = skill_dir / "SKILL.md"
                if skill_file.is_file():
                    files.append(skill_file)
    return files


def _get_changed_files(
    files: list[Path],
    db_cog,
) -> list[tuple[Path, str]]:
    """比对 worldview_sources 表中的 hash，返回变更文件列表

    Returns: [(path, new_hash), ...]
    """
    changed: list[tuple[Path, str]] = []
    skipped = 0
    for path in files:
        new_hash = _compute_file_hash(path)
        if not new_hash:
            continue

        # 查 worldview_sources 表
        try:
            row = db_cog.conn.execute(
                "SELECT hash FROM worldview_sources WHERE file_path = ?",
                (str(path),),
            ).fetchone()
            if row and row[0] == new_hash:
                skipped += 1
                continue  # 未变更
        except Exception as e:
            logger.debug("worldview_sources 查询失败 (%s): %s，视为变更", path.name, e)

        changed.append((path, new_hash))

    if changed:
        logger.info(
            "worldview 增量检测: %d 变更, %d 跳过 (共 %d 文件)",
            len(changed), skipped, len(files),
        )
    return changed


def _classify_rule(text: str) -> str:
    """L0 分类规则文本 — constraint / preference / worldview

    专用于规则文件提取，比 auto_classify 更严格：
    只返回 worldview 层级的三个 category。
    """
    if _CONSTRAINT_RE.search(text):
        return "constraint"
    if _PREFERENCE_RE.search(text):
        return "preference"
    if _WORLDVIEW_RE.search(text):
        return "worldview"
    # fallback: 用 auto_classify 判断
    cat = classify_category(text)
    if cat in ("constraint", "preference", "worldview"):
        return cat
    # 规则文件默认为 preference（来自配置文件 = 用户偏好）
    return "preference"


def _extract_l0(content: str, filename: str) -> list[dict]:
    """L0 纯正则提取 — 永远可用

    按 ## 分节，对每个列表项/表格行检测规则模式。
    """
    results: list[dict] = []
    current_section = ""

    # 按节解析
    lines = content.split("\n")
    i = 0
    while i < len(lines):
        line = lines[i]

        # 检测 section header
        m = _SECTION_RE.match(line)
        if m:
            current_section = m.group(2).strip()
            i += 1
            continue

        # 检测列表项
        m = _LIST_ITEM_RE.match(line)
        if m:
            text = m.group(1).strip()
            # 去掉 markdown bold
            text_clean = re.sub(r"\*\*(.+?)\*\*", r"\1", text)

            if _CONSTRAINT_RE.search(text_clean) or _PREFERENCE_RE.search(text_clean) or _WORLDVIEW_RE.search(text_clean):
                assertion = text_clean[:MAX_ASSERTION_LEN]
                category = _classify_rule(text_clean)
                strength = "mandatory" if category == "constraint" else "recommended"
                results.append({
                    "assertion": assertion,
                    "category": category,
                    "source": SOURCE_TAG,
                    "confidence": 0.95,
                    "context_json": json.dumps({
                        "file": filename,
                        "section": current_section,
                        "strength": strength,
                        "extraction": "l0_regex",
                    }, ensure_ascii=False),
                })
            i += 1
            continue

        # 检测表格行
        m = _TABLE_ROW_RE.match(line)
        if m and not _TABLE_SEP_RE.match(line):
            row_text = m.group(1).strip()
            # 过滤表头行（通常是第一行 | 列名 | 列名 |）
            cells = [c.strip() for c in row_text.split("|") if c.strip()]
            combined = " ".join(cells)
            combined_clean = re.sub(r"\*\*(.+?)\*\*", r"\1", combined)

            if _CONSTRAINT_RE.search(combined_clean) or _PREFERENCE_RE.search(combined_clean):
                assertion = combined_clean[:MAX_ASSERTION_LEN]
                category = _classify_rule(combined_clean)
                strength = "mandatory" if category == "constraint" else "recommended"
                results.append({
                    "assertion": assertion,
                    "category": category,
                    "source": SOURCE_TAG,
                    "confidence": 0.95,
                    "context_json": json.dumps({
                        "file": filename,
                        "section": current_section,
                        "strength": strength,
                        "extraction": "l0_regex",
                    }, ensure_ascii=False),
                })
            i += 1
            continue

        # 普通行：检查是否包含强规则关键词（通常是段落文本）
        text_clean = re.sub(r"\*\*(.+?)\*\*", r"\1", line.strip())
        if text_clean and len(text_clean) > 10 and _CONSTRAINT_RE.search(text_clean):
            assertion = text_clean[:MAX_ASSERTION_LEN]
            results.append({
                "assertion": assertion,
                "category": "constraint",
                "source": SOURCE_TAG,
                "confidence": 0.90,  # 段落文本置信度略低
                "context_json": json.dumps({
                    "file": filename,
                    "section": current_section,
                    "strength": "mandatory",
                    "extraction": "l0_regex",
                }, ensure_ascii=False),
            })

        i += 1

    return results


# ── L1 LLM 增强提取 ──────────────────────────────────────

_L1_PROMPT_TEMPLATE = """\
You are extracting user preferences and rules from a configuration file.

File: {filename}
Content:
{content}

Extract ALL rules, preferences, and constraints from this file.
For each rule, determine:
- rule: The rule text (concise, max 200 chars, keep the key meaning)
- category: "constraint" (must/never/forbidden), "preference" (prefer/recommend), or "worldview" (value judgment, best practice)
- scope: What area this applies to (e.g., "git", "code-review", "error-handling")
- strength: "mandatory" or "recommended"
- evidence: Quote the exact source text that supports this rule (max 100 chars)

Reply ONLY with a JSON array:
[{{"rule": "...", "category": "...", "scope": "...", "strength": "...", "evidence": "..."}}]

If no rules found, reply: []
IMPORTANT: Each rule must be grounded in the source text. Do not invent rules."""

# CJK 比例检测
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]")

_L1_PROMPT_TEMPLATE_CJK = """\
你是一个规则提取器。从以下配置文件中提取所有用户规则、偏好和约束。

文件: {filename}
内容:
{content}

对每条规则，确定：
- rule: 规则文本（精炼，最多 200 字符，保留核心含义）
- category: "constraint"（必须/禁止/强制）, "preference"（优先/推荐/建议）, 或 "worldview"（价值判断、最佳实践）
- scope: 适用领域（如 "git"、"code-review"、"error-handling"）
- strength: "mandatory" 或 "recommended"
- evidence: 引用原文中支持该规则的原始文本（最多 100 字符）

只回复 JSON 数组:
[{{"rule": "...", "category": "...", "scope": "...", "strength": "...", "evidence": "..."}}]

没有规则时回复: []
重要：每条规则必须有原文依据。不要编造规则。"""


def _is_cjk_dominant(text: str) -> bool:
    """CJK 字符占比 > 30%"""
    if not text:
        return False
    cjk_count = len(_CJK_RE.findall(text))
    total = len(text.replace(" ", "").replace("\n", ""))
    if total == 0:
        return False
    return cjk_count / total > 0.30


def _parse_l1_output(raw: str) -> list[dict] | None:
    """4 层 JSON 容错解析（复用 session_review.py 模式）

    1. 直接 JSON parse
    2. 提取 ```json 块
    3. 查找 [ ] 边界
    4. 返回 None
    """
    if not raw or not raw.strip():
        return None

    text = raw.strip()

    # Layer 1: 直接解析
    try:
        result = json.loads(text)
        if isinstance(result, list):
            return result
    except json.JSONDecodeError:
        pass

    # Layer 2: 提取 ```json 块
    m = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
    if m:
        try:
            result = json.loads(m.group(1))
            if isinstance(result, list):
                return result
        except json.JSONDecodeError:
            pass

    # Layer 3: 查找 [ ] 边界
    start = text.find("[")
    end = text.rfind("]")
    if start >= 0 and end > start:
        try:
            result = json.loads(text[start:end + 1])
            if isinstance(result, list):
                return result
        except json.JSONDecodeError:
            pass

    return None


def _validate_l1_item(item: dict) -> dict | None:
    """验证 L1 提取项 — Source-Grounding: 没有 evidence 的丢弃"""
    if not isinstance(item, dict):
        return None
    rule = item.get("rule", "").strip()
    if not rule or len(rule) < 5:
        return None
    evidence = item.get("evidence", "").strip()
    if not evidence:
        return None  # 没有原文依据的丢弃

    category = item.get("category", "preference")
    if category not in ("constraint", "preference", "worldview"):
        category = "preference"

    strength = item.get("strength", "recommended")
    if strength not in ("mandatory", "recommended"):
        strength = "recommended"

    return {
        "assertion": rule[:MAX_ASSERTION_LEN],
        "category": category,
        "source": SOURCE_TAG,
        "confidence": 0.90,  # LLM 提取置信度略低于 L0 正则
        "context_json": json.dumps({
            "scope": item.get("scope", ""),
            "strength": strength,
            "evidence": evidence[:100],
            "extraction": "l1_llm",
        }, ensure_ascii=False),
    }


async def _extract_l1(
    content: str,
    filename: str,
    config: Config,
) -> list[dict]:
    """L1 LLM 增强提取 — 可选，提升隐含偏好覆盖

    ServiceCooldown 保护 + 30s 超时，失败返回空列表。
    """
    import httpx

    # ServiceCooldown 保护：Ollama 冷却中直接跳过
    if _l1_cooldown.is_cooling:
        logger.debug("rules_extractor L1 跳过（Ollama 冷却中）")
        return []

    # 截断内容（LLM prompt 上限）
    max_content = 6000
    if len(content) > max_content:
        content = content[:max_content] + "\n... (truncated)"

    # 语言自适应 prompt
    if _is_cjk_dominant(content):
        prompt = _L1_PROMPT_TEMPLATE_CJK.format(filename=filename, content=content)
    else:
        prompt = _L1_PROMPT_TEMPLATE.format(filename=filename, content=content)

    # 调用 Ollama（localhost — 不走系统代理）
    try:
        async with httpx.AsyncClient(
            base_url=config.ollama_url,
            timeout=30.0,
        ) as client:
            resp = await client.post(
                "/api/generate",
                json={
                    "model": config.cognition_model,
                    "prompt": prompt,
                    "stream": False,
                },
            )
            resp.raise_for_status()
            _l1_cooldown.record_success()
            raw = resp.json().get("response", "")
    except Exception as e:
        logger.warning("rules_extractor L1 LLM 调用失败: %s", e)
        _l1_cooldown.record_failure()
        return []

    # 解析
    items = _parse_l1_output(raw)
    if not items:
        logger.debug("rules_extractor L1 解析失败")
        return []

    # 验证 + 过滤
    results: list[dict] = []
    for item in items:
        validated = _validate_l1_item(item)
        if validated:
            # 补充 file 信息到 context_json
            ctx = json.loads(validated["context_json"])
            ctx["file"] = filename
            validated["context_json"] = json.dumps(ctx, ensure_ascii=False)
            results.append(validated)

    logger.info("rules_extractor L1 提取 %d 条 (from %s)", len(results), filename)
    return results


# ── 主入口 ──────────────────────────────────────────────────

async def extract_worldview(
    db_cog,
    config: Config,
    collection: str,
    force: bool = False,
) -> dict:
    """主入口 — 扫描规则文件，提取偏好写入 cognitive.db

    Args:
        db_cog: CogDatabase 实例
        config: 配置
        collection: 项目集合名
        force: True = 忽略 hash，全量重提取

    Returns:
        {"extracted": N, "skipped": N, "files": [...], "deduped": N}
    """
    if not config.worldview_extraction_enabled:
        return {"extracted": 0, "skipped": 0, "files": [], "deduped": 0, "reason": "disabled"}

    # 1. 扫描文件
    all_files = _scan_rule_files(config)
    if not all_files:
        return {"extracted": 0, "skipped": 0, "files": [], "deduped": 0, "reason": "no files"}

    # 2. 增量检测
    if force:
        changed = [(f, h) for f in all_files if (h := _compute_file_hash(f))]
    else:
        changed = _get_changed_files(all_files, db_cog)

    if not changed:
        return {
            "extracted": 0,
            "skipped": len(all_files),
            "files": [],
            "deduped": 0,
        }

    # 3. 逐文件提取
    total_extracted = 0
    total_deduped = 0
    processed_files: list[str] = []

    for path, file_hash in changed:
        filename = path.name
        try:
            content = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError) as e:
            logger.warning("rules_extractor 读取 %s 失败: %s", path, e)
            continue

        # L0 提取
        l0_facts = _extract_l0(content, filename)

        # L1 LLM 增强提取（可选）
        l1_facts: list[dict] = []
        try:
            l1_facts = await _extract_l1(content, filename, config)
        except Exception as e:
            logger.warning("rules_extractor L1 失败 (%s): %s", filename, e)

        # 合并 L0 + L1（L0 优先）
        all_facts = l0_facts + l1_facts

        # 先收集通过去重的 facts（不写入）
        file_deduped = 0
        valid_facts: list[dict] = []
        for fact in all_facts:
            fact["collection"] = collection
            fact["scope"] = "project"

            # 从 assertion 提取 scope 信息
            scope = extract_scope(fact["assertion"])
            fact["scope_files"] = json.dumps(scope["scope_files"])
            fact["scope_entities"] = json.dumps(scope["scope_entities"])
            fact["scope_modules"] = json.dumps(scope["scope_modules"])

            # 去重检查
            dedup_result = check_duplicate(
                assertion=fact["assertion"],
                collection=collection,
                session_id=None,
                db_cog=db_cog,
            )
            if dedup_result.is_dup:
                file_deduped += 1
                continue

            valid_facts.append(fact)

        total_deduped += file_deduped

        # 有有效 facts 时才 delete + batch insert（原子化：先删旧再写新）
        if valid_facts:
            _delete_old_facts(db_cog, str(path), collection)
            file_extracted = 0
            for fact in valid_facts:
                result = db_cog.insert_fact(fact)
                if result is not None:
                    file_extracted += 1
                    total_extracted += 1
        else:
            file_extracted = 0

        # 更新 worldview_sources 记录
        _update_source_record(db_cog, str(path), file_hash, collection, file_extracted)
        processed_files.append(filename)
        logger.info(
            "rules_extractor %s: L0=%d, L1=%d, 写入=%d, 去重=%d",
            filename, len(l0_facts), len(l1_facts), file_extracted, file_deduped,
        )

    return {
        "extracted": total_extracted,
        "skipped": len(all_files) - len(changed),
        "files": processed_files,
        "deduped": total_deduped,
    }


def _delete_old_facts(db_cog, file_path: str, collection: str = "") -> None:
    """删除指定文件的旧 facts（同文件重新提取时清理）"""
    try:
        # 查找该文件的 facts（source=rules_extraction + collection 过滤）
        sql = "SELECT id, context_json FROM facts WHERE source = ? AND status = 'active'"
        params: list = [SOURCE_TAG]
        if collection:
            sql += " AND collection = ?"
            params.append(collection)
        rows = db_cog.conn.execute(sql, params).fetchall()
        ids_to_delete: list[int] = []
        for row in rows:
            try:
                ctx = json.loads(row[1]) if row[1] else {}
                if ctx.get("file") == Path(file_path).name:
                    ids_to_delete.append(row[0])
            except (json.JSONDecodeError, TypeError):
                pass
        if ids_to_delete:
            # 软删除（标记 invalidated_at）
            placeholders = ",".join("?" * len(ids_to_delete))
            safe_db_write(
                db_cog.conn,
                f"UPDATE facts SET status = 'invalidated', "
                f"invalidated_at = datetime('now') "
                f"WHERE id IN ({placeholders})",
                ids_to_delete,
            )
            logger.debug("rules_extractor 清理 %s 的 %d 条旧 facts", file_path, len(ids_to_delete))
    except Exception as e:
        logger.warning("rules_extractor 清理旧 facts 失败: %s", e)


def _update_source_record(
    db_cog,
    file_path: str,
    file_hash: str,
    collection: str,
    fact_count: int,
) -> None:
    """更新 worldview_sources 记录（含 mtime+size 供快速检测）"""
    mtime = 0.0
    file_size = 0
    try:
        st = Path(file_path).stat()
        mtime = st.st_mtime
        file_size = st.st_size
    except OSError:
        pass
    safe_db_write(
        db_cog.conn,
        """INSERT OR REPLACE INTO worldview_sources
           (file_path, hash, mtime, file_size, collection, last_extracted, fact_count)
           VALUES (?, ?, ?, ?, ?, datetime('now'), ?)""",
        [file_path, file_hash, mtime, file_size, collection, fact_count],
    )


# ── 辅助：快速检测文件变更（供 hooks.py awaken() 使用）──

def check_worldview_pending(config: Config, db_cog) -> bool:
    """快速检查规则文件是否有变更（<5ms 延迟预算）

    只做 stat()（mtime + size 对比），不读文件内容，不做 DB 写入。
    用于 awaken() 决定是否写 pending marker。
    """
    if not config.worldview_extraction_enabled:
        return False

    files = _scan_rule_files(config)
    if not files:
        return False

    for path in files:
        try:
            st = path.stat()
        except OSError:
            continue
        try:
            row = db_cog.conn.execute(
                "SELECT mtime, file_size FROM worldview_sources WHERE file_path = ?",
                (str(path),),
            ).fetchone()
            if not row:
                return True  # 新文件
            if row[0] != st.st_mtime or row[1] != st.st_size:
                return True  # 文件变更
        except Exception:
            return True  # DB 不可用，保守触发

    return False
