from __future__ import annotations

from dataclasses import dataclass
import hashlib
import time
from typing import TYPE_CHECKING, Any

import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.core.config import get_settings
from app.models.ai_config import SystemConfig
from app.services.external_errors import ExternalServiceError
from app.services.knowledge_service import KnowledgeScope
from app.services.secret_service import SecretService

if TYPE_CHECKING:
    from app.services.rag_service import RetrievedChunk

# 飞书 API 基地址
FEISHU_OPEN_API = "https://open.feishu.cn"

# tenant_access_token 缓存，按应用凭证隔离
_token_cache: dict[str, dict[str, Any]] = {}


@dataclass(frozen=True)
class FeishuRetrievalConfig:
    search_url: str
    app_id: str
    app_secret: str
    timeout_seconds: int
    retry_count: int


def _get_tenant_token(config: FeishuRetrievalConfig) -> str:
    """获取飞书 tenant_access_token，自动缓存"""
    now = time.time()
    cache_key = _token_cache_key(config.app_id, config.app_secret)
    cached = _token_cache.get(cache_key)

    if cached and cached["token"] and now < cached["expires_at"]:
        return cached["token"]

    url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal"
    payload = {
        "app_id": config.app_id,
        "app_secret": config.app_secret,
    }

    try:
        resp = httpx.post(url, json=payload, timeout=config.timeout_seconds)
        resp.raise_for_status()
        data = resp.json()

        if data.get("code") != 0:
            raise ExternalServiceError(
                f"获取飞书Token失败：{data.get('msg', '未知错误')}",
                provider="feishu",
            )

        token = data["tenant_access_token"]
        expire = data.get("expire", 7200)

        _token_cache[cache_key] = {
            "token": token,
            "expires_at": now + expire - 300,  # 提前5分钟过期
        }

        return token
    except httpx.HTTPError as exc:
        raise ExternalServiceError(f"获取飞书Token网络错误：{exc}", provider="feishu") from exc


def _feishu_get(path: str, params: dict | None = None, config: FeishuRetrievalConfig | None = None) -> dict:
    """封装飞书 GET 请求"""
    if config is None:
        config = _feishu_retrieval_config(None)
    token = _get_tenant_token(config)
    url = f"{FEISHU_OPEN_API}{path}"
    headers = {"Authorization": f"Bearer {token}"}

    try:
        resp = httpx.get(url, headers=headers, params=params, timeout=config.timeout_seconds)
        resp.raise_for_status()
        data = resp.json()
    except httpx.HTTPStatusError as exc:
        raise ExternalServiceError(
            f"飞书HTTP请求失败 [{path}]：{exc.response.status_code} {_response_error_detail(exc.response)}",
            provider="feishu",
        ) from exc
    except httpx.HTTPError as exc:
        raise ExternalServiceError(f"飞书网络请求失败 [{path}]：{exc}", provider="feishu") from exc

    if data.get("code") != 0:
        raise ExternalServiceError(
            f"飞书API调用失败 [{path}]：{data.get('msg', '未知错误')}",
            provider="feishu",
        )
    return data


def _feishu_post(path: str, payload: dict | None = None, config: FeishuRetrievalConfig | None = None) -> dict:
    """封装飞书 POST 请求"""
    if config is None:
        config = _feishu_retrieval_config(None)
    token = _get_tenant_token(config)
    url = f"{FEISHU_OPEN_API}{path}"
    headers = {"Authorization": f"Bearer {token}"}

    try:
        resp = httpx.post(url, headers=headers, json=payload, timeout=config.timeout_seconds)
        resp.raise_for_status()
        data = resp.json()
    except httpx.HTTPStatusError as exc:
        raise ExternalServiceError(
            f"飞书HTTP请求失败 [{path}]：{exc.response.status_code} {_response_error_detail(exc.response)}",
            provider="feishu",
        ) from exc
    except httpx.HTTPError as exc:
        raise ExternalServiceError(f"飞书网络请求失败 [{path}]：{exc}", provider="feishu") from exc

    if data.get("code") != 0:
        raise ExternalServiceError(
            f"飞书API调用失败 [{path}]：{data.get('msg', '未知错误')}",
            provider="feishu",
        )
    return data


def _feishu_download(path: str, config: FeishuRetrievalConfig) -> bytes:
    token = _get_tenant_token(config)
    url = f"{FEISHU_OPEN_API}{path}"
    headers = {"Authorization": f"Bearer {token}"}

    try:
        resp = httpx.get(url, headers=headers, timeout=config.timeout_seconds)
        resp.raise_for_status()
        return resp.content
    except httpx.HTTPStatusError as exc:
        raise ExternalServiceError(
            f"飞书文件下载失败 [{path}]：{exc.response.status_code} {_response_error_detail(exc.response)}",
            provider="feishu",
        ) from exc
    except httpx.HTTPError as exc:
        raise ExternalServiceError(f"飞书文件下载网络错误 [{path}]：{exc}", provider="feishu") from exc


def _response_error_detail(response: httpx.Response) -> str:
    try:
        body = response.json()
    except ValueError:
        return response.text[:200]
    message = body.get("msg") or body.get("message") or body.get("error") or body
    return str(message)[:300]


def _extract_text(elements: list[dict]) -> str:
    """从 elements 列表中提取纯文本"""
    texts = []
    for elem in elements:
        text_run = elem.get("text_run", {})
        content = text_run.get("content", "")
        if content:
            texts.append(content)
    return "".join(texts)


def _parse_docx_content(blocks: list[dict]) -> str:
    """将飞书文档 block 列表解析为纯文本"""
    texts = []
    for block in blocks:
        block_type = block.get("block_type", 0)

        # Page block (block_type=1): 标题可能在 page.elements
        if block_type == 1:
            elements = block.get("page", {}).get("elements", [])
            content = _extract_text(elements)
            if content:
                texts.append(content)
                texts.append("\n")
            # 子内容在 children 里，但子 block 也会遍历到

        # 文本块 (block_type=2 is text paragraph in new API)
        elif block_type == 2:
            elements = block.get("text", {}).get("elements", [])
            content = _extract_text(elements)
            if content:
                texts.append(content)
                texts.append("\n")

        # 标题块 (3=heading1, 4=heading2, 5=heading3, 6=heading4, 7=heading5, 8=heading6)
        elif 3 <= block_type <= 8:
            heading_level = block_type - 2  # 3->h1, 4->h2, etc.
            key = f"heading{heading_level}"
            elements = block.get(key, {}).get("elements", [])
            content = _extract_text(elements)
            if content:
                texts.append(f"{'#' * heading_level} {content}")
                texts.append("\n")

        # 引用块 (15=quote)
        elif block_type == 15:
            elements = block.get("quote", {}).get("elements", [])
            content = _extract_text(elements)
            if content:
                texts.append(f"  > {content}")
                texts.append("\n")

        # 列表块 - bullet (9), ordered (10)
        elif block_type in (9, 10):
            key = "bullet" if block_type == 9 else "ordered"
            elements = block.get(key, {}).get("elements", [])
            content = _extract_text(elements)
            if content:
                prefix = "-" if block_type == 9 else "1."
                texts.append(f"  {prefix} {content}")
                texts.append("\n")

        # 其他 block 类型也尝试提取文本
        else:
            for sub_key in ("text", "quote", "bullet", "ordered"):
                if sub_key in block:
                    elements = block.get(sub_key, {}).get("elements", [])
                    content = _extract_text(elements)
                    if content:
                        texts.append(content)
                        texts.append("\n")
                        break

    return "".join(texts).strip()


def _get_docx_content(doc_token: str, config: FeishuRetrievalConfig) -> str:
    """读取单个飞书云文档内容"""
    path = f"/open-apis/docx/v1/documents/{doc_token}/blocks"
    items: list[dict] = []
    page_token = ""
    while True:
        params: dict[str, Any] = {"page_size": 500}
        if page_token:
            params["page_token"] = page_token
        data = _feishu_get(path, params=params, config=config)
        page = data.get("data", {})
        items.extend(page.get("items", []))
        page_token = str(page.get("page_token") or "")
        if not page.get("has_more") or not page_token:
            break
    return _parse_docx_content(items)


def _get_file_content(file_token: str, config: FeishuRetrievalConfig) -> str:
    """下载普通文件节点内容，主要用于 Markdown/TXT 知识库文件。"""
    content = _feishu_download(f"/open-apis/drive/v1/files/{file_token}/download", config)
    for encoding in ("utf-8-sig", "utf-8", "gb18030"):
        try:
            return content.decode(encoding).strip()
        except UnicodeDecodeError:
            continue
    raise ExternalServiceError("飞书文件不是可解析的文本内容", provider="feishu")


def _get_wiki_node_info(node_token: str, config: FeishuRetrievalConfig) -> dict:
    """获取 Wiki 节点信息，返回 space_id 和 obj_token"""
    data = _feishu_get("/open-apis/wiki/v2/spaces/get_node", params={"token": node_token}, config=config)
    node = data.get("data", {}).get("node", {})
    return {
        "space_id": node.get("space_id", ""),
        "obj_token": node.get("obj_token", ""),  # 文档的实际 token
        "obj_type": node.get("obj_type", ""),      # docx / doc / sheet
        "title": node.get("title", ""),
        "node_token": node.get("node_token", ""),
    }


def _get_wiki_children(
    space_id: str,
    node_token: str,
    config: FeishuRetrievalConfig,
    page_size: int = 50,
) -> list[dict]:
    """获取 Wiki 节点的子节点列表"""
    if not space_id:
        raise ExternalServiceError("飞书知识库缺少 SpaceID，无法读取子节点", provider="feishu")

    all_children = []
    page_token = ""
    while True:
        params: dict[str, Any] = {
            "page_size": page_size,
            "parent_node_token": node_token,
        }
        if page_token:
            params["page_token"] = page_token

        data = _feishu_get(f"/open-apis/wiki/v2/spaces/{space_id}/nodes", params=params, config=config)
        children = data.get("data", {}).get("items", [])
        all_children.extend(children)

        page_token = data.get("data", {}).get("page_token", "")
        if not page_token:
            break

    return all_children


def _get_space_node_tree(space_id: str, config: FeishuRetrievalConfig | None = None) -> list[dict]:
    """获取知识空间下所有根节点"""
    all_nodes = []
    page_token = ""
    while True:
        params: dict[str, Any] = {
            "page_size": 50,
            "space_id": space_id,
        }
        if page_token:
            params["page_token"] = page_token

        data = _feishu_get(f"/open-apis/wiki/v2/spaces/{space_id}/nodes", params=params, config=config)
        items = data.get("data", {}).get("items", [])
        all_nodes.extend(items)

        page_token = data.get("data", {}).get("page_token", "")
        if not page_token:
            break

    return all_nodes


class FeishuKnowledgeService:
    """飞书知识库检索服务：优先搜索接口，失败时飞书直读。"""

    # 文档内容缓存: node_token -> (content, timestamp)
    _content_cache: dict[str, tuple[str, float]] = {}
    _CACHE_TTL = 300  # 缓存 5 分钟

    @classmethod
    def clear_cache(cls) -> int:
        """清空所有飞书内容缓存，返回被清除的条目数"""
        count = len(cls._content_cache)
        cls._content_cache.clear()
        return count

    @classmethod
    def get_node_info(cls, node_token: str, db: Session | None = None) -> dict:
        """获取 Wiki 节点基本信息（space_id、标题、obj_type 等）"""
        config = _feishu_retrieval_config(db)
        if not config.app_id or not config.app_secret:
            raise ExternalServiceError(
                "未配置飞书应用凭证，无法读取飞书知识库。",
                provider="feishu",
            )
        return _get_wiki_node_info(node_token, config)

    @classmethod
    def import_nodes_from_parent(
        cls,
        parent_node_token: str,
        db: Session | None = None,
    ) -> list[dict]:
        """从指定节点递归获取所有子节点，用于批量导入知识库。
        不需要空间级别权限，只需要对 parent_node_token 所在节点有查看权限。
        自动通过 node_info 获取 space_id，调用方无需手动传入。
        """
        config = _feishu_retrieval_config(db)
        if not config.app_id or not config.app_secret:
            raise ExternalServiceError(
                "未配置飞书应用凭证，无法读取飞书知识库。",
                provider="feishu",
            )

        # 先通过 node_token 获取 space_id
        node_info = _get_wiki_node_info(parent_node_token, config)
        space_id = node_info.get("space_id", "")
        if not space_id:
            raise ExternalServiceError(
                f"无法获取节点 {parent_node_token} 所属的 SpaceID",
                provider="feishu",
            )

        all_nodes: list[dict] = []
        visited: set[str] = set()

        def _fetch_children(parent_token: str) -> None:
            try:
                children = _get_wiki_children(space_id, parent_token, config)
            except ExternalServiceError:
                return
            for child in children:
                node_token = child.get("node_token", "")
                if not node_token or node_token in visited:
                    continue
                visited.add(node_token)
                all_nodes.append({
                    "name": child.get("title", node_token),
                    "node_token": node_token,
                    "space_id": space_id,
                    "obj_type": child.get("obj_type", ""),
                    "parent_token": parent_token,
                })
                if child.get("has_child", False):
                    _fetch_children(node_token)

        _fetch_children(parent_node_token)
        return all_nodes

    @classmethod
    def retrieve(
        cls,
        question: str,
        scopes: list[KnowledgeScope],
        db: Session | None = None,
    ) -> list[RetrievedChunk]:
        if not scopes:
            return []

        config = _feishu_retrieval_config(db)
        if config.search_url:
            try:
                return cls._retrieve_from_search(question, scopes, config)
            except ExternalServiceError as exc:
                try:
                    return cls._retrieve_from_feishu(question, scopes, config)
                except ExternalServiceError as fallback_exc:
                    raise ExternalServiceError(
                        f"{exc}；飞书直读也失败：{fallback_exc}",
                        provider="feishu",
                    ) from fallback_exc
        return cls._retrieve_from_feishu(question, scopes, config)

    @classmethod
    def _retrieve_from_search(
        cls,
        question: str,
        scopes: list[KnowledgeScope],
        config: FeishuRetrievalConfig,
    ) -> list[RetrievedChunk]:
        from app.services.rag_service import RetrievedChunk

        payload = _build_search_payload(question, scopes)
        last_error: Exception | None = None
        for _ in range(max(1, config.retry_count + 1)):
            try:
                response = httpx.post(config.search_url, json=payload, timeout=config.timeout_seconds)
                response.raise_for_status()
                body = response.json()
                return _parse_search_chunks(body, scopes, RetrievedChunk)
            except (ValueError, httpx.HTTPError) as exc:
                last_error = exc

        raise ExternalServiceError(f"飞书搜索接口调用失败：{last_error}", provider="feishu-search")

    @classmethod
    def _retrieve_from_feishu(
        cls,
        question: str,
        scopes: list[KnowledgeScope],
        config: FeishuRetrievalConfig,
    ) -> list[RetrievedChunk]:
        """从飞书知识库检索文档内容"""
        from app.services.rag_service import RetrievedChunk

        if not config.app_id or not config.app_secret:
            raise ExternalServiceError(
                "未配置飞书应用凭证，无法读取飞书知识库。请配置 FEISHU_APP_ID/FEISHU_APP_SECRET，"
                "或在系统设置中填写飞书 AppID/AppSecret。",
                provider="feishu",
            )

        # 收集所有文档片段
        all_chunks: list[tuple[KnowledgeScope, str, str, str | None]] = []
        load_errors: list[str] = []

        for scope in scopes:
            try:
                documents = cls._load_documents(scope, question, config)
            except ExternalServiceError as exc:
                load_errors.append(f"{scope.name}：{exc}")
                continue

            for title, content, source_url in documents:
                all_chunks.append((scope, title, content, source_url))

        if not all_chunks:
            if load_errors:
                raise ExternalServiceError(
                    "飞书知识库读取失败：" + "；".join(load_errors[:3]),
                    provider="feishu",
                )
            return []
        if load_errors:
            raise ExternalServiceError(
                "部分飞书知识库读取失败：" + "；".join(load_errors[:3]),
                provider="feishu",
            )

        # 关键词匹配评分（简单但够用的检索方式）
        scored_chunks = cls._score_and_rank(question, all_chunks)

        # 返回 top 5 相关片段
        return [
            RetrievedChunk(
                knowledge_id=scope.id,
                knowledge_name=scope.name,
                title=title,
                content=content,
                source_url=source_url,
            )
            for scope, title, content, source_url in scored_chunks[:5]
        ]

    @classmethod
    def _load_documents(
        cls,
        scope: KnowledgeScope,
        question: str,
        config: FeishuRetrievalConfig,
    ) -> list[tuple[str, str, str | None]]:
        """加载指定知识库 scope 下的文档，按需读取（先按标题过滤，再读内容）"""
        node_id = scope.feishu_node_id
        base_url = f"https://zcn7hk6n047c.feishu.cn"

        if not node_id:
            return []

        # 1. 判断 node 类型：先查 wiki 节点信息
        try:
            node_info = cls._get_node_info_with_cache(node_id, config)
        except ExternalServiceError as node_exc:
            # 可能是普通 docx token，直接尝试读取
            try:
                content = cls._get_document_content_with_cache(node_id, config)
                title = node_id
                return [(title, content, f"{base_url}/docx/{node_id}")]
            except ExternalServiceError as docx_exc:
                raise ExternalServiceError(
                    f"飞书节点读取失败：{node_exc}；按普通 docx token 读取也失败：{docx_exc}",
                    provider="feishu",
                ) from docx_exc

        obj_type = node_info.get("obj_type", "")
        obj_token = node_info.get("obj_token", "")
        title = node_info.get("title", node_id)
        space_id = node_info.get("space_id", scope.feishu_space_id)

        # 如果节点本身就是一个文档，直接读取后做段落筛选
        if obj_type == "docx":
            content = cls._get_document_content_with_cache(obj_token, config)
            source_url = f"{base_url}/wiki/{node_id}"
            filtered = cls._extract_relevant_paragraphs(content, question)
            return [(title, filtered, source_url)]
        if obj_type == "file":
            content = cls._get_file_content_with_cache(obj_token, config)
            source_url = f"{base_url}/wiki/{node_id}"
            filtered = cls._extract_relevant_paragraphs(content, question)
            return [(title, filtered, source_url)]

        # 如果节点是文件夹，获取子节点列表（只读标题，不读内容）
        children = cls._get_wiki_children_with_cache(space_id, node_id, config)

        # 先按标题和问题做关键词匹配，筛选可能相关的文档
        scored_children = cls._score_children_by_title(question, children)

        # 只读取 top 5 最可能相关的文档（避免全量读取）
        MAX_DOCS_PER_SCOPE = 5

        documents = []
        for child, _score in scored_children[:MAX_DOCS_PER_SCOPE]:
            child_node_token = child.get("node_token", "")
            child_obj_type = child.get("obj_type", "")
            child_obj_token = child.get("obj_token", "")
            child_title = child.get("title", "")

            if not child_obj_token:
                continue

            try:
                if child_obj_type == "docx":
                    content = cls._get_document_content_with_cache(child_obj_token, config)
                elif child_obj_type == "file":
                    content = cls._get_file_content_with_cache(child_obj_token, config)
                else:
                    continue
                source_url = f"{base_url}/wiki/{child_node_token}"
                filtered = cls._extract_relevant_paragraphs(content, question)
                documents.append((child_title, filtered, source_url))
            except ExternalServiceError:
                continue

        # 也读取节点本身的文档（如果有）
        if obj_type == "docx" and obj_token:
            try:
                content = cls._get_document_content_with_cache(obj_token, config)
                source_url = f"{base_url}/wiki/{node_id}"
                filtered = cls._extract_relevant_paragraphs(content, question)
                documents.insert(0, (title, filtered, source_url))
            except ExternalServiceError:
                pass

        return documents

    @classmethod
    def _score_children_by_title(cls, question: str, children: list[dict]) -> list[tuple[dict, float]]:
        """按文档标题与问题的相关性打分排序"""
        question_lower = question.lower()
        question_chars = set(question_lower)
        question_bigrams = set(question_lower[i : i + 2] for i in range(len(question_lower) - 1))

        scored = []
        for child in children:
            title = (child.get("title", "") or "").lower()
            if not title:
                continue

            score = 0.0
            # 标题包含问题中的字/词
            for char in question_chars:
                if char in title:
                    score += 2.0
            for bigram in question_bigrams:
                if bigram in title:
                    score += 5.0
            # 完全包含问题关键词
            if question_lower in title:
                score += 10.0

            scored.append((child, score))

        scored.sort(key=lambda x: x[1], reverse=True)
        return scored

    @classmethod
    def _truncate_content(cls, content: str, max_len: int = 5000) -> str:
        """截断文档内容，保留前 max_len 字符，优先保留完整段落"""
        if len(content) <= max_len:
            return content
        # 找最后一个换行符，尽量保留完整段落
        truncated = content[:max_len]
        last_newline = truncated.rfind("\n")
        if last_newline > max_len * 0.8:
            return truncated[:last_newline]
        return truncated

    @classmethod
    def _extract_relevant_paragraphs(cls, content: str, question: str, top_n: int = 10, min_len: int = 15) -> str:
        """把文档拆成段落，只保留和问题最相关的 top_n 段"""
        if not content or not question:
            return content

        # 拆段落：按连续空行拆分，保留标题行
        raw_paragraphs = []
        for block in content.split("\n\n"):
            block = block.strip()
            if not block:
                continue
            # 一个 block 里可能有多行（比如列表），再按行拆
            lines = block.split("\n")
            for line in lines:
                line = line.strip()
                if len(line) >= min_len:
                    raw_paragraphs.append(line)

        if not raw_paragraphs:
            return content

        question_lower = question.lower()
        question_chars = set(question_lower)
        question_bigrams = set(question_lower[i : i + 2] for i in range(len(question_lower) - 1))

        scored = []
        for para in raw_paragraphs:
            para_lower = para.lower()
            score = 0.0

            # 完全包含问题关键词（最高权重）
            if question_lower in para_lower:
                score += 20.0

            # 2-gram 匹配
            for bigram in question_bigrams:
                if bigram in para_lower:
                    score += 3.0

            # 单字匹配
            for char in question_chars:
                if char in para_lower:
                    score += 1.0

            scored.append((para, score))

        # 按分数降序，取 top_n
        scored.sort(key=lambda x: x[1], reverse=True)
        selected = scored[:top_n]

        # 按原文顺序拼接（而不是按分数顺序），这样阅读更自然
        selected_set = set(p[0] for p in selected)
        result_paras = [p for p in raw_paragraphs if p in selected_set]

        return "\n".join(result_paras)

    @classmethod
    def _get_node_info_with_cache(cls, node_token: str, config: FeishuRetrievalConfig) -> dict:
        """带缓存获取节点信息"""
        cache_key = f"{config.app_id}:node_info:{node_token}"
        now = time.time()

        if cache_key in cls._content_cache:
            cached_data, ts = cls._content_cache[cache_key]
            if now - ts < cls._CACHE_TTL:
                return eval(cached_data)  # noqa: S307

        info = _get_wiki_node_info(node_token, config)
        cls._content_cache[cache_key] = (str(info), now)
        return info

    @classmethod
    def _get_document_content_with_cache(cls, doc_token: str, config: FeishuRetrievalConfig) -> str:
        """带缓存读取文档内容"""
        cache_key = f"{config.app_id}:doc:{doc_token}"
        now = time.time()

        if cache_key in cls._content_cache:
            content, ts = cls._content_cache[cache_key]
            if now - ts < cls._CACHE_TTL:
                return content

        content = _get_docx_content(doc_token, config)
        cls._content_cache[cache_key] = (content, now)
        return content

    @classmethod
    def _get_file_content_with_cache(cls, file_token: str, config: FeishuRetrievalConfig) -> str:
        """带缓存读取普通文件内容"""
        cache_key = f"{config.app_id}:file:{file_token}"
        now = time.time()

        if cache_key in cls._content_cache:
            content, ts = cls._content_cache[cache_key]
            if now - ts < cls._CACHE_TTL:
                return content

        content = _get_file_content(file_token, config)
        cls._content_cache[cache_key] = (content, now)
        return content

    @classmethod
    def _get_wiki_children_with_cache(
        cls,
        space_id: str,
        node_token: str,
        config: FeishuRetrievalConfig,
    ) -> list[dict]:
        """带缓存获取子节点"""
        cache_key = f"{config.app_id}:children:{space_id}:{node_token}"
        now = time.time()

        if cache_key in cls._content_cache:
            cached_data, ts = cls._content_cache[cache_key]
            if now - ts < cls._CACHE_TTL:
                return eval(cached_data)  # noqa: S307

        children = _get_wiki_children(space_id, node_token, config)
        cls._content_cache[cache_key] = (str(children), now)
        return children

    @classmethod
    def _score_and_rank(
        cls,
        question: str,
        chunks: list[tuple[KnowledgeScope, str, str, str | None]],
    ) -> list[tuple[KnowledgeScope, str, str, str | None]]:
        """基于关键词匹配对文档片段打分排序"""
        question_lower = question.lower()

        # 提取问题中的关键词（按字/词拆分）
        question_chars = set(question_lower)
        # 2-gram
        question_bigrams = set(question_lower[i : i + 2] for i in range(len(question_lower) - 1))
        # 3-gram
        question_trigrams = set(question_lower[i : i + 3] for i in range(len(question_lower) - 2))

        scored = []
        for scope, title, content, source_url in chunks:
            content_lower = content.lower()
            title_lower = title.lower()

            # 计算匹配分
            score = 0.0

            # 标题匹配（权重高）
            for char in question_chars:
                if char in title_lower:
                    score += 3.0
            for bigram in question_bigrams:
                if bigram in title_lower:
                    score += 5.0
            for trigram in question_trigrams:
                if trigram in title_lower:
                    score += 8.0

            # 内容匹配
            for char in question_chars:
                if char in content_lower:
                    score += 1.0
            for bigram in question_bigrams:
                if bigram in content_lower:
                    score += 2.0
            for trigram in question_trigrams:
                if trigram in content_lower:
                    score += 3.0

            if score > 0:
                scored.append((scope, title, content, source_url, score))

        # 按分数降序排列
        scored.sort(key=lambda x: x[4], reverse=True)

        return [(s, t, c, u) for s, t, c, u, _ in scored]


def _feishu_retrieval_config(db: Session | None) -> FeishuRetrievalConfig:
    settings = get_settings()
    return FeishuRetrievalConfig(
        search_url=_config_text(db, "feishu_search_url", settings.feishu_search_url),
        app_id=_config_text(db, "feishu_app_id", settings.feishu_app_id),
        app_secret=SecretService.decrypt(_config_text(db, "feishu_app_secret", settings.feishu_app_secret)),
        timeout_seconds=_config_int(db, "feishu_timeout_seconds", settings.feishu_timeout_seconds, minimum=1, maximum=120),
        retry_count=_config_int(db, "feishu_retry_count", settings.feishu_retry_count, minimum=0, maximum=10),
    )


def _token_cache_key(app_id: str, app_secret: str) -> str:
    return hashlib.sha256(f"{app_id}:{app_secret}".encode()).hexdigest()


def _config_text(db: Session | None, key: str, default: str) -> str:
    value = _config_value(db, key)
    return value.strip() if value is not None else default


def _config_int(db: Session | None, key: str, default: int, *, minimum: int, maximum: int) -> int:
    value = _config_value(db, key)
    if value is not None:
        try:
            return max(minimum, min(maximum, int(float(value.strip()))))
        except ValueError:
            pass
    return default


def _config_value(db: Session | None, key: str) -> str | None:
    if db is None:
        return None
    config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key))
    if config is None or not config.config_value.strip():
        return None
    return config.config_value


def _build_search_payload(question: str, scopes: list[KnowledgeScope]) -> dict:
    scope_payload = [
        {
            "id": scope.id,
            "name": scope.name,
            "spaceId": scope.feishu_space_id,
            "nodeId": scope.feishu_node_id,
        }
        for scope in scopes
    ]
    return {
        "query": question.strip(),
        "question": question.strip(),
        "limit": 5,
        "knowledgeScopes": scope_payload,
        "scopes": scope_payload,
        "spaceIds": [scope.feishu_space_id for scope in scopes if scope.feishu_space_id],
        "nodeIds": [scope.feishu_node_id for scope in scopes if scope.feishu_node_id],
    }


def _parse_search_chunks(body: Any, scopes: list[KnowledgeScope], chunk_cls: type) -> list:
    if isinstance(body, dict) and body.get("code") not in (None, 0, "0"):
        raise ExternalServiceError(
            f"飞书搜索接口返回错误：{body.get('message') or body.get('msg') or body.get('error') or '未知错误'}",
            provider="feishu-search",
        )

    items = _extract_search_items(body)
    chunks = []
    for item in items:
        if not isinstance(item, dict):
            continue
        scope = _match_search_scope(item, scopes)
        if scope is None:
            continue
        content = _first_text(item, ("content", "text", "snippet", "summary", "answer"))
        if not content:
            continue
        chunks.append(
            chunk_cls(
                knowledge_id=scope.id,
                knowledge_name=scope.name,
                title=_first_text(item, ("title", "name", "documentTitle", "docTitle")) or scope.name,
                content=content,
                source_url=_first_text(item, ("sourceUrl", "source_url", "url", "link")),
            )
        )
        if len(chunks) >= 5:
            break
    return chunks


def _extract_search_items(body: Any) -> list:
    data = body.get("data", body) if isinstance(body, dict) else body
    if isinstance(data, list):
        return data
    if not isinstance(data, dict):
        return []
    for key in ("chunks", "results", "items", "documents", "records"):
        value = data.get(key)
        if isinstance(value, list):
            return value
    return []


def _match_search_scope(item: dict, scopes: list[KnowledgeScope]) -> KnowledgeScope | None:
    knowledge_id = _first_text(item, ("knowledgeId", "knowledge_id", "kbId", "kb_id"))
    if knowledge_id:
        for scope in scopes:
            if str(scope.id) == knowledge_id:
                return scope

    node_id = _first_text(item, ("nodeId", "node_id", "feishuNodeId", "feishu_node_id"))
    if node_id:
        for scope in scopes:
            if scope.feishu_node_id == node_id:
                return scope
        return None

    space_id = _first_text(item, ("spaceId", "space_id", "feishuSpaceId", "feishu_space_id"))
    if space_id:
        for scope in scopes:
            if scope.feishu_space_id == space_id:
                return scope
        return None

    # 搜索适配服务收到的请求已经被限定到 knowledgeScopes；
    # 如果返回结果不带范围字段，只能按 scoped response 处理。
    return scopes[0] if scopes else None


def _first_text(item: dict, keys: tuple[str, ...]) -> str:
    for key in keys:
        value = item.get(key)
        if value is None:
            continue
        text = str(value).strip()
        if text:
            return text
    return ""
