# -*- coding: utf-8 -*-
"""园区知识库 —— 轻量本地 RAG（embedding + numpy 余弦检索，双路索引）

升级为「细粒度分块 + LLM 生成问答对 + 双路向量索引（原句 + 问题）+ 余弦检索返回原文」：
- 知识源：backend/knowledge/park.md（按 H2 节 → 原子事实两级分块）
- 向量化：dashscope text-embedding-v4（云端 API，索引构建一次后缓存，构建时 L2 归一化）
- 问答：复用 llm._chat_once 对每块生成 3-5 个口语化问句，缓存到 kb_qa.json（无 key 自动单路降级）
- 检索：query 同时比对原句向量与问题向量，按块去重，返回 top_k 个原文块
- 提供：
  - retrieve(query, top_k) → 检索命中的原文块（对话动态注入）
  - full_kb() → 整库原文（小库全量注入，供语音 instructions）
  - brief() → 前 N 字摘要（兼容 /api/kb/brief 与检索无命中回退）
"""
import hashlib
import json
import logging
import os
import re
from pathlib import Path

import numpy as np

log = logging.getLogger("dpm.rag")


def _trunc(s, n=300):
    """日志用截断：超长内容只留前 n 字并标注总长。"""
    s = str(s)
    return s if len(s) <= n else s[:n] + f"...（共{len(s)}字）"


KB_DIR = Path(__file__).resolve().parent / "knowledge"
KB_GLOB = "*.md"      # 知识库 = knowledge/ 下全部 markdown（park.md + opc.md 等）
META_FILE = KB_DIR / "kb_meta.json"     # 元数据（JSON，可读）：model/hash/chunks/questions/embed_texts/embed_owner
VECTOR_FILE = KB_DIR / "kb_vectors.npy" # 向量矩阵（二进制 numpy float32，快载小体积）
LEGACY_INDEX_FILE = KB_DIR / "kb_index.json"  # 旧版单文件索引（迁移后删除）
QA_FILE = KB_DIR / "kb_qa.json"
EMBED_MODEL = "text-embedding-v4"
EMBED_BATCH = 10      # DashScope embedding 单批上限
QA_BATCH = 5          # 每批几块生成问答
CHUNK_MAX = 240       # 单块超过此长度按句拆

_index = None  # 缓存 {chunks, questions, embed_texts, embed_owner, embeddings(归一化 np.ndarray)}


def _kb_text() -> str:
    """拼接 knowledge/ 下全部 markdown 内容（按文件名排序，稳定顺序）。"""
    parts = []
    for f in sorted(KB_DIR.glob(KB_GLOB)):
        try:
            parts.append(f.read_text("utf-8"))
        except OSError as e:  # noqa: BLE001
            log.warning("rag: 跳过知识文件 %s: %s", f.name, e)
    return "\n\n".join(parts)


def _data_hash() -> str:
    """全部知识文件内容签名，用于索引/问答缓存失效判断。"""
    return hashlib.sha1(_kb_text().encode("utf-8")).hexdigest()


def _split_long(text: str, limit: int = CHUNK_MAX) -> list[str]:
    """超长块按句号/分号/顿号等边界拆成多块，保留语义完整。"""
    if len(text) <= limit:
        return [text]
    parts: list[str] = []
    cur = ""
    for seg in re.split(r"(?<=[。！？；、])", text):
        if len(cur) + len(seg) > limit and cur:
            parts.append(cur.strip())
            cur = ""
        cur += seg
    if cur.strip():
        parts.append(cur.strip())
    return [p for p in parts if len(p) > 8]


def _chunk_md(text: str) -> list[str]:
    """两级切分：H2 节标题作上下文前缀 + 节内原子事实。

    企业条目（加粗公司名行 `**...｜公司名**` + 负责人/简介列表）合并为单个块，
    保证「企业名称 + 企业介绍」作为一个整体可被按名召回。
    """
    chunks: list[str] = []
    section = ""
    para: list[str] = []
    company: list[str] | None = None   # 正在累积的单个企业块

    def flush_para():
        nonlocal para
        if para:
            t = "\n".join(para).strip()
            if len(t) > 8:
                chunks.extend(_split_long(t))
            para = []

    def flush_company():
        nonlocal company
        if company:
            t = "\n".join(company).strip()
            if len(t) > 8:
                chunks.append(t)   # 企业块不拆，保证名称+介绍同块
            company = None

    def add_fact(content: str):
        full = f"{section}\n{content}" if section else content
        if len(full) > 8:
            chunks.extend(_split_long(full))

    for line in text.splitlines():
        s = line.rstrip()
        if s.startswith("# "):          # H1：不产块
            flush_para(); flush_company()
            continue
        if s.startswith("## "):         # H2 小节：切换上下文前缀
            flush_para(); flush_company()
            section = s[3:].strip()
            continue
        if s.startswith("###"):         # H3 子节：上下文（如分区），flush 企业
            flush_para(); flush_company()
            para.append(s.strip())
            continue
        stripped = s.strip()
        if not stripped:
            continue
        # 企业条目：加粗公司名行作为单个企业块的起点
        if stripped.startswith("**") and "｜" in stripped:
            flush_para(); flush_company()
            company = [stripped]
            continue
        if company is not None:
            # 企业块的跟随行（负责人/简介等列表项）
            if stripped.startswith("- ") or stripped.startswith("问：") or re.match(r"^\d+[\.、]\s", stripped):
                company.append(stripped)
            else:
                flush_company()
                para.append(stripped)
            continue
        if stripped.startswith("- "):          # 列表项 = 原子事实
            flush_para(); add_fact(stripped)
        elif re.match(r"^\d+[\.、]\s", stripped):  # 编号步
            flush_para(); add_fact(stripped)
        elif stripped.startswith("问："):      # 单条问答
            flush_para(); add_fact(stripped)
        else:                                   # 普通段落：累积后整段成块（超长再拆）
            para.append(stripped)
    flush_para()
    flush_company()
    return chunks


def _company_name(chunk: str) -> str:
    """从企业块提取企业名称：`**加速1｜云南派音人工智能科技有限公司**` → `云南派音人工智能科技有限公司`。"""
    m = re.match(r"^\*\*[^｜\n]*｜([^*]+)\*\*", chunk) or re.match(r"^\*\*([^*]+)\*\*", chunk)
    return m.group(1).strip() if m else ""


def _embed(texts: list[str]) -> list[list[float]]:
    """dashscope text-embedding-v4 批量向量化（每批 ≤10 条，按 text_index 排序拼接）。"""
    import dashscope

    api_key = os.environ.get("DASHSCOPE_API_KEY") or ""
    out: list[list[float]] = []
    for i in range(0, len(texts), EMBED_BATCH):
        batch = texts[i:i + EMBED_BATCH]
        resp = dashscope.TextEmbedding.call(
            model=EMBED_MODEL,
            input=batch,
            api_key=api_key,
        )
        if resp.status_code != 200:
            raise RuntimeError(
                f"embedding API {resp.status_code}: {resp.code} {resp.message}"
            )
        embs = sorted(
            resp.output["embeddings"],
            key=lambda e: e.get("text_index", e.get("index", 0)),
        )
        out.extend(e["embedding"] for e in embs)
    log.info("rag: embedding 完成 %d 条（%s，每批 %d）", len(texts), EMBED_MODEL, EMBED_BATCH)
    return out


def _normalize(vectors: list[list[float]]) -> np.ndarray:
    """转为 float32 np.ndarray 并做 L2 行归一化（点积即余弦相似度）。"""
    arr = np.asarray(vectors, dtype=np.float32)
    norms = np.linalg.norm(arr, axis=1, keepdims=True)
    norms[norms == 0] = 1.0
    return arr / norms


# ── LLM 问答生成 ────────────────────────────────────────────────

def _parse_json_array(text: str) -> list:
    """解析模型返回的 JSON 数组：剥 markdown 围栏 → json.loads → 截取首尾方括号兜底。"""
    text = (text or "").strip()
    if text.startswith("```"):
        lines = [l for l in text.splitlines() if not l.strip().startswith("```")]
        text = "\n".join(lines).strip()
    for candidate in (text,):
        try:
            return json.loads(candidate)
        except Exception:
            pass
    i = text.find("[")
    j = text.rfind("]")
    if i != -1 and j > i:
        try:
            return json.loads(text[i:j + 1])
        except Exception:
            pass
    return []


def _qa_for_batch(chunks: list[str]) -> list[list[str]]:
    """对一批分块，用 LLM 生成每块的 3-5 个口语化问句（输出 JSON 数组，与块一一对应）。"""
    from .llm import _chat_once

    numbered = "\n\n".join(f"[{i}]\n{c}" for i, c in enumerate(chunks))

    def build_prompt():
        return (
            "下面是园区知识库的若干分块，每块标有 [编号]。"
            "请为每一块生成 3-5 个用户可能会问的自然口语化中文问句，"
            "覆盖该块全部信息点与常见同义问法。"
            f"严格只输出一个 JSON 数组，数组长度必须为 {len(chunks)}，"
            "第 i 个元素是第 i 块的问题字符串数组。不要输出任何其他文字或解释。\n\n"
            + numbered
        )

    for attempt in range(2):
        try:
            data = _chat_once([{"role": "user", "content": build_prompt()}], with_tools=False)
            content = data["choices"][0]["message"].get("content") or ""
            arr = _parse_json_array(content)
            if isinstance(arr, list) and arr:
                out: list[list[str]] = []
                for i in range(len(chunks)):
                    qs = arr[i] if i < len(arr) and isinstance(arr[i], list) else []
                    out.append([str(q).strip() for q in qs if str(q).strip()][:5])
                return out
        except Exception as e:  # noqa: BLE001
            log.warning("rag: QA 批生成失败（尝试 %d/%d）: %s", attempt + 1, 2, e)
    return [[] for _ in chunks]


def _first_line(chunk: str) -> str:
    """取块首行（去标题），用作无问句时的兜底检索问题。"""
    for ln in chunk.splitlines():
        ln = ln.strip()
        if ln and not ln.startswith("#"):
            return ln
    return chunk[:60]


def _ensure_questions(chunks: list[str], questions) -> list[list[str]]:
    """确保每块都有至少一个检索问题：未生成问句的块，用块首句兜底（保证全量覆盖）。"""
    qs: list[list[str]] = [list(q or []) for q in (questions or [])]
    if len(qs) < len(chunks):
        qs.extend([] for _ in range(len(chunks) - len(qs)))
    for i, c in enumerate(chunks):
        if not qs[i]:
            qs[i] = [_first_line(c)]
    return qs


def _load_questions(chunks: list[str]) -> list[list[str]]:
    """加载/生成问答缓存；返回与 chunks 对齐的问题数组（保证每块 ≥1 个问题）。无 key 时单路回退。"""
    from .config import settings

    if not settings.DASHSCOPE_API_KEY:
        log.warning("rag: 无 DASHSCOPE_API_KEY，跳过问答生成（单路检索）")
        return _ensure_questions(chunks, [[] for _ in chunks])
    h = _data_hash()
    if QA_FILE.exists():
        try:
            data = json.loads(QA_FILE.read_text("utf-8"))
            if data.get("data_hash") == h and data.get("chunks") == chunks:
                log.info("rag: 问答缓存已加载（%d 块）", len(chunks))
                return _ensure_questions(chunks, data["questions"])
        except Exception:  # noqa: BLE001
            log.warning("rag: 问答缓存损坏，重建")
    questions: list[list[str]] = []
    total = len(chunks)
    for i in range(0, total, QA_BATCH):
        questions.extend(_qa_for_batch(chunks[i:i + QA_BATCH]))
        log.info("rag: 问答生成进度 %d/%d", min(i + QA_BATCH, total), total)
    questions = _ensure_questions(chunks, questions)
    QA_FILE.write_text(
        json.dumps({"data_hash": h, "chunks": chunks, "questions": questions}, ensure_ascii=False),
        "utf-8",
    )
    log.info("rag: 问答缓存已生成（%d 块）-> %s", len(chunks), QA_FILE)
    return questions


# ── 双路向量索引 ────────────────────────────────────────────────

def _migrate_legacy():
    """把旧版单文件 kb_index.json（向量内嵌在 JSON）就地转为 kb_vectors.npy + kb_meta.json，再删除旧文件。"""
    if not LEGACY_INDEX_FILE.exists() or META_FILE.exists():
        return
    try:
        d = json.loads(LEGACY_INDEX_FILE.read_text("utf-8"))
        np.save(VECTOR_FILE, np.asarray(d["embeddings"], dtype=np.float32))
        META_FILE.write_text(
            json.dumps({
                "model": d.get("model"),
                "data_hash": d.get("data_hash"),
                "chunks": d["chunks"],
                "questions": d.get("questions", []),
                "embed_texts": d["embed_texts"],
                "embed_owner": d["embed_owner"],
            }, ensure_ascii=False),
            "utf-8",
        )
        LEGACY_INDEX_FILE.unlink()
        log.info("rag: 已迁移旧 kb_index.json -> kb_vectors.npy + kb_meta.json（无重建，未调 API）")
    except Exception as e:  # noqa: BLE001
        log.warning("rag: 旧索引迁移失败（将重建）: %s", e)


def _load_index():
    """构建/加载双路向量索引（原句 + 生成问题）。向量存二进制 .npy，元数据存 JSON。"""
    global _index
    if _index is not None:
        return _index
    _migrate_legacy()
    h = _data_hash()
    if META_FILE.exists() and VECTOR_FILE.exists():
        try:
            data = json.loads(META_FILE.read_text("utf-8"))
            valid = (
                data.get("model") == EMBED_MODEL
                and data.get("data_hash") == h
                and len(data.get("embed_texts", [])) == len(data.get("embed_owner", []))
            )
            if valid:
                vectors = np.load(VECTOR_FILE)  # 已归一化 float32
                if len(vectors) == len(data["embed_texts"]):
                    _index = {
                        "chunks": data["chunks"],
                        "questions": data.get("questions", []),
                        "embed_texts": data["embed_texts"],
                        "embed_owner": data["embed_owner"],
                        "embeddings": vectors,
                    }
                    log.info("rag: 双路知识索引已加载（%d 块 → %d 向量, %s）",
                             len(_index["chunks"]), len(_index["embed_texts"]), EMBED_MODEL)
                    return _index
            log.warning("rag: 索引失效（model/hash/长度不符），重建")
        except Exception:  # noqa: BLE001
            log.warning("rag: 索引损坏，重建")
    # 构建
    chunks = _chunk_md(_kb_text())
    questions = _load_questions(chunks)
    embed_texts: list[str] = []
    embed_owner: list[int] = []
    for i, c in enumerate(chunks):
        embed_texts.append(c)
        embed_owner.append(i)
        # 企业块：把「企业名称」作为确定性问题加入索引，保证按名称可召回（名称为问、介绍为答）
        cname = _company_name(c)
        if cname:
            embed_texts.append(cname)
            embed_owner.append(i)
        for q in questions[i]:
            embed_texts.append(q)
            embed_owner.append(i)
    log.info("rag: 构建双路索引（%d 块 → %d 向量，%s embedding 中…）",
             len(chunks), len(embed_texts), EMBED_MODEL)
    arr = _normalize(_embed(embed_texts))
    _index = {
        "chunks": chunks,
        "questions": questions,
        "embed_texts": embed_texts,
        "embed_owner": embed_owner,
        "embeddings": arr,
    }
    np.save(VECTOR_FILE, arr)
    META_FILE.write_text(
        json.dumps({
            "model": EMBED_MODEL,
            "data_hash": h,
            "chunks": chunks,
            "questions": questions,
            "embed_texts": embed_texts,
            "embed_owner": embed_owner,
        }, ensure_ascii=False),
        "utf-8",
    )
    log.info("rag: 双路索引构建完成（%d 块, dim=%d）-> %s + %s",
             len(chunks), arr.shape[1], VECTOR_FILE, META_FILE)
    return _index


# ── 对外接口 ────────────────────────────────────────────────────

def retrieve(query: str, top_k: int = 3, threshold: float = 0.3) -> list[str]:
    """双路检索：query 同时比对原句与问题向量，按块去重，返回 top_k 个原文块。"""
    if not query or not query.strip():
        return []
    idx = _load_index()
    q = _normalize([_embed([query])[0]])[0]
    sims = np.dot(idx["embeddings"], q)
    order = np.argsort(-sims)
    best: dict[int, float] = {}  # owner -> 该块最高分
    for e in order:
        owner = idx["embed_owner"][int(e)]
        sc = float(sims[int(e)])
        if sc < threshold:
            break
        if owner not in best:
            best[owner] = sc
        if len(best) >= top_k:
            break
    ordered = sorted(best.items(), key=lambda kv: kv[1], reverse=True)
    hits = [idx["chunks"][o] for o, _ in ordered]
    log.info("rag: retrieve() query=%s top_k=%d threshold=%.2f -> 命中 %d 段",
             _trunc(query, 120), top_k, threshold, len(hits))
    for j, (o, sc) in enumerate(ordered):
        log.info("rag:   [%d] %.4f %s", j, sc, _trunc(idx["chunks"][o], 300))
    return hits


def brief(max_chars: int = 1200) -> str:
    """固定知识摘要（前 N 字；兼容 /api/kb/brief 与检索无命中回退）。"""
    idx = _load_index()
    out = []
    total = 0
    for c in idx["chunks"]:
        if total + len(c) > max_chars:
            break
        out.append(c)
        total += len(c)
    text = "\n\n".join(out)
    log.info("rag: brief() 知识库摘要 %d 字 -> %s", total, _trunc(text, 300))
    return text


def full_kb() -> str:
    """整库原文（小库全量注入，用于语音 instructions，修复尾部知识丢失）。"""
    idx = _load_index()
    text = "\n\n".join(idx["chunks"])
    log.info("rag: full_kb() 全库 %d 字 / %d 块", len(text), len(idx["chunks"]))
    return text


def build_instructions() -> str:
    """语音助手的完整基础提示词 = 配置提示词 + 整库知识（服务端统一组装）。"""
    from .config import settings
    base = settings.S2S_INSTRUCTIONS.strip()
    try:
        kb = full_kb()
        if kb:
            base += f"\n\n【园区知识库】\n{kb}"
    except Exception as e:  # noqa: BLE001
        log.warning("rag: 知识库拼接失败（仅用基础提示词）: %s", e)
    log.info("rag: build_instructions() 语音系统提示词=%s", _trunc(base, 600))
    return base
