"""个人知识库: `workspace/users/<user_id>/.kb/<库名>/` —— 纯文件,无向量无 DB。

照「记忆」机制范式(core/memory.py):真实文件为准 + agentic search,索引只是
可重建的派生视图。每个库一个目录:

  .kb/<库名>/
    INDEX.md     —— 库目录(单行一条,格式见 INDEX_LINE_FORMAT),注 prompt 的召回依据
    docs/<x>.md  —— 转换后的 markdown 正文(agent 用 read/grep 按需拉)
    sources/<x>  —— 原始文件(pdf/docx/...),留档 + 待入库队列

**「已入库」判据 = INDEX.md 有指向该 doc 的条目**;sources 有而 INDEX 无对应 doc
= 待入库。由此入库天然幂等(重跑只补缺口)、崩溃可恢复(半截转换重来即可)、
零 DB migration。入库编排在 core/kb_ingest.py,本模块只管状态读写与视图。

.kb 是 dotfile:被 GET /v1/files 天然隐藏、validate_task_name 拒 `.` 起头 ——
与 .memory/.skills 同款防呆。agent 无需新工具:fs 工具 user_root 内可读写,
docker 沙箱把整个 user_root bind 到 /workspace,.kb 随之可见。
"""
from __future__ import annotations

import re
import shutil
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from uuid import UUID

from .file_store import atomic_write_bytes, atomic_write_text
from .kb_lock import kb_mutation_lock

# INDEX 单行格式(全角 ｜ 分隔,摘要/关键词内允许半角标点)。agent 对话内手动入库
# 与后台 ingest 产出同一格式 —— 契约文本(kb_block)里原样给出。
INDEX_LINE_FORMAT = "- [标题](docs/<文件名>.md)｜来源 sources/<原件名>｜摘要：<两三句>｜关键词：<逗号分隔>"

_INDEX_LINE_RE = re.compile(
    r"^-\s*\[(?P<title>[^\]]*)\]\((?P<doc>docs/[^)]+)\)"
    r"\s*｜\s*来源\s*(?P<source>sources/[^｜]+?)"
    r"\s*｜\s*摘要[:：]\s*(?P<summary>[^｜]*)"
    r"(?:\s*｜\s*关键词[:：]\s*(?P<keywords>.*))?\s*$"
)

# 库名:中文/字母/数字/-/_,拒 dotfile、路径分隔、Windows 保留字符。
_KB_NAME_RE = re.compile(r"^[\w一-鿿][\w一-鿿\-. ]{0,39}$")
# 库内文件名(docs/ 与 sources/ 下的扁平文件):拒斜杠 / `..` / dotfile。
_FILE_NAME_RE = re.compile(r"^[^/\\]{1,200}$")


def kb_root(workspace_dir: Path, user_id: UUID) -> Path:
    return workspace_dir / "users" / str(user_id) / ".kb"


def is_safe_kb_name(name: str) -> bool:
    if not name or name != name.strip() or name.startswith("."):
        return False
    if ".." in name or any(c in name for c in '/\\:*?"<>|'):
        return False
    return bool(_KB_NAME_RE.match(name))


def is_safe_file_name(name: str) -> bool:
    if not name or name.startswith(".") or ".." in name:
        return False
    return bool(_FILE_NAME_RE.match(name))


def kb_dir(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Path]:
    """库目录(校验名字合法 + 落在 .kb 子树内);非法返回 None(调用方转 4xx)。"""
    if not is_safe_kb_name(name):
        return None
    root = kb_root(workspace_dir, user_id).resolve()
    d = (root / name).resolve()
    if d.parent != root:
        return None
    return d


def parse_index(text: str) -> List[Dict[str, str]]:
    """解析 INDEX.md → [{title, doc, source, summary, keywords}];不合格式的行忽略。"""
    out: List[Dict[str, str]] = []
    for raw in text.splitlines():
        m = _INDEX_LINE_RE.match(raw.strip())
        if not m:
            continue
        out.append({
            "title": m.group("title").strip(),
            "doc": m.group("doc").strip(),
            "source": (m.group("source") or "").strip(),
            "summary": (m.group("summary") or "").strip(),
            "keywords": (m.group("keywords") or "").strip(),
        })
    return out


def format_index_line(*, title: str, doc: str, source: str, summary: str, keywords: str) -> str:
    """产出与 INDEX_LINE_FORMAT 一致的单行(后台 ingest 用;agent 侧照契约手写)。"""
    def clean(s: str) -> str:
        return " ".join((s or "").split()).replace("｜", "|")
    return (
        f"- [{clean(title)}]({doc})｜来源 {source}"
        f"｜摘要：{clean(summary)}｜关键词：{clean(keywords)}"
    )


def _read_index(d: Path) -> List[Dict[str, str]]:
    p = d / "INDEX.md"
    if not p.is_file():
        return []
    try:
        return parse_index(p.read_text(encoding="utf-8"))
    except (OSError, UnicodeDecodeError):
        return []


def _list_sources(d: Path) -> List[str]:
    src = d / "sources"
    if not src.is_dir():
        return []
    return sorted(p.name for p in src.iterdir() if p.is_file() and not p.name.startswith("."))


def pending_sources(d: Path) -> List[str]:
    """sources 有而 INDEX 无 = 待入库(判据即幂等性来源,见模块注释)。"""
    indexed = {e["source"].removeprefix("sources/") for e in _read_index(d)}
    return [n for n in _list_sources(d) if n not in indexed]


def list_kbs(workspace_dir: Path, user_id: UUID) -> List[Dict[str, Any]]:
    """所有库概览:[{name, doc_count, pending_count}],按名排序。"""
    root = kb_root(workspace_dir, user_id)
    if not root.is_dir():
        return []
    out: List[Dict[str, Any]] = []
    for d in sorted(root.iterdir()):
        if not d.is_dir() or d.name.startswith("."):
            continue
        entries = _read_index(d)
        out.append({
            "name": d.name,
            "doc_count": len(entries),
            "pending_count": len(pending_sources(d)),
        })
    return out


def create_kb(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Path]:
    """建库(幂等):目录 + 空 INDEX.md + docs/ + sources/。名字非法返回 None。"""
    d = kb_dir(workspace_dir, user_id, name)
    if d is None:
        return None
    with kb_mutation_lock(workspace_dir, user_id, name):
        (d / "docs").mkdir(parents=True, exist_ok=True)
        (d / "sources").mkdir(parents=True, exist_ok=True)
        idx = d / "INDEX.md"
        if not idx.exists():
            atomic_write_text(idx, f"# {name}\n\n")
    return d


def delete_kb(workspace_dir: Path, user_id: UUID, name: str) -> bool:
    """整库删除(原件 + docs + INDEX 一起没,前端已二次确认)。"""
    d = kb_dir(workspace_dir, user_id, name)
    if d is None or not d.is_dir():
        return False
    with kb_mutation_lock(workspace_dir, user_id, name):
        if not d.is_dir():
            return False
        shutil.rmtree(d)
    return True


def kb_detail(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Dict[str, Any]]:
    """单库全貌:INDEX 条目 + 待入库 sources 列表。库不存在返回 None。"""
    d = kb_dir(workspace_dir, user_id, name)
    if d is None or not d.is_dir():
        return None
    return {
        "name": name,
        "entries": _read_index(d),
        "pending": pending_sources(d),
    }


def read_doc(workspace_dir: Path, user_id: UUID, name: str, filename: str) -> Optional[str]:
    """读单篇 docs/<filename> 原文;非法 / 不存在 → None(调用方转 404)。"""
    d = kb_dir(workspace_dir, user_id, name)
    if d is None or not is_safe_file_name(filename) or not filename.endswith(".md"):
        return None
    target = (d / "docs" / filename).resolve()
    if target.parent != (d / "docs").resolve() or not target.is_file():
        return None
    try:
        return target.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return None


def _delete_doc_unlocked(
    d: Path, filename: str, *, delete_source: bool = True
) -> bool:
    if not is_safe_file_name(filename) or not filename.endswith(".md"):
        return False
    doc_rel = f"docs/{filename}"
    entries = _read_index(d)
    hit = next((e for e in entries if e["doc"] == doc_rel), None)
    target = (d / "docs" / filename).resolve()
    if target.parent != (d / "docs").resolve():
        return False
    if not target.is_file() and hit is None:
        return False
    src: Optional[Path] = None
    if hit is not None:
        src_name = hit["source"].removeprefix("sources/")
        if is_safe_file_name(src_name):
            src = d / "sources" / src_name
        idx = d / "INDEX.md"
        try:
            lines = idx.read_text(encoding="utf-8").splitlines()
            kept = [
                ln for ln in lines
                if not (_INDEX_LINE_RE.match(ln.strip()) and f"({doc_rel})" in ln)
            ]
            atomic_write_text(idx, "\n".join(kept).rstrip() + "\n")
        except (OSError, UnicodeDecodeError):
            return False
    # Publish the recoverable INDEX state before deleting data:if the process
    # dies here, an undeleted source is merely pending and can be re-ingested.
    if target.is_file():
        target.unlink()
    if delete_source and src is not None and src.is_file():
        src.unlink()
    return True


def delete_doc(workspace_dir: Path, user_id: UUID, name: str, filename: str) -> bool:
    """删单篇:docs 文件 + INDEX 对应行 + 对应 source 原件一起删。"""
    d = kb_dir(workspace_dir, user_id, name)
    if d is None:
        return False
    with kb_mutation_lock(workspace_dir, user_id, name):
        return _delete_doc_unlocked(d, filename)


def _save_source_unlocked(d: Path, filename: str, data: bytes) -> Optional[str]:
    if not d.is_dir() or not is_safe_file_name(filename):
        return None
    src_dir = d / "sources"
    src_dir.mkdir(parents=True, exist_ok=True)
    # 覆盖旧 doc 判据:同名 source 若已在 INDEX,删掉旧条目让它重新排队入库
    doc_rel_hits = [e for e in _read_index(d) if e["source"] == f"sources/{filename}"]
    for e in doc_rel_hits:
        if not _delete_doc_unlocked(
            d, e["doc"].removeprefix("docs/"), delete_source=False
        ):
            return None
    atomic_write_bytes(src_dir / filename, data)
    return filename


def save_sources(
    workspace_dir: Path,
    user_id: UUID,
    name: str,
    items: Iterable[tuple[str, bytes]],
) -> Optional[List[str]]:
    """一把库锁内批量保存原件;任一文件名非法时整批不写。"""
    d = kb_dir(workspace_dir, user_id, name)
    batch = list(items)
    if (
        d is None
        or not d.is_dir()
        or any(not is_safe_file_name(filename) for filename, _data in batch)
    ):
        return None
    with kb_mutation_lock(workspace_dir, user_id, name):
        saved: List[str] = []
        for filename, data in batch:
            result = _save_source_unlocked(d, filename, data)
            if result is None:
                return None
            saved.append(result)
        return saved


def save_source(workspace_dir: Path, user_id: UUID, name: str, filename: str, data: bytes) -> Optional[str]:
    """上传单个原件;保留给内部调用方的兼容入口。"""
    saved = save_sources(workspace_dir, user_id, name, [(filename, data)])
    return saved[0] if saved else None


# ── prompt 注入(照 memory_block 范式) ────────────────────────────────

# 零库时的冷启动契约:曾因零库零注入,用户说「放进知识库」被 agent 就近写进
# .memory/(2026-07-24)—— 模型根本不知道 KB 机制存在。与 memory 空契约常驻
# 同一课:教会第一次即可,建库落盘后下轮 build_agent 自然切到全量注入。
_KB_COLD_START = """\


## 个人知识库 (user 级,跨 task 共享)

用户还没有任何知识库(`{base}/` 下无库)。当用户明确要求把资料**放进知识库 /
长期保存备查**时,不要写进记忆 —— 直接用 fs 工具建库入库(无需专用工具):
- **建库**:创建 `{base}/<库名>/docs/` 与 `{base}/<库名>/sources/` 两个目录,写
  `{base}/<库名>/INDEX.md`(首行 `# <库名>`)。库名用中文/字母/数字。
- **入库**:原件放 `sources/`(对话里没有原件就跳过);正文转成 markdown 写
  `docs/<文件名>.md`;再往 INDEX.md 追加一行,**格式必须是**:
  `{fmt}`
  (摘要写准 —— 它是下次召回的依据)。
- **分工**:成篇资料(文档/规范/报告)进知识库;关于用户本人的短事实才写记忆。
  仅用户明确表达"存知识库/长期保存"时才建库,临时讨论材料不要自动入库。"""

_KB_CONTRACT = """\
用法规矩:
- **主动查阅**:回答前先扫一眼下方各库条目,标题 / 摘要 / 关键词与用户问题相关就先
  `read` 对应 `docs/*.md` 正文再作答(量大时 `grep` 先缩范围)。**无需用户点名
  "知识库"** —— 用户资料里可能已有的内容,宁可多查一次,不要凭空回答。
- **答题标来源**:引用了哪个库哪篇就在回答里注明(标题或文件名)。
- **对话内入库**:用户在对话里给了值得长期留的资料时,可直接写入:原件放
  `sources/`(没有原件就跳过)、正文转成 markdown 写 `docs/<slug>.md`、再往该库
  INDEX.md 追加一行,**格式必须是**:
  `{fmt}`
  (与后台自动入库产出一致;摘要写准 —— 它是下次召回的依据)。"""


def kb_block(
    workspace_dir: Path,
    user_id: UUID,
    kb_dir_display: Optional[str] = None,
) -> str:
    """构造注入 system prompt 的知识库段;零库时注极简冷启动契约(~百 token)。

    kb_dir_display: `.kb/` 在 agent 视角下的路径前缀(docker 传 `/workspace/.kb`,
    host 传 None ⇒ 宿主绝对路径)—— 与 memory_block 的 mem_dir_display 同款约定。
    注 INDEX 全文而非只注库名:INDEX 就是召回索引(照 memory extended 的
    description 逻辑),几十篇的个人库体量注得起;正文仍按需 read。
    """
    kbs = list_kbs(workspace_dir, user_id)
    root = kb_root(workspace_dir, user_id)
    base = (kb_dir_display if kb_dir_display is not None else str(root)).rstrip("/")
    if not kbs:
        return _KB_COLD_START.replace("{base}", base).replace("{fmt}", INDEX_LINE_FORMAT)

    parts = ["\n\n## 个人知识库 (user 级,跨 task 共享)\n"]
    parts.append(_KB_CONTRACT.replace("{fmt}", INDEX_LINE_FORMAT))
    for kb in kbs:
        d = root / kb["name"]
        parts.append(f"\n\n### 库「{kb['name']}」(`{base}/{kb['name']}/`)\n")
        entries = _read_index(d)
        if not entries:
            parts.append("(空库,尚无已入库文档)\n")
            continue
        for e in entries:
            kw = f"｜关键词：{e['keywords']}" if e["keywords"] else ""
            parts.append(
                f"- [{e['title']}](`{base}/{kb['name']}/{e['doc']}`)"
                f"｜摘要：{e['summary']}{kw}\n"
            )
    return "".join(parts)
