import asyncio
import mimetypes
import os
import re
import secrets
import string
from abc import ABC, abstractmethod
from typing import Any

from yuxi.knowledge.chunking.ragflow_like.presets import ensure_chunk_defaults_in_additional_params
from yuxi.knowledge.schemas import FindOutputSchema, FindWindowSchema, SearchOutputSchema, SearchResultSchema
from yuxi.knowledge.utils import resolve_processing_params, sanitize_processing_params
from yuxi.utils import logger
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, utc_isoformat


class FileStatus:
    UPLOADED = "uploaded"
    PARSING = "parsing"
    PARSED = "parsed"
    ERROR_PARSING = "error_parsing"
    INDEXING = "indexing"
    INDEXED = "indexed"
    ERROR_INDEXING = "error_indexing"


class KnowledgeBaseException(Exception):
    """知识库统一异常基类"""

    pass


class KBNotFoundError(KnowledgeBaseException):
    """知识库不存在错误"""

    pass


class KBOperationError(KnowledgeBaseException):
    """知识库操作错误"""

    pass


class KnowledgeBase(ABC):
    """知识库抽象基类，定义统一接口"""

    kb_type = ""
    name = ""
    description = ""
    requires_embedding_model = True
    supports_documents = True
    apply_chunk_defaults = True

    # 类级别的处理队列，跟踪所有正在处理的文件
    _processing_files = set()
    _processing_lock = None

    def __init__(self, work_dir: str):
        """
        初始化知识库

        Args:
            work_dir: 工作目录
        """
        import threading

        self.work_dir = work_dir
        self.databases_meta: dict[str, dict] = {}
        self.files_meta: dict[str, dict] = {}
        self.benchmarks_meta: dict[str, dict] = {}
        self._metadata_loaded = False  # 标记元数据是否已加载

        # 初始化类级别的锁
        if KnowledgeBase._processing_lock is None:
            KnowledgeBase._processing_lock = threading.Lock()

        os.makedirs(work_dir, exist_ok=True)

        # 注意：不在 __init__ 中加载元数据，由 KnowledgeBaseManager 统一管理加载

    def load_metadata(
        self, global_databases_meta: dict[str, dict], files_meta: dict[str, dict], benchmarks_meta: dict[str, dict]
    ):
        """由 KnowledgeBaseManager 调用，同步加载元数据"""
        # 过滤出当前 kb_type 的知识库
        self.databases_meta = {}
        for kb_id, meta in global_databases_meta.items():
            if meta.get("kb_type") == self.kb_type:
                normalized_additional_params = self.normalize_additional_params(meta.get("additional_params"))
                self.databases_meta[kb_id] = {
                    "name": meta.get("name"),
                    "description": meta.get("description"),
                    "kb_type": meta.get("kb_type"),
                    "embedding_model_spec": meta.get("embedding_model_spec"),
                    "llm_model_spec": meta.get("llm_model_spec"),
                    "query_params": meta.get("query_params"),
                    "metadata": normalized_additional_params,
                    "created_at": meta.get("created_at"),
                }

        # 过滤文件
        self.files_meta = {}
        for file_id, meta in files_meta.items():
            if meta.get("kb_id") in self.databases_meta:
                kb_id = meta.get("kb_id")
                kb_additional_params = self.databases_meta.get(kb_id, {}).get("metadata") or {}
                normalized_meta = dict(meta)
                normalized_meta["processing_params"] = resolve_processing_params(
                    kb_additional_params=kb_additional_params,
                    file_processing_params=meta.get("processing_params"),
                )
                self.files_meta[file_id] = normalized_meta

        # 过滤评估基准
        self.benchmarks_meta = {}
        for kb_id, benchmarks in benchmarks_meta.items():
            if kb_id in self.databases_meta:
                self.benchmarks_meta[kb_id] = benchmarks

        self._normalize_metadata_state()
        self._metadata_loaded = True
        logger.info(f"{self.kb_type}: 加载了 {len(self.databases_meta)} 个数据库的元数据")

    def _ensure_metadata_loaded(self):
        """确保元数据已加载（延迟加载）"""
        if not self._metadata_loaded:
            logger.warning(f"{self.kb_type}: 元数据尚未加载，请确保 KnowledgeBaseManager 已调用 load_metadata()")

    @staticmethod
    def _normalize_timestamp(value: Any) -> str | None:
        """Convert persisted timestamps to a normalized UTC ISO string."""
        try:
            dt_value = coerce_any_to_utc_datetime(value)
        except (TypeError, ValueError) as exc:  # noqa: BLE001
            logger.warning(f"Invalid timestamp encountered: {value!r} ({exc})")
            return None

        if not dt_value:
            return None
        return utc_isoformat(dt_value)

    def _normalize_metadata_state(self) -> None:
        """Ensure in-memory metadata uses normalized timestamp formats."""
        for meta in self.databases_meta.values():
            if "created_at" in meta:
                normalized = self._normalize_timestamp(meta.get("created_at"))
                if normalized:
                    meta["created_at"] = normalized

        for file_info in self.files_meta.values():
            if "created_at" in file_info:
                normalized = self._normalize_timestamp(file_info.get("created_at"))
                if normalized:
                    file_info["created_at"] = normalized

        for db_benchmarks in self.benchmarks_meta.values():
            for b in db_benchmarks.values():
                if "created_at" in b:
                    normalized = self._normalize_timestamp(b.get("created_at"))
                    if normalized:
                        b["created_at"] = normalized
                if "updated_at" in b:
                    normalized = self._normalize_timestamp(b.get("updated_at"))
                    if normalized:
                        b["updated_at"] = normalized

    @classmethod
    def get_create_params_config(cls) -> dict[str, Any]:
        """获取创建知识库时的类型特定参数配置。"""
        return {"options": []}

    @classmethod
    def validate_additional_params(cls, additional_params: dict | None) -> dict:
        """校验并规范化类型特定配置。"""
        return dict(additional_params or {})

    @classmethod
    def normalize_additional_params(cls, additional_params: dict | None) -> dict:
        """规范化 additional_params，仅文档型知识库补充分块默认值。"""
        params = cls.validate_additional_params(additional_params)
        if cls.apply_chunk_defaults:
            return ensure_chunk_defaults_in_additional_params(params)
        return params

    @abstractmethod
    async def _create_kb_instance(self, kb_id: str, config: dict) -> Any:
        """
        创建底层知识库实例

        Args:
            kb_id: 数据库ID
            config: 配置信息

        Returns:
            底层知识库实例
        """
        pass

    @abstractmethod
    async def _initialize_kb_instance(self, instance: Any) -> None:
        """
        初始化底层知识库实例

        Args:
            instance: 底层知识库实例
        """
        pass

    async def add_file_record(
        self, kb_id: str, item: str, params: dict | None = None, operator_id: str | None = None
    ) -> dict:
        """
        Add a file record to metadata (Status: UPLOADED)

        Args:
            kb_id: Database ID
            item: File path or URL
            params: Parameters
            operator_id: Operator ID who created the file

        Returns:
            File metadata record
        """
        from yuxi.knowledge.utils.kb_utils import prepare_item_metadata

        params = params or {}
        content_type = params.get("content_type", "file")

        # Prepare metadata
        metadata = await prepare_item_metadata(item, content_type, kb_id, params=params)
        file_id = metadata["file_id"]
        kb_additional_params = self.databases_meta.get(kb_id, {}).get("metadata") or {}
        metadata["processing_params"] = resolve_processing_params(
            kb_additional_params=kb_additional_params,
            file_processing_params=metadata.get("processing_params"),
        )

        # Fallback: fetch file size from MinIO if not provided
        if metadata.get("size") is None and content_type == "file":
            try:
                from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
                from yuxi.storage.minio import get_minio_client

                file_path = metadata.get("path") or item
                if is_minio_url(file_path):
                    bucket_name, obj_name = parse_minio_url(file_path)
                    minio_client = get_minio_client()
                    file_size = await minio_client.astat_file(bucket_name, obj_name)
                    if file_size is not None:
                        metadata["size"] = file_size
            except Exception as exc:
                logger.warning(f"Failed to stat file size from MinIO for {item}: {exc}")

        # Initial status
        metadata["status"] = FileStatus.UPLOADED
        metadata["created_at"] = utc_isoformat()
        if operator_id:
            metadata["created_by"] = operator_id

        # Save to metadata
        self.files_meta[file_id] = metadata
        await self._persist_file(file_id)

        return metadata

    async def parse_file(self, kb_id: str, file_id: str, operator_id: str | None = None) -> dict:
        """
        Parse file to Markdown and save to MinIO (Status: PARSING -> PARSED/ERROR_PARSING)

        Args:
            kb_id: Database ID
            file_id: File ID
            operator_id: ID of the user performing the operation

        Returns:
            Updated file metadata
        """
        if file_id not in self.files_meta:
            raise ValueError(f"File {file_id} not found")

        file_meta = self.files_meta[file_id]
        current_status = file_meta.get("status")

        # Validate current status - only allow parsing from these states
        allowed_statuses = {
            FileStatus.UPLOADED,
            FileStatus.ERROR_PARSING,
            "failed",  # Legacy status
        }

        if current_status not in allowed_statuses:
            raise ValueError(
                f"Cannot parse file with status '{current_status}'. "
                f"File must be in one of these states: {', '.join(allowed_statuses)}"
            )

        file_path = file_meta.get("path")
        if not file_path:
            raise ValueError(f"File {file_id} has no valid path in metadata")

        # Clear previous error if any
        if "error" in file_meta:
            self.files_meta[file_id].pop("error", None)

        # Update status to PARSING and add to processing queue
        self.files_meta[file_id]["status"] = FileStatus.PARSING
        self.files_meta[file_id]["updated_at"] = utc_isoformat()
        if operator_id:
            self.files_meta[file_id]["updated_by"] = operator_id
        await self._persist_file(file_id)

        # Add to processing queue
        self._add_to_processing_queue(file_id)

        try:
            from yuxi.knowledge.parser.unified import Parser

            # Prepare params
            params = file_meta.get("processing_params", {}) or {}
            params["image_bucket"] = "public"
            params["image_prefix"] = f"{kb_id}/kb-images"

            markdown_content = await Parser.aparse(
                source=file_path,
                params=params,
            )

            # Save Markdown to MinIO
            markdown_file_path = await self._save_markdown_to_minio(kb_id, file_id, markdown_content)

            # Update metadata
            self.files_meta[file_id]["status"] = FileStatus.PARSED
            self.files_meta[file_id]["markdown_file"] = markdown_file_path
            self.files_meta[file_id]["updated_at"] = utc_isoformat()
            if operator_id:
                self.files_meta[file_id]["updated_by"] = operator_id
            await self._persist_file(file_id)

            return self.files_meta[file_id]

        except Exception as e:
            error_msg = str(e)
            logger.error(f"Failed to parse file {file_id}: {error_msg}")

            self.files_meta[file_id]["status"] = FileStatus.ERROR_PARSING
            self.files_meta[file_id]["error"] = error_msg
            self.files_meta[file_id]["updated_at"] = utc_isoformat()
            if operator_id:
                self.files_meta[file_id]["updated_by"] = operator_id
            await self._persist_file(file_id)

            raise

        finally:
            # Remove from processing queue
            self._remove_from_processing_queue(file_id)

    async def update_file_params(self, kb_id: str, file_id: str, params: dict, operator_id: str | None = None) -> None:
        """Update file processing params"""
        if file_id not in self.files_meta:
            raise ValueError(f"File {file_id} not found")

        # Skip if no params to update
        if not params:
            return

        current_params = self.files_meta[file_id].get("processing_params", {}) or {}
        kb_additional_params = self.databases_meta.get(kb_id, {}).get("metadata") or {}

        logger.debug(f"[update_file_params] file_id={file_id}, current_params={current_params}, new_params={params}")

        current_params = resolve_processing_params(
            kb_additional_params=kb_additional_params,
            file_processing_params=current_params,
            request_params=params,
        )

        self.files_meta[file_id]["processing_params"] = current_params
        self.files_meta[file_id]["updated_at"] = utc_isoformat()
        if operator_id:
            self.files_meta[file_id]["updated_by"] = operator_id

        logger.debug(f"[update_file_params] file_id={file_id}, updated_params={current_params}")

        await self._persist_file(file_id)

    async def _mark_file_unparsed(self, file_id: str, operator_id: str | None = None) -> None:
        if file_id not in self.files_meta:
            return

        self.files_meta[file_id]["status"] = FileStatus.UPLOADED
        self.files_meta[file_id].pop("markdown_file", None)
        self.files_meta[file_id].pop("error", None)
        self.files_meta[file_id]["updated_at"] = utc_isoformat()
        if operator_id:
            self.files_meta[file_id]["updated_by"] = operator_id
        await self._persist_file(file_id)

    async def _save_markdown_to_minio(self, kb_id: str, file_id: str, content: str) -> str:
        """Save markdown content to MinIO and return HTTP URL"""
        from yuxi.storage.minio import get_minio_client

        minio_client = get_minio_client()
        bucket_name = minio_client.KB_BUCKETS["parsed"]
        await asyncio.to_thread(minio_client.ensure_bucket_exists, bucket_name)

        object_name = f"{kb_id}/parsed/{file_id}.md"
        data = content.encode("utf-8")

        # Return standard HTTP URL from UploadResult
        upload_result = await minio_client.aupload_file(
            bucket_name=bucket_name,
            object_name=object_name,
            data=data,
        )

        return upload_result.url

    async def _read_minio_bytes(self, file_path: str) -> bytes:
        from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
        from yuxi.storage.minio import get_minio_client

        if not file_path or not is_minio_url(file_path):
            raise ValueError(f"Invalid MinIO path format: {file_path}")

        bucket_name, object_name = parse_minio_url(file_path)
        minio_client = get_minio_client()
        return await minio_client.adownload_file(bucket_name, object_name)

    async def _read_markdown_from_minio(self, file_path: str) -> str:
        """Read markdown content from MinIO"""
        content_bytes = await self._read_minio_bytes(file_path)
        return content_bytes.decode("utf-8")

    def _get_file_meta(self, kb_id: str, file_id: str) -> dict:
        file_meta = self.files_meta.get(file_id)
        if not file_meta or file_meta.get("kb_id") != kb_id:
            raise ValueError(f"File {file_id} not found")
        return file_meta

    @staticmethod
    def _original_file_path(file_meta: dict) -> str | None:
        return file_meta.get("minio_url") or file_meta.get("path")

    def _knowledge_preview_variants(self, file_meta: dict) -> list[dict]:
        variants = []
        original_path = self._original_file_path(file_meta)
        if original_path:
            variants.append({"key": "original", "label": "Source", "supported": True})
        if file_meta.get("markdown_file"):
            variants.append({"key": "parsed", "label": "MD", "supported": True})
        return variants

    def _knowledge_file_entry(self, kb_id: str, file_id: str, file_meta: dict) -> dict:
        is_dir = bool(file_meta.get("is_folder"))
        variants = [] if is_dir else self._knowledge_preview_variants(file_meta)
        preview_modes = [item["key"] for item in variants]
        default_preview_mode = None
        if "parsed" in preview_modes:
            default_preview_mode = "parsed"
        elif preview_modes:
            default_preview_mode = preview_modes[0]
        path = f"/{file_id}"
        if is_dir:
            path = f"{path}/"
        return {
            "source": "knowledge",
            "kb_id": kb_id,
            "file_id": file_id,
            "parent_id": file_meta.get("parent_id"),
            "path": path,
            "virtual_path": f"/knowledge/{kb_id}/{file_id}",
            "name": file_meta.get("filename") or file_meta.get("original_filename") or file_id,
            "is_dir": is_dir,
            "size": 0 if is_dir else file_meta.get("size") or 0,
            "modified_at": file_meta.get("updated_at") or file_meta.get("created_at") or "",
            "readonly": True,
            "status": file_meta.get("status", "done"),
            "preview_modes": preview_modes,
            "default_preview_mode": default_preview_mode,
        }

    def _sort_file_entries(self, entries: list[dict]) -> list[dict]:
        return sorted(
            entries,
            key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower()),
        )

    def _list_knowledge_children(
        self,
        kb_id: str,
        parent_id: str | None,
        *,
        recursive: bool,
        files_only: bool,
    ) -> list[dict]:
        children = [
            (file_id, meta)
            for file_id, meta in self.files_meta.items()
            if meta.get("kb_id") == kb_id and meta.get("parent_id") == parent_id
        ]
        entries = []
        for file_id, meta in children:
            if not files_only or not meta.get("is_folder"):
                entries.append(self._knowledge_file_entry(kb_id, file_id, meta))
            if recursive and meta.get("is_folder"):
                entries.extend(
                    self._list_knowledge_children(
                        kb_id,
                        file_id,
                        recursive=True,
                        files_only=files_only,
                    )
                )
        return self._sort_file_entries(entries)

    async def list_file_tree(
        self,
        kb_id: str,
        parent_id: str | None = None,
        recursive: bool = False,
        files_only: bool = False,
    ) -> dict:
        if kb_id not in self.databases_meta:
            raise ValueError(f"Database {kb_id} not found")
        if parent_id:
            parent_meta = self._get_file_meta(kb_id, parent_id)
            if not parent_meta.get("is_folder"):
                raise ValueError("Parent is not a folder")
        return {
            "entries": self._list_knowledge_children(
                kb_id,
                parent_id,
                recursive=recursive,
                files_only=files_only,
            ),
            "readonly": True,
        }

    async def read_file_preview(self, kb_id: str, file_id: str, variant: str = "parsed") -> dict:
        from yuxi.services.file_preview import detect_preview_type

        file_meta = self._get_file_meta(kb_id, file_id)
        if file_meta.get("is_folder"):
            raise ValueError("Cannot preview a folder")

        variants = self._knowledge_preview_variants(file_meta)
        variant_keys = {item["key"] for item in variants}
        if variant not in {"original", "parsed"}:
            raise ValueError("Unsupported preview variant")

        filename = file_meta.get("filename") or file_meta.get("original_filename") or file_id
        response = {
            "source": "knowledge",
            "kb_id": kb_id,
            "file_id": file_id,
            "filename": filename,
            "variant": variant,
            "readonly": True,
            "available_variants": variants,
        }

        if variant == "parsed":
            markdown_file = file_meta.get("markdown_file")
            if not markdown_file or "parsed" not in variant_keys:
                return {
                    **response,
                    "content": None,
                    "preview_type": "unsupported",
                    "supported": False,
                    "message": "文件尚未生成解析结果",
                }
            content = await self._read_markdown_from_minio(markdown_file)
            return {
                **response,
                "content": content,
                "preview_type": "markdown",
                "supported": True,
                "message": None,
            }

        original_path = self._original_file_path(file_meta)
        if not original_path or "original" not in variant_keys:
            return {
                **response,
                "content": None,
                "preview_type": "unsupported",
                "supported": False,
                "message": "文件没有可预览的原始内容",
            }

        preview_type, supported, message = detect_preview_type(filename, b"")
        if preview_type in {"image", "pdf"}:
            return {
                **response,
                "content": None,
                "preview_type": preview_type,
                "supported": supported,
                "message": message,
            }

        raw_content = await self._read_minio_bytes(original_path)
        preview_type, supported, message = detect_preview_type(filename, raw_content)
        if preview_type in {"image", "pdf"} or not supported:
            return {
                **response,
                "content": None,
                "preview_type": preview_type,
                "supported": supported,
                "message": message,
            }
        try:
            content = raw_content.decode("utf-8")
        except UnicodeDecodeError:
            return {
                **response,
                "content": None,
                "preview_type": "unsupported",
                "supported": False,
                "message": "当前文件不是 UTF-8 文本，暂不支持预览",
            }
        return {
            **response,
            "content": content,
            "preview_type": preview_type,
            "supported": True,
            "message": message,
        }

    async def get_file_download(self, kb_id: str, file_id: str, variant: str = "original") -> dict:
        file_meta = self._get_file_meta(kb_id, file_id)
        if file_meta.get("is_folder"):
            raise ValueError("Cannot download a folder")
        if variant not in {"original", "parsed"}:
            raise ValueError("Unsupported download variant")

        filename = file_meta.get("filename") or file_meta.get("original_filename") or file_id
        if variant == "parsed":
            markdown_file = file_meta.get("markdown_file")
            if not markdown_file:
                raise ValueError("文件尚未生成解析结果")
            return {
                "filename": f"{filename}.parsed.md",
                "content": await self._read_minio_bytes(markdown_file),
                "media_type": "text/markdown; charset=utf-8",
            }

        original_path = self._original_file_path(file_meta)
        if not original_path:
            raise ValueError("文件没有可下载的原始内容")
        media_type = file_meta.get("content_type") or mimetypes.guess_type(filename)[0] or "application/octet-stream"
        return {
            "filename": filename,
            "content": await self._read_minio_bytes(original_path),
            "media_type": media_type,
        }

    def _build_open_file_window(self, content: str, *, offset: int = 0, limit: int = 800) -> dict[str, Any]:
        lines = content.splitlines()
        total_lines = len(lines)
        start = min(max(int(offset), 0), total_lines)
        window_size = min(max(int(limit), 1), 2000)
        selected = lines[start : start + window_size]
        end = start + len(selected)

        return {
            "start_line": start + 1 if selected else 0,
            "end_line": end,
            "total_lines": total_lines,
            "offset": start,
            "window_size": window_size,
            "has_more_before": start > 0,
            "has_more_after": end < total_lines,
            "next_offset": end if end < total_lines else None,
            "content": "\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected)),
        }

    @staticmethod
    def build_search_output(kb_id: str, retrieval_results: Any) -> dict[str, Any] | Any:
        if not isinstance(retrieval_results, list):
            return retrieval_results

        results = []
        for index, chunk in enumerate(retrieval_results):
            if not isinstance(chunk, dict):
                continue

            metadata = chunk.get("metadata") if isinstance(chunk.get("metadata"), dict) else {}
            metadata = {
                key: value
                for key, value in metadata.items()
                if key not in {"filepath", "parsed_path", "path", "markdown_file"}
            }
            file_id = metadata.get("file_id") or chunk.get("file_id") or chunk.get("full_doc_id") or ""
            chunk_id = metadata.get("chunk_id") or chunk.get("chunk_id") or chunk.get("id")
            chunk_index = metadata.get("chunk_index")
            if chunk_index is None:
                chunk_index = chunk.get("chunk_index")
            if chunk_index is not None:
                metadata.setdefault("chunk_index", chunk_index)
            if chunk.get("score") is not None:
                metadata.setdefault("score", chunk.get("score"))
            if chunk.get("distance") is not None:
                metadata.setdefault("distance", chunk.get("distance"))

            results.append(
                SearchResultSchema(
                    id=str(chunk_id or f"{file_id}:{index + 1}"),
                    kb_id=str(kb_id),
                    file_id=str(file_id or ""),
                    content=str(chunk.get("content") or ""),
                    metadata=metadata,
                )
            )

        return SearchOutputSchema(kb_id=str(kb_id), results=results).model_dump()

    @staticmethod
    def _build_find_file_windows(
        content: str,
        *,
        patterns: list[str],
        use_regex: bool = False,
        case_sensitive: bool = False,
        max_windows: int = 5,
        window_size: int = 80,
    ) -> dict[str, Any]:
        patterns = [pattern for pattern in patterns if pattern]
        if not patterns:
            raise ValueError("请提供至少一个 pattern")

        lines = content.splitlines()
        flags = 0 if case_sensitive else re.IGNORECASE
        if use_regex:
            matchers = [re.compile(pattern, flags) for pattern in patterns]

            def line_matches(line: str) -> bool:
                return any(matcher.search(line) for matcher in matchers)

        else:
            normalized_patterns = patterns if case_sensitive else [pattern.lower() for pattern in patterns]

            def line_matches(line: str) -> bool:
                haystack = line if case_sensitive else line.lower()
                return any(pattern in haystack for pattern in normalized_patterns)

        matched_indexes = [index for index, line in enumerate(lines) if line_matches(line)]
        windows: list[FindWindowSchema] = []
        covered_until = -1
        normalized_window_size = min(max(int(window_size), 1), 200)
        half_window = normalized_window_size // 2

        for matched_index in matched_indexes:
            if matched_index < covered_until:
                continue
            start = max(matched_index - half_window, 0)
            end = min(start + normalized_window_size, len(lines))
            start = max(end - normalized_window_size, 0)
            matched_lines = [index + 1 for index in matched_indexes if start <= index < end]
            selected = lines[start:end]
            windows.append(
                FindWindowSchema(
                    start_line=start + 1 if selected else 0,
                    end_line=end,
                    matched_lines=matched_lines,
                    content="\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected)),
                )
            )
            covered_until = end
            if len(windows) >= max_windows:
                break

        return FindOutputSchema(
            kb_id="",
            file_id="",
            semantic=False,
            match_mode="regex" if use_regex else "keyword",
            total_matches=len(matched_indexes),
            windows=windows,
        ).model_dump(exclude={"kb_id", "file_id"})

    async def open_file_content(self, kb_id: str, file_id: str, offset: int = 0, limit: int = 800) -> dict:
        """按行窗口打开文件解析后的 Markdown 内容"""
        file_meta = self.files_meta.get(file_id)
        if file_meta is None:
            raise Exception(f"文件不存在: {file_id}")
        if file_meta.get("kb_id") != kb_id:
            raise Exception(f"文件 {file_id} 不属于知识库 {kb_id}")
        if file_meta.get("is_folder"):
            raise Exception(f"文件 {file_id} 是文件夹")

        markdown_file = file_meta.get("markdown_file")
        if not markdown_file:
            raise Exception(f"文件 {file_id} 没有解析后的 Markdown 内容")

        content = await self._read_markdown_from_minio(markdown_file)
        return self._build_open_file_window(content, offset=offset, limit=limit)

    async def find_file_content(
        self,
        kb_id: str,
        file_id: str,
        patterns: list[str],
        *,
        use_regex: bool = False,
        case_sensitive: bool = False,
        max_windows: int = 5,
        window_size: int = 80,
    ) -> dict:
        file_meta = self.files_meta.get(file_id)
        if file_meta is None:
            raise Exception(f"文件不存在: {file_id}")
        if file_meta.get("kb_id") != kb_id:
            raise Exception(f"文件 {file_id} 不属于知识库 {kb_id}")
        if file_meta.get("is_folder"):
            raise Exception(f"文件 {file_id} 是文件夹")

        markdown_file = file_meta.get("markdown_file")
        if not markdown_file:
            raise Exception(f"文件 {file_id} 没有解析后的 Markdown 内容")

        content = await self._read_markdown_from_minio(markdown_file)
        return self._build_find_file_windows(
            content,
            patterns=patterns,
            use_regex=use_regex,
            case_sensitive=case_sensitive,
            max_windows=max_windows,
            window_size=window_size,
        )

    @abstractmethod
    async def index_file(self, kb_id: str, file_id: str, operator_id: str | None = None) -> dict:
        """
        Index parsed file (Status: INDEXING -> INDEXED/ERROR_INDEXING)

        Args:
            kb_id: Database ID
            file_id: File ID
            operator_id: ID of the user performing the operation

        Returns:
            Updated file metadata
        """
        pass

    async def create_database(
        self,
        database_name: str,
        description: str,
        embedding_model_spec: str | None = None,
        llm_model_spec: str | None = None,
        **kwargs,
    ) -> dict:
        """
        创建数据库

        Args:
            database_name: 数据库名称
            description: 数据库描述
            embedding_model_spec: 嵌入模型 spec
            llm_model_spec: LLM 模型 spec
            **kwargs: 其他配置参数

        Returns:
            数据库信息字典
        """
        kwargs = self.normalize_additional_params(kwargs)

        alphabet = string.ascii_lowercase + string.digits
        while True:
            kb_id = "kb_" + "".join(secrets.choice(alphabet) for _ in range(10))
            if kb_id not in self.databases_meta:
                break

        self.databases_meta[kb_id] = {
            "name": database_name,
            "description": description,
            "kb_type": self.kb_type,
            "embedding_model_spec": embedding_model_spec,
            "llm_model_spec": llm_model_spec,
            "metadata": kwargs,
            "created_at": utc_isoformat(),
            "query_params": self._get_default_query_params(kb_id),
        }
        await self._persist_kb(kb_id)

        # 创建工作目录
        working_dir = os.path.join(self.work_dir, kb_id)
        os.makedirs(working_dir, exist_ok=True)

        # 返回数据库信息
        db_dict = self.databases_meta[kb_id].copy()
        db_dict["kb_id"] = kb_id
        db_dict["files"] = {}

        return db_dict

    async def delete_database(self, kb_id: str) -> dict:
        """
        删除数据库

        Args:
            kb_id: 数据库ID

        Returns:
            操作结果
        """
        if kb_id in self.databases_meta:
            from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
            from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
            from yuxi.storage.minio import get_minio_client

            minio_client = get_minio_client()

            # 1. 删除文件元数据中记录的 MinIO 文件
            files_to_delete = [fid for fid, finfo in self.files_meta.items() if finfo.get("kb_id") == kb_id]
            for file_id in files_to_delete:
                file_path = self.files_meta[file_id].get("path")
                if file_path and is_minio_url(file_path):
                    try:
                        bucket_name, object_name = parse_minio_url(file_path)
                        await minio_client.adelete_file(bucket_name, object_name)
                    except Exception as e:
                        logger.warning(f"Failed to delete MinIO file {file_path}: {e}")

                # 删除解析后的 markdown 文件
                parsed_object = f"{kb_id}/parsed/{file_id}.md"
                await minio_client.adelete_file(minio_client.KB_BUCKETS["parsed"], parsed_object)

                del self.files_meta[file_id]

            # 2. 并行删除所有知识库 bucket 中该 kb_id 下的文件
            prefix = f"{kb_id}/"
            cleanup_buckets = {
                minio_client.KB_BUCKETS["parsed"],
                minio_client.KB_BUCKETS["documents"],
                minio_client.KB_BUCKETS["images"],
            }
            cleanup_tasks = [
                minio_client.adelete_objects_by_prefix(bucket_name, prefix) for bucket_name in cleanup_buckets
            ]
            await asyncio.gather(*cleanup_tasks)

            # 3. 删除数据库记录
            del self.databases_meta[kb_id]
            kb_repo = KnowledgeBaseRepository()
            await kb_repo.delete(kb_id)
            await self._save_metadata()

        # 删除工作目录
        working_dir = os.path.join(self.work_dir, kb_id)
        if os.path.exists(working_dir):
            import shutil

            try:
                shutil.rmtree(working_dir)
            except Exception as e:
                logger.error(f"Error deleting working directory {working_dir}: {e}")

        return {"message": "删除成功"}

    async def create_folder(self, kb_id: str, folder_name: str, parent_id: str | None = None) -> dict:
        """Create a folder in the database."""
        import uuid

        folder_id = f"folder-{uuid.uuid4()}"

        self.files_meta[folder_id] = {
            "file_id": folder_id,
            "filename": folder_name,
            "is_folder": True,
            "parent_id": parent_id,
            "kb_id": kb_id,
            "created_at": utc_isoformat(),
            "status": "done",
            "path": folder_name,
            "file_type": "folder",
        }
        await self._persist_file(folder_id)
        return self.files_meta[folder_id]

    @abstractmethod
    async def update_content(self, kb_id: str, file_ids: list[str], params: dict | None = None) -> list[dict]:
        """
        更新内容 - 根据file_ids重新解析文件并更新向量库

        Args:
            kb_id: 数据库ID
            file_ids: 文件ID列表
            params: 处理参数

        Returns:
            更新结果列表
        """
        pass

    @abstractmethod
    async def aquery(self, query_text: str, kb_id: str, **kwargs) -> list[dict]:
        """
        异步查询知识库

        Args:
            query_text: 查询文本
            kb_id: 数据库ID
            **kwargs: 查询参数

        Returns:
            一个包含字典的列表，每个字典代表一个检索到的文档块。
        """
        pass

    @abstractmethod
    def get_query_params_config(self, kb_id: str, **kwargs) -> dict:
        """
        获取知识库类型的查询参数配置

        Args:
            kb_id: 数据库ID
            **kwargs: 额外参数

        Returns:
            dict: {
                "type": "kb_type",
                "options": [
                    {
                        "key": "param_name",
                        "label": "参数名称",
                        "type": "select|number|boolean",
                        "default": default_value,
                        "options": [...],  # 对于 select 类型
                        "description": "参数描述",
                        "min": 1,  # 对于 number 类型
                        "max": 100,
                        "step": 0.1
                    },
                    ...
                ]
            }
        """
        pass

    async def export_data(self, kb_id: str, format: str = "zip", **kwargs) -> str:
        pass

    def _get_query_params(self, kb_id: str) -> dict:
        """从实例元数据中加载查询参数"""
        if kb_id in self.databases_meta:
            query_params_meta = self.databases_meta[kb_id].get("query_params") or {}
            return query_params_meta.get("options", {})
        return {}

    def _get_default_query_params(self, kb_id: str) -> dict[str, Any]:
        """从 get_query_params_config 中提取所有参数的默认值，返回 {"options": {...}}"""
        config = self.get_query_params_config(kb_id)
        defaults = {}
        for opt in config.get("options", []):
            if "default" in opt:
                defaults[opt["key"]] = opt["default"]
        return {"options": defaults}

    def get_database_info(self, kb_id: str, include_files: bool = True) -> dict | None:
        """
        获取数据库详细信息

        Args:
            kb_id: 数据库ID
            include_files: 是否包含文件信息，默认为True

        Returns:
            数据库信息或None
        """
        if kb_id not in self.databases_meta:
            return None

        meta = self.databases_meta[kb_id].copy()
        meta["kb_id"] = kb_id

        # 检查并修复异常的processing状态
        self._check_and_fix_processing_status(kb_id)

        # 统计文件数量（始终计算，即使不加载文件详情）
        db_file_count = sum(1 for file_info in self.files_meta.values() if file_info.get("kb_id") == kb_id)
        meta["row_count"] = db_file_count

        # 仅在需要时加载文件详情
        if include_files:
            db_files = {}
            for file_id, file_info in self.files_meta.items():
                if file_info.get("kb_id") == kb_id:
                    created_at = self._normalize_timestamp(file_info.get("created_at"))
                    db_files[file_id] = {
                        "file_id": file_id,
                        "filename": file_info.get("filename", ""),
                        "path": file_info.get("path", ""),
                        "markdown_file": file_info.get("markdown_file", ""),
                        "type": file_info.get("file_type", ""),
                        "status": file_info.get("status", "done"),
                        "created_at": created_at,
                        "is_folder": file_info.get("is_folder", False),
                        "parent_id": file_info.get("parent_id", None),
                    }

            # 按创建时间倒序排序文件列表
            sorted_files = dict(
                sorted(
                    db_files.items(),
                    key=lambda item: item[1].get("created_at") or "",
                    reverse=True,
                )
            )

            meta["files"] = sorted_files

        meta["status"] = "已连接"
        return meta

    def get_databases(self, include_files: bool = False) -> dict:
        """
        获取所有数据库信息

        Args:
            include_files: 是否包含文件信息，默认False以减少响应大小

        Returns:
            数据库列表
        """
        # 确保元数据已加载（延迟加载机制）
        self._ensure_metadata_loaded()

        databases = []
        for kb_id, meta in self.databases_meta.items():
            # 检查并修复异常的processing状态
            self._check_and_fix_processing_status(kb_id)

            db_dict = meta.copy()
            db_dict["kb_id"] = kb_id

            # 统计文件数量（始终计算，即使不加载文件详情）
            db_file_count = sum(1 for file_info in self.files_meta.values() if file_info.get("kb_id") == kb_id)
            db_dict["row_count"] = db_file_count

            # 仅在需要时加载文件详情
            if include_files:
                db_files = {}
                for file_id, file_info in self.files_meta.items():
                    if file_info.get("kb_id") == kb_id:
                        created_at = self._normalize_timestamp(file_info.get("created_at"))
                        db_files[file_id] = {
                            "file_id": file_id,
                            "filename": file_info.get("filename", ""),
                            "path": file_info.get("path", ""),
                            "markdown_file": file_info.get("markdown_file", ""),
                            "type": file_info.get("file_type", ""),
                            "status": file_info.get("status", "done"),
                            "created_at": created_at,
                            "is_folder": file_info.get("is_folder", False),
                            "parent_id": file_info.get("parent_id", None),
                        }

                # 按创建时间倒序排序文件列表
                sorted_files = dict(
                    sorted(
                        db_files.items(),
                        key=lambda item: item[1].get("created_at") or "",
                        reverse=True,
                    )
                )

                db_dict["files"] = sorted_files

            db_dict["status"] = "已连接"
            databases.append(db_dict)

        return {"databases": databases}

    @classmethod
    def _add_to_processing_queue(cls, file_id: str) -> None:
        """
        将文件添加到处理队列

        Args:
            file_id: 文件ID
        """
        with cls._processing_lock:
            cls._processing_files.add(file_id)
            logger.debug(f"Added file {file_id} to processing queue")

    @classmethod
    def _remove_from_processing_queue(cls, file_id: str) -> None:
        """
        从处理队列中移除文件

        Args:
            file_id: 文件ID
        """
        with cls._processing_lock:
            cls._processing_files.discard(file_id)
            logger.debug(f"Removed file {file_id} from processing queue")

    @classmethod
    def _is_file_in_processing_queue(cls, file_id: str) -> bool:
        """
        检查文件是否在处理队列中

        Args:
            file_id: 文件ID

        Returns:
            bool: 文件是否在处理队列中
        """
        with cls._processing_lock:
            return file_id in cls._processing_files

    def _check_and_fix_processing_status(self, kb_id: str) -> None:
        """
        检查并修复异常的处理中状态
        如果文件状态为处理中但实际不在处理队列中，则修改为相应的错误状态

        Args:
            kb_id: 数据库ID
        """
        try:
            status_changed = False

            # 定义需要检查的中间状态及其对应的错误状态
            intermediate_states = {
                FileStatus.PARSING: FileStatus.ERROR_PARSING,
                FileStatus.INDEXING: FileStatus.ERROR_INDEXING,
            }

            # 检查该数据库下所有中间状态的文件
            for file_id, file_info in self.files_meta.items():
                if file_info.get("kb_id") == kb_id:
                    current_status = file_info.get("status")

                    if current_status in intermediate_states:
                        # 检查文件是否真的在处理队列中
                        if not self._is_file_in_processing_queue(file_id):
                            error_status = intermediate_states[current_status]
                            logger.warning(
                                f"File {file_id} has {current_status} status but is not in processing queue, "
                                f"marking as {error_status}"
                            )
                            self.files_meta[file_id]["status"] = error_status
                            self.files_meta[file_id]["error"] = (
                                f"{current_status.capitalize()} interrupted - process not found in queue"
                            )
                            self.files_meta[file_id]["updated_at"] = utc_isoformat()
                            status_changed = True

            # 如果有状态变更，保存元数据
            if status_changed:
                logger.info(f"Fixed interrupted processing status for database {kb_id}")

        except Exception as e:
            logger.error(f"Error checking processing status for database {kb_id}: {e}")

    async def delete_folder(self, kb_id: str, folder_id: str) -> None:
        """
        Recursively delete a folder and its content.

        Args:
            kb_id: Database ID
            folder_id: Folder ID to delete
        """
        # Find all children
        children = [
            fid
            for fid, meta in self.files_meta.items()
            if meta.get("kb_id") == kb_id and meta.get("parent_id") == folder_id
        ]

        for child_id in children:
            child_meta = self.files_meta.get(child_id)
            if child_meta and child_meta.get("is_folder"):
                await self.delete_folder(kb_id, child_id)
            else:
                await self.delete_file(kb_id, child_id)

        # Delete the folder itself
        # We call delete_file which should handle the actual removal.
        # Implementations should ensure they handle folder deletion gracefully (e.g. skip vector deletion)
        await self.delete_file(kb_id, folder_id)

    async def move_file(self, kb_id: str, file_id: str, new_parent_id: str | None) -> dict:
        """
        Move a file or folder to a new parent folder.

        Args:
            kb_id: Database ID
            file_id: File/Folder ID to move
            new_parent_id: New parent folder ID (None for root)

        Returns:
            dict: Updated metadata
        """
        if file_id not in self.files_meta:
            raise ValueError(f"File {file_id} not found")

        meta = self.files_meta[file_id]
        if meta.get("kb_id") != kb_id:
            raise ValueError(f"File {file_id} does not belong to database {kb_id}")

        # Basic cycle detection for folders
        if meta.get("is_folder") and new_parent_id:
            # Check if new_parent_id is a child of file_id (or is file_id itself)
            if new_parent_id == file_id:
                raise ValueError("Cannot move a folder into itself")

            # Walk up the tree from new_parent_id
            current = new_parent_id
            while current:
                parent_meta = self.files_meta.get(current)
                if not parent_meta:
                    break  # Should not happen if integrity is maintained
                if current == file_id:
                    raise ValueError("Cannot move a folder into its own subfolder")
                current = parent_meta.get("parent_id")

        meta["parent_id"] = new_parent_id
        await self._persist_file(file_id)
        return meta

    @abstractmethod
    async def delete_file(self, kb_id: str, file_id: str) -> None:
        """
        删除文件

        Args:
            kb_id: 数据库ID
            file_id: 文件ID
        """
        pass

    @abstractmethod
    async def get_file_basic_info(self, kb_id: str, file_id: str) -> dict:
        """
        获取文件基本信息（仅元数据）

        Args:
            kb_id: 数据库ID
            file_id: 文件ID

        Returns:
            dict: 包含文件基本信息的字典
        """
        pass

    @abstractmethod
    async def get_file_content(self, kb_id: str, file_id: str) -> dict:
        """
        获取文件内容信息（chunks和lines）

        Args:
            kb_id: 数据库ID
            file_id: 文件ID

        Returns:
            dict: 包含文件内容信息的字典
        """
        pass

    @abstractmethod
    async def get_file_info(self, kb_id: str, file_id: str) -> dict:
        """
        获取文件完整信息（基本信息+内容信息）

        Args:
            kb_id: 数据库ID
            file_id: 文件ID

        Returns:
            dict: 包含文件信息和chunks的字典
        """
        pass

    def update_database(
        self,
        kb_id: str,
        name: str,
        description: str,
        llm_model_spec: str | None = None,
        update_llm_model_spec: bool = False,
    ) -> dict:
        """
        更新数据库

        Args:
            kb_id: 数据库ID
            name: 新名称
            description: 新描述
            llm_model_spec: LLM 模型 spec（可选）

        Returns:
            更新后的数据库信息
        """
        if kb_id not in self.databases_meta:
            raise ValueError(f"数据库 {kb_id} 不存在")

        self.databases_meta[kb_id]["name"] = name
        self.databases_meta[kb_id]["description"] = description
        if update_llm_model_spec:
            self.databases_meta[kb_id]["llm_model_spec"] = llm_model_spec

        return self.get_database_info(kb_id)

    def get_retrievers(self) -> dict[str, dict]:
        """
        获取所有检索器

        Returns:
            检索器字典
        """
        retrievers = {}
        for kb_id, meta in self.databases_meta.items():

            def make_retriever(kb_id):
                async def retriever(query_text, **kwargs):
                    results = await self.aquery(query_text, kb_id, agent_call=True, **kwargs)
                    return self.build_search_output(kb_id, results)

                return retriever

            retrievers[kb_id] = {
                "name": meta["name"],
                "description": meta["description"],
                "retriever": make_retriever(kb_id),
                "metadata": meta,
            }
        return retrievers

    async def _load_metadata(self) -> None:
        from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
        from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository

        kb_repo = KnowledgeBaseRepository()
        file_repo = KnowledgeFileRepository()

        databases = [kb for kb in await kb_repo.get_all() if kb.kb_type == self.kb_type]
        self.databases_meta = {
            kb.kb_id: {
                "name": kb.name,
                "description": kb.description,
                "kb_type": kb.kb_type,
                "embedding_model_spec": kb.embedding_model_spec,
                "llm_model_spec": kb.llm_model_spec,
                "query_params": kb.query_params or self._get_default_query_params(kb.kb_id),
                "metadata": self.normalize_additional_params(kb.additional_params),
                "created_at": utc_isoformat(kb.created_at) if kb.created_at else utc_isoformat(),
            }
            for kb in databases
        }

        self.files_meta = {}
        for kb in databases:
            kb_additional_params = self.databases_meta.get(kb.kb_id, {}).get("metadata") or {}
            for record in await file_repo.list_by_kb_id(kb.kb_id):
                self.files_meta[record.file_id] = {
                    "file_id": record.file_id,
                    "kb_id": record.kb_id,
                    "parent_id": record.parent_id,
                    "filename": record.filename,
                    "file_type": record.file_type,
                    "path": record.path,
                    "markdown_file": record.markdown_file,
                    "status": record.status,
                    "content_hash": record.content_hash,
                    "size": record.file_size,
                    "content_type": record.content_type,
                    "processing_params": sanitize_processing_params(
                        resolve_processing_params(
                            kb_additional_params=kb_additional_params,
                            file_processing_params=record.processing_params,
                        )
                    ),
                    "is_folder": record.is_folder,
                    "error": record.error_message,
                    "created_by": record.created_by,
                    "updated_by": record.updated_by,
                    "created_at": utc_isoformat(record.created_at) if record.created_at else None,
                    "updated_at": utc_isoformat(record.updated_at) if record.updated_at else None,
                    "original_filename": record.original_filename,
                    "minio_url": record.minio_url,
                }

        self.benchmarks_meta = {}

        logger.info(f"Loaded {self.kb_type} metadata from database for {len(self.databases_meta)} databases")
        await self._fill_missing_file_sizes()

    async def _fill_missing_file_sizes(self) -> None:
        """为缺少 size 的已有文件从 MinIO 补全大小信息"""
        from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
        from yuxi.storage.minio import get_minio_client

        files_to_update: list[str] = []
        for file_id, meta in self.files_meta.items():
            if meta.get("is_folder"):
                continue
            if meta.get("size") is not None:
                continue
            file_path = meta.get("minio_url") or meta.get("path")
            if not file_path or not is_minio_url(file_path):
                continue
            files_to_update.append(file_id)

        if not files_to_update:
            return

        minio_client = get_minio_client()

        async def _stat_file(file_id: str, file_path: str) -> tuple[str, int | None]:
            bucket_name, obj_name = parse_minio_url(file_path)
            try:
                return file_id, await minio_client.astat_file(bucket_name, obj_name)
            except Exception as exc:
                logger.warning(f"Failed to fill size for {file_id}: {exc}")
                return file_id, None

        results = await asyncio.gather(
            *(
                _stat_file(fid, self.files_meta[fid].get("minio_url") or self.files_meta[fid].get("path"))
                for fid in files_to_update
            )
        )
        updated = 0
        for file_id, file_size in results:
            if file_size is not None:
                self.files_meta[file_id]["size"] = file_size
                updated += 1

        if updated:
            logger.info(f"Filled {updated}/{len(files_to_update)} missing file sizes from MinIO for {self.kb_type}")
            for file_id, file_size in results:
                if file_size is not None:
                    await self._persist_file(file_id)

    async def _save_metadata(self) -> None:
        from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
        from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository

        kb_repo = KnowledgeBaseRepository()
        file_repo = KnowledgeFileRepository()

        self._normalize_metadata_state()

        for kb_id, meta in self.databases_meta.items():
            existing = await kb_repo.get_by_kb_id(kb_id)
            payload = {
                "kb_id": kb_id,
                "name": meta.get("name") or kb_id,
                "description": meta.get("description"),
                "kb_type": meta.get("kb_type") or self.kb_type,
                "embedding_model_spec": meta.get("embedding_model_spec"),
                "llm_model_spec": meta.get("llm_model_spec"),
                "query_params": meta.get("query_params"),
                "additional_params": meta.get("metadata") or {},
            }
            if existing is None:
                await kb_repo.create(payload)

        for file_id, meta in self.files_meta.items():
            kb_id = meta.get("kb_id")
            if not kb_id:
                continue
            await file_repo.upsert(
                file_id=file_id,
                data={
                    "kb_id": kb_id,
                    "parent_id": meta.get("parent_id"),
                    "filename": meta.get("filename") or "",
                    "original_filename": meta.get("original_filename"),
                    "file_type": meta.get("file_type"),
                    "path": meta.get("path"),
                    "minio_url": meta.get("minio_url"),
                    "markdown_file": meta.get("markdown_file"),
                    "status": meta.get("status"),
                    "content_hash": meta.get("content_hash"),
                    "file_size": meta.get("size"),
                    "content_type": meta.get("content_type"),
                    "processing_params": sanitize_processing_params(meta.get("processing_params")),
                    "is_folder": meta.get("is_folder", False),
                    "error_message": meta.get("error"),
                    "created_by": str(meta.get("created_by")) if meta.get("created_by") else None,
                    "updated_by": str(meta.get("updated_by")) if meta.get("updated_by") else None,
                },
            )

    async def _persist_file(self, file_id: str) -> None:
        """只保存单个文件到数据库，避免全量遍历"""
        from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository

        file_repo = KnowledgeFileRepository()

        if file_id not in self.files_meta:
            return

        meta = self.files_meta[file_id]
        kb_id = meta.get("kb_id")
        if not kb_id:
            return

        await file_repo.upsert(
            file_id=file_id,
            data={
                "kb_id": kb_id,
                "parent_id": meta.get("parent_id"),
                "filename": meta.get("filename") or "",
                "original_filename": meta.get("original_filename"),
                "file_type": meta.get("file_type"),
                "path": meta.get("path"),
                "minio_url": meta.get("minio_url"),
                "markdown_file": meta.get("markdown_file"),
                "status": meta.get("status"),
                "content_hash": meta.get("content_hash"),
                "file_size": meta.get("size"),
                "content_type": meta.get("content_type"),
                "processing_params": sanitize_processing_params(meta.get("processing_params")),
                "is_folder": meta.get("is_folder", False),
                "error_message": meta.get("error"),
                "created_by": str(meta.get("created_by")) if meta.get("created_by") else None,
                "updated_by": str(meta.get("updated_by")) if meta.get("updated_by") else None,
            },
        )

    async def _persist_kb(self, kb_id: str) -> None:
        """只保存单个知识库到数据库，避免全量遍历"""
        from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository

        kb_repo = KnowledgeBaseRepository()

        if kb_id not in self.databases_meta:
            return

        meta = self.databases_meta[kb_id]
        existing = await kb_repo.get_by_kb_id(kb_id)
        payload = {
            "kb_id": kb_id,
            "name": meta.get("name") or kb_id,
            "description": meta.get("description"),
            "kb_type": meta.get("kb_type") or self.kb_type,
            "embedding_model_spec": meta.get("embedding_model_spec"),
            "llm_model_spec": meta.get("llm_model_spec"),
            "query_params": meta.get("query_params"),
            "additional_params": meta.get("metadata") or {},
        }

        if existing is None:
            await kb_repo.create(payload)
        else:
            await kb_repo.update(
                kb_id,
                {
                    "name": payload["name"],
                    "description": payload["description"],
                    "kb_type": payload["kb_type"],
                    "embedding_model_spec": payload["embedding_model_spec"],
                    "llm_model_spec": payload["llm_model_spec"],
                    "query_params": payload["query_params"],
                    "additional_params": payload["additional_params"],
                },
            )
