import asyncio
import hashlib
import json
import re
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional

from config import WIKI_CONFIG
from wiki_engine import db
from wiki_engine.chunk_enhancer import enhance_slices
from wiki_engine.htknow_client import HTKnowClient
from wiki_engine.wiki_builder import build_pages_for_file


_FILE_SLICE_TEXT_CACHE: Dict[str, Dict[str, str]] = {}


async def sync_file(
    file_id: str,
    kb_id: Optional[str] = None,
    file_info: Optional[Dict[str, Any]] = None,
    use_llm: bool = False,
    job_id: Optional[str] = None,
    force: bool = False,
) -> Dict[str, Any]:
    client = HTKnowClient()
    file_info = dict(file_info or {})
    filename = str(file_info.get("filename") or file_info.get("name") or "")
    if job_id:
        await db.update_job(
            job_id,
            stage="fetch_file",
            current_file_id=str(file_id),
            current_filename=filename,
        )
    if not file_info or not (file_info.get("filename") or file_info.get("name")):
        try:
            file_info.update(await client.get_file(str(file_id)))
        except Exception as exc:
            print(f"[wiki_engine] get file detail failed for {file_id}: {exc}")
    file_info.setdefault("id", str(file_id))
    file_info.setdefault("file_id", str(file_id))
    if kb_id:
        file_info.setdefault("kb_id", str(kb_id))
    filename = str(file_info.get("filename") or file_info.get("name") or "")
    if job_id:
        await db.update_job(job_id, stage="fetch_slices", current_filename=filename)

    slices = await client.get_file_slices(str(file_id), limit=WIKI_CONFIG.get("max_slices_per_file"))
    if job_id:
        await db.update_job(job_id, stage="enhance_slices")
    refs = enhance_slices(
        slices,
        file_info=file_info,
        max_slices=int(WIKI_CONFIG.get("max_slices_per_file") or 300),
    )
    if not refs:
        raise ValueError(f"no slices returned for file {file_id}; keep existing wiki data unchanged")

    content_signature = _file_content_signature(refs)
    existing_state = await db.get_file_state(str(file_id))
    existing_page_count = await db.count_file_pages(str(file_id)) if existing_state else 0
    if not force and _is_unchanged_file(existing_state, refs, content_signature) and existing_page_count > 0:
        if job_id:
            await db.update_job(job_id, stage="skipped_unchanged")
        return {
            "success": True,
            "skipped": True,
            "reason": "unchanged",
            "file_id": str(file_id),
            "kb_id": str(kb_id or file_info.get("kb_id") or ""),
            "slice_count": len(refs),
            "embedding_count": 0,
            "embedding_error": "",
            "page_count": existing_page_count,
            "page_ids": [],
        }

    source_hash = str(file_info.get("hash") or "")
    metadata = file_info.get("metadata") if isinstance(file_info.get("metadata"), dict) else {}
    metadata = dict(metadata)
    metadata["content_signature"] = content_signature
    metadata["source_hash"] = source_hash
    metadata["wiki_pipeline_version"] = "wiki-sync-v3"
    file_info["metadata"] = metadata
    file_info["content_signature"] = content_signature
    if not source_hash:
        file_info["hash"] = content_signature

    await db.upsert_file_state(file_info, slice_count=len(refs))
    if job_id:
        await db.update_job(job_id, stage="write_slices")
    await db.replace_file_slice_refs(str(file_id), refs)
    if job_id:
        await db.update_job(job_id, stage="build_pages")
    async def progress(stage: str) -> None:
        if job_id and WIKI_CONFIG.get("stage_progress_enabled", True):
            await db.update_job(job_id, stage=stage)

    pages = await build_pages_for_file(file_info, refs, use_llm=use_llm, progress=progress if job_id else None)
    if job_id:
        await db.update_job(job_id, stage="write_pages")
    page_ids = []
    for page in pages:
        page_id = await _upsert_page_with_slug_lock(page, owner=job_id or f"sync:{file_id}")
        page_ids.append(page_id)
        await db.replace_page_slices(page_id, page.get("links", []))
    if job_id:
        await db.update_job(job_id, stage="file_done")

    return {
        "success": True,
        "skipped": False,
        "file_id": str(file_id),
        "kb_id": str(kb_id or file_info.get("kb_id") or ""),
        "slice_count": len(refs),
        "embedding_count": 0,
        "embedding_error": "",
        "page_count": len(page_ids),
        "page_ids": page_ids,
    }


async def sync_kb(
    kb_id: str,
    limit: Optional[int] = None,
    use_llm: bool = False,
    job_id: Optional[str] = None,
    force: bool = False,
) -> Dict[str, Any]:
    limit = int(limit or WIKI_CONFIG.get("default_sync_limit") or 20)
    client = HTKnowClient()
    files = await client.list_files(kb_id=kb_id, limit=limit)
    if job_id:
        await db.update_job(job_id, total_files=len(files))

    processed = 0
    total_slices = 0
    skipped = 0
    page_count = 0
    errors: List[Dict[str, str]] = []
    for file_info in files:
        file_id = str(file_info.get("id") or file_info.get("file_id") or "")
        if not file_id:
            continue
        try:
            result = await sync_file(file_id, kb_id=kb_id, file_info=file_info, use_llm=use_llm, job_id=job_id, force=force)
            processed += 1
            total_slices += int(result.get("slice_count") or 0)
            skipped += 1 if result.get("skipped") else 0
            if not result.get("skipped"):
                page_count += int(result.get("page_count") or 0)
        except Exception as exc:
            errors.append({"file_id": file_id, "error": str(exc)})
        if job_id:
            await db.update_job(
                job_id,
                processed_files=processed,
                total_slices=total_slices,
                skipped_files=skipped,
                page_count=page_count,
            )
        await asyncio.sleep(0)

    if job_id and WIKI_CONFIG.get("finalize_enabled", True):
        await enqueue_finalize_job(kb_id, job_id)

    return {
        "success": not errors,
        "kb_id": str(kb_id),
        "file_count": len(files),
        "processed_files": processed,
        "total_slices": total_slices,
        "skipped_files": skipped,
        "page_count": page_count,
        "errors": errors[:20],
    }


async def start_sync_job(
    target_type: str,
    target_id: str,
    kb_id: Optional[str] = None,
    limit: Optional[int] = None,
    use_llm: bool = False,
    force: bool = False,
) -> str:
    job_id = str(uuid.uuid4())
    await db.create_job(job_id, target_type=target_type, target_id=target_id, kb_id=kb_id or "")
    if WIKI_CONFIG.get("queue_enabled", True):
        if target_type == "file":
            await db.set_job_totals(job_id, 1)
            await db.enqueue_wiki_file_op(
                job_id=job_id,
                kb_id=kb_id or "",
                file_id=target_id,
                file_info={},
                use_llm=use_llm,
                force=force,
            )
            return job_id
        if target_type == "kb":
            client = HTKnowClient()
            files = await client.list_files(kb_id=target_id, limit=limit or WIKI_CONFIG.get("default_sync_limit"))
            valid_files = [
                (str(file_info.get("id") or file_info.get("file_id") or ""), file_info)
                for file_info in files
                if str(file_info.get("id") or file_info.get("file_id") or "")
            ]
            await db.set_job_totals(job_id, len(valid_files))
            if not valid_files:
                await db.update_job(job_id, status="success")
                return job_id
            for file_id, file_info in valid_files:
                await db.enqueue_wiki_file_op(
                    job_id=job_id,
                    kb_id=target_id,
                    file_id=file_id,
                    file_info=file_info,
                    use_llm=use_llm,
                    force=force,
                )
            return job_id

    async def _runner() -> None:
        try:
            if target_type == "file":
                await sync_file(target_id, kb_id=kb_id, use_llm=use_llm, job_id=job_id, force=force)
            elif target_type == "kb":
                await sync_kb(target_id, limit=limit, use_llm=use_llm, job_id=job_id, force=force)
            else:
                raise ValueError(f"unsupported target_type: {target_type}")
            await db.update_job(job_id, status="success")
        except Exception as exc:
            await db.update_job(job_id, status="failed", error=str(exc))

    asyncio.create_task(_runner())
    return job_id


async def get_queue_stats() -> Dict[str, Any]:
    return await db.get_wiki_queue_stats()


async def get_sync_job(job_id: str) -> Optional[Dict[str, Any]]:
    return await db.get_job(job_id)


async def enqueue_finalize_job(kb_id: str, job_id: str = "") -> Dict[str, Any]:
    if not WIKI_CONFIG.get("finalize_enabled", True):
        return {"success": True, "enqueued": False, "reason": "disabled"}
    op_id = await db.enqueue_wiki_finalize_op(
        job_id=job_id,
        kb_id=kb_id,
        delay_seconds=int(WIKI_CONFIG.get("finalize_debounce_seconds") or 20),
    )
    return {"success": True, "enqueued": bool(op_id), "op_id": op_id}


async def finalize_kb(kb_id: str, job_id: str = "") -> Dict[str, Any]:
    if job_id:
        await db.update_job(job_id, stage="finalize")
    page_count = await db.count_pages_for_kb(kb_id)
    if job_id:
        await db.update_job(job_id, stage="completed", page_count=page_count, current_file_id="", current_filename="")
    return {"success": True, "kb_id": str(kb_id or ""), "page_count": page_count}


async def search_wiki(
    query: str,
    kb_id: Optional[str] = None,
    limit: int = 8,
    include_slices: bool = True,
) -> Dict[str, Any]:
    page_limit = max(int(WIKI_CONFIG.get("page_search_top_k") or limit or 8), limit)
    pages = await db.search_pages(query=query, kb_id=kb_id, limit=page_limit)

    slice_map = {}
    if include_slices and pages:
        slice_map = await db.get_page_slices(
            [int(p["id"]) for p in pages],
            max_slices_per_page=int(WIKI_CONFIG.get("evidence_slices_per_page") or 6),
        )
    items = []
    for rank, page in enumerate(pages, start=1):
        page_id = int(page["id"])
        slices = slice_map.get(page_id, [])
        related_slice_ids = [str(s.get("slice_id")) for s in slices if s.get("slice_id")]
        related_file_ids = list({
            str(s.get("file_id"))
            for s in slices
            if s.get("file_id")
        } | set([str(x) for x in (page.get("related_file_ids") or []) if x]))
        items.append({
            "source_type": "wiki",
            "source_id": str(page_id),
            "kb_id": str(page.get("kb_id") or ""),
            "rank_sources": {"page": rank},
            "slug": page.get("slug", ""),
            "title": page.get("title", ""),
            "page_type": page.get("page_type", ""),
            "summary": page.get("summary", ""),
            "content": page.get("content", ""),
            "keywords": page.get("keywords", []),
            "aliases": page.get("aliases", []),
            "score": page.get("score", 0.0),
            "related_file_ids": related_file_ids,
            "related_slice_ids": related_slice_ids,
            "slices": slices,
        })

    items = _fuse_wiki_items(items, limit=max(limit, 1))
    await db.log_query(query, kb_id, len(items))
    return {
        "success": True,
        "query": query,
        "kb_id": kb_id or "",
        "items": items,
        "debug": {"page_hits": len(pages), "vector_hits": 0, "text_hits": 0},
    }


async def evidence_search(query: str, kb_id: Optional[str] = None, limit: int = 5) -> Dict[str, Any]:
    result = await search_wiki(
        query=query,
        kb_id=kb_id,
        limit=max(limit * 2, limit),
        include_slices=True,
    )
    evidence_items = []
    for item in result.get("items", []):
        slices = await _hydrate_wiki_slices(item.get("slices", []))
        slice_texts = []
        for s in slices[:6]:
            text = str(s.get("content") or s.get("brief") or "").strip()
            if not text:
                continue
            filename = str(s.get("filename") or "").strip()
            title_path = " / ".join(str(x) for x in (s.get("title_path") or []) if x)
            prefix_parts = [x for x in (filename, title_path) if x]
            prefix = f"{' - '.join(prefix_parts)}\n" if prefix_parts else ""
            slice_texts.append(
                f"{prefix}{text}\n"
                f"(file_id={s.get('file_id') or ''}, slice_id={s.get('slice_id') or ''})"
            )
        deep_text = "\n\n".join(slice_texts)
        page_text = "\n".join(x for x in [
            str(item.get("summary") or "").strip(),
            str(item.get("content") or "").strip(),
        ] if x).strip()
        text = "\n\n".join(x for x in [page_text, deep_text] if x).strip()
        if not text:
            continue
        evidence_items.append({
            "source_type": "wiki",
            "source_id": item.get("source_id"),
            "title": item.get("title"),
            "text": text,
            "score": item.get("score", 0.0),
            "kb_id": item.get("kb_id", ""),
            "metadata": {
                "kb_id": item.get("kb_id", ""),
                "page_type": item.get("page_type"),
                "keywords": item.get("keywords", []),
                "related_file_ids": item.get("related_file_ids", []),
                "related_slice_ids": item.get("related_slice_ids", []),
                "slices": slices,
                "rank_sources": item.get("rank_sources", {}),
            },
        })
    # Keep the page-search order here. The baike workflow does a later cross-source
    # rerank, and preserving the Wiki top hit makes the displayed Wiki evidence
    # match `/api/wiki/search`.
    evidence_items.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True)
    return {"success": True, "query": query, "items": evidence_items[:limit], "debug": result.get("debug", {})}


def _fuse_wiki_items(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:
    rrf_k = max(int(WIKI_CONFIG.get("rrf_k") or 60), 1)
    by_key: Dict[str, Dict[str, Any]] = {}
    for item in items:
        key = str(item.get("source_id") or item.get("title") or "")
        if not key:
            continue
        fused_score = float(item.get("score") or 0.0)
        for rank in (item.get("rank_sources") or {}).values():
            try:
                fused_score += 1.0 / (rrf_k + int(rank))
            except Exception:
                continue
        item = dict(item)
        item["score"] = fused_score
        existing = by_key.get(key)
        if existing:
            merged_sources = dict(existing.get("rank_sources") or {})
            for source, rank in (item.get("rank_sources") or {}).items():
                if source not in merged_sources or int(rank) < int(merged_sources.get(source) or 999999):
                    merged_sources[source] = rank
            item["rank_sources"] = merged_sources
            fused_score = max(fused_score, float(existing.get("score") or 0.0))
            for rank in merged_sources.values():
                try:
                    fused_score += 1.0 / (rrf_k + int(rank))
                except Exception:
                    continue
            item["score"] = fused_score
        if not existing or fused_score > float(existing.get("score") or 0.0):
            by_key[key] = item
    return sorted(by_key.values(), key=lambda x: float(x.get("score") or 0.0), reverse=True)[:limit]


async def _rerank_wiki_evidence(query: str, items: List[Dict[str, Any]], top_k: int) -> List[Dict[str, Any]]:
    if not query.strip() or not items:
        return items[:top_k]
    try:
        from modelsAPI.model_api import OpenaiAPI

        selected = items[:24]
        documents = [
            "\n".join([
                str(item.get("title") or ""),
                str(item.get("text") or ""),
            ])[:4000]
            for item in selected
        ]
        rows = await OpenaiAPI.rerank(query=query, documents=documents, top_k=top_k)
        if not rows:
            return items[:top_k]
        ranked: List[Dict[str, Any]] = []
        used = set()
        for row in rows:
            idx = int(row.get("index"))
            if idx < 0 or idx >= len(selected) or idx in used:
                continue
            item = dict(selected[idx])
            item["rerank_score"] = float(row.get("relevance_score") or 0.0)
            item["score"] = max(float(item.get("score") or 0.0), item["rerank_score"])
            ranked.append(item)
            used.add(idx)
        for idx, item in enumerate(selected):
            if idx not in used and len(ranked) < top_k:
                ranked.append(item)
        return ranked[:top_k]
    except Exception as exc:
        print(f"[wiki_engine] wiki evidence rerank failed: {exc}")
        return items[:top_k]


async def _hydrate_wiki_slices(slices: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Fill missing slice content from 11000 for old Wiki rows."""
    out = [dict(s or {}) for s in slices]
    missing_by_file: Dict[str, set[str]] = {}
    for s in out:
        if str(s.get("content") or "").strip():
            continue
        file_id = str(s.get("file_id") or "").strip()
        slice_id = str(s.get("slice_id") or "").strip()
        if file_id and slice_id:
            missing_by_file.setdefault(file_id, set()).add(slice_id)
    if not missing_by_file:
        return out

    client = HTKnowClient()
    fetched: Dict[str, str] = {}
    for file_id, wanted_ids in missing_by_file.items():
        cached = _FILE_SLICE_TEXT_CACHE.get(file_id)
        if cached is not None:
            fetched.update({sid: cached.get(sid, "") for sid in wanted_ids})
            continue
        try:
            raw_slices = await client.get_file_slices(file_id, limit=WIKI_CONFIG.get("max_slices_per_file"))
        except Exception as exc:
            print(f"[wiki_engine] hydrate slices failed for file {file_id}: {exc}")
            continue
        file_texts: Dict[str, str] = {}
        for idx, raw in enumerate(raw_slices):
            sid = str(raw.get("id") or raw.get("slice_id") or raw.get("uuid") or f"{file_id}:{idx}")
            text = _raw_slice_text(raw)
            if text:
                file_texts[sid] = text
            if sid in wanted_ids:
                fetched[sid] = text
        _FILE_SLICE_TEXT_CACHE[file_id] = file_texts

    for s in out:
        sid = str(s.get("slice_id") or "")
        if sid in fetched and fetched[sid]:
            s["content"] = fetched[sid]
    return out


def _file_content_signature(refs: List[Dict[str, Any]]) -> str:
    rows = []
    for ref in sorted(refs, key=lambda x: (int(x.get("ordinal") or 0), str(x.get("slice_id") or ""))):
        rows.append({
            "slice_id": str(ref.get("slice_id") or ""),
            "content_hash": str(ref.get("content_hash") or ""),
            "title_path": ref.get("title_path") or [],
            "keywords": ref.get("keywords") or [],
        })
    raw = json.dumps(rows, ensure_ascii=False, sort_keys=True)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def _is_unchanged_file(
    state: Optional[Dict[str, Any]],
    refs: List[Dict[str, Any]],
    content_signature: str,
) -> bool:
    if not state:
        return False
    if int(state.get("slice_count") or 0) != len(refs):
        return False
    metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
    stored_signature = str(metadata.get("content_signature") or "")
    if stored_signature and stored_signature == content_signature:
        return True
    return str(state.get("file_hash") or "") == content_signature


async def _upsert_page_with_slug_lock(page: Dict[str, Any], owner: str) -> int:
    kb_id = str(page.get("kb_id") or "")
    slug = str(page.get("slug") or "")
    if not kb_id or not slug:
        return await db.upsert_wiki_page(page)

    ttl = int(WIKI_CONFIG.get("slug_lock_ttl_seconds") or 600)
    acquired = False
    for _ in range(600):
        acquired = await db.acquire_wiki_page_lock(kb_id, slug, owner=owner, ttl_seconds=ttl)
        if acquired:
            break
        await asyncio.sleep(0.1)
    if not acquired:
        raise TimeoutError(f"wiki page lock timeout: {kb_id}/{slug}")

    try:
        return await db.upsert_wiki_page(page)
    finally:
        await db.release_wiki_page_lock(kb_id, slug, owner=owner)


def _raw_slice_text(raw: Dict[str, Any]) -> str:
    for key in ("content", "text", "page_content", "slice_content", "markdown"):
        value = raw.get(key)
        if isinstance(value, str) and value.strip():
            return value
    return ""


async def get_page(page_id: int) -> Optional[Dict[str, Any]]:
    return await db.get_page(page_id)


async def get_stats() -> Dict[str, Any]:
    return await db.get_stats()


async def get_kb_tree() -> Dict[str, Any]:
    client = HTKnowClient()
    tree = await client.get_kb_tree()
    return {"success": True, "tree": tree}


async def resolve_kb_reference(reference: str) -> Dict[str, Any]:
    """Resolve a user-facing knowledge-base name or admin id to a kb_id."""
    ref = str(reference or "").strip()
    if not ref:
        return {"success": False, "error": "knowledge base name is empty", "matches": []}

    client = HTKnowClient()
    tree = await client.get_kb_tree()
    nodes = _flatten_kb_tree(tree)
    ref_norm = _normalize_kb_text(ref)

    scored: List[Dict[str, Any]] = []
    for node in nodes:
        node_id = str(node.get("id") or "")
        names = _node_names(node)
        best_score = 0
        best_name = names[0] if names else ""
        if node_id and node_id == ref:
            best_score = 10000
        for name in names:
            name_norm = _normalize_kb_text(name)
            if not name_norm:
                continue
            score = 0
            if name_norm == ref_norm:
                score = 9000 + len(name_norm)
            elif name_norm in ref_norm:
                score = 7000 + len(name_norm)
            elif ref_norm in name_norm:
                score = 5000 + len(ref_norm)
            if score > best_score:
                best_score = score
                best_name = name
        if best_score:
            scored.append({
                "kb_id": node_id,
                "name": best_name,
                "path": node.get("_path", best_name),
                "score": best_score,
            })

    scored.sort(key=lambda item: (-int(item.get("score") or 0), -len(str(item.get("path") or ""))))
    if not scored:
        return {
            "success": False,
            "error": f"未找到名为“{ref}”的知识库",
            "matches": [],
            "candidates": [
                {"kb_id": str(node.get("id") or ""), "name": (_node_names(node) or [""])[0], "path": node.get("_path", "")}
                for node in nodes[:20]
            ],
        }

    top_score = int(scored[0].get("score") or 0)
    top_matches = [item for item in scored if int(item.get("score") or 0) == top_score]
    if len({item.get("kb_id") for item in top_matches}) > 1 and top_score < 10000:
        return {
            "success": False,
            "error": f"知识库名称“{ref}”匹配到多个结果，请说得更完整一点",
            "matches": top_matches[:10],
        }

    best = scored[0]
    return {
        "success": True,
        "kb_id": str(best.get("kb_id") or ""),
        "kb_name": str(best.get("name") or ""),
        "kb_path": str(best.get("path") or ""),
        "matches": scored[:10],
    }


async def diff_kb(
    kb_id: str,
    include_children: bool = False,
    limit: int = 200,
    parsed_only: bool = True,
) -> Dict[str, Any]:
    client = HTKnowClient()
    kb_ids = await _resolve_kb_ids(client, kb_id, include_children=include_children)
    ht_files: Dict[str, Dict[str, Any]] = {}
    skipped_files: List[Dict[str, Any]] = []

    for kid in kb_ids:
        files = await client.list_files(kb_id=kid, limit=max(int(limit or 200), 1))
        for file_info in files:
            fid = str(file_info.get("id") or file_info.get("file_id") or "")
            if not fid:
                continue
            if parsed_only and str(file_info.get("status")) != "1":
                skipped_files.append(_compact_file(file_info))
                continue
            file_info["file_id"] = fid
            file_info["kb_id"] = str(file_info.get("kb_id") or kid)
            ht_files[fid] = file_info

    wiki_states = await db.list_file_states(kb_ids)
    wiki_by_file = {str(item.get("file_id")): item for item in wiki_states}

    missing_in_wiki = []
    stale_in_wiki = []
    for fid, file_info in ht_files.items():
        state = wiki_by_file.get(fid)
        if not state:
            missing_in_wiki.append(_compact_file(file_info))
        elif _is_stale(file_info, state):
            stale_in_wiki.append({
                "file_id": fid,
                "kb_id": str(file_info.get("kb_id") or ""),
                "filename": str(file_info.get("filename") or ""),
                "htknow_hash": str(file_info.get("hash") or ""),
                "wiki_hash": str(state.get("file_hash") or ""),
                "htknow_updated_at": str(file_info.get("updated_at") or ""),
                "wiki_source_updated_at": str(state.get("source_updated_at") or ""),
                "last_synced_at": state.get("last_synced_at") or "",
            })

    orphan_in_wiki = []
    for fid, state in wiki_by_file.items():
        if fid not in ht_files:
            orphan_in_wiki.append({
                "file_id": fid,
                "kb_id": str(state.get("kb_id") or ""),
                "filename": str(state.get("filename") or ""),
                "last_synced_at": state.get("last_synced_at") or "",
            })

    return {
        "success": True,
        "kb_id": str(kb_id),
        "include_children": include_children,
        "kb_ids": kb_ids,
        "summary": {
            "htknow_files": len(ht_files),
            "wiki_files": len(wiki_by_file),
            "missing_in_wiki": len(missing_in_wiki),
            "stale_in_wiki": len(stale_in_wiki),
            "orphan_in_wiki": len(orphan_in_wiki),
            "skipped_unparsed": len(skipped_files),
        },
        "missing_in_wiki": missing_in_wiki,
        "stale_in_wiki": stale_in_wiki,
        "orphan_in_wiki": orphan_in_wiki,
        "skipped_unparsed": skipped_files[:50],
    }


async def reconcile_kb(
    kb_id: str,
    include_children: bool = False,
    limit: int = 200,
    sync_missing: bool = True,
    rebuild_stale: bool = True,
    delete_orphan: bool = False,
    use_llm: bool = False,
    dry_run: bool = True,
) -> Dict[str, Any]:
    diff = await diff_kb(kb_id=kb_id, include_children=include_children, limit=limit, parsed_only=True)
    actions: List[Dict[str, Any]] = []
    if sync_missing:
        actions.extend({"action": "sync_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["missing_in_wiki"])
    if rebuild_stale:
        actions.extend({"action": "rebuild_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["stale_in_wiki"])
    if delete_orphan:
        actions.extend({"action": "delete_wiki_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["orphan_in_wiki"])

    executed: List[Dict[str, Any]] = []
    if not dry_run:
        client = HTKnowClient()
        for action in actions:
            try:
                if action["action"] in {"sync_file", "rebuild_file"}:
                    file_info = await client.get_file(action["file_id"])
                    if action.get("kb_id"):
                        file_info["kb_id"] = action["kb_id"]
                    result = await sync_file(
                        action["file_id"],
                        kb_id=action.get("kb_id"),
                        file_info=file_info,
                        use_llm=use_llm,
                        force=action["action"] == "rebuild_file",
                    )
                elif action["action"] == "delete_wiki_file":
                    result = await delete_by_file(action["file_id"])
                else:
                    result = {"success": False, "error": "unknown action"}
                executed.append({"action": action, "result": result})
            except Exception as exc:
                executed.append({"action": action, "result": {"success": False, "error": str(exc)}})

    return {
        "success": True,
        "dry_run": dry_run,
        "kb_id": str(kb_id),
        "include_children": include_children,
        "summary": diff.get("summary", {}),
        "planned_actions": actions,
        "executed": executed,
    }


async def create_admin_plan(
    chat_id: str,
    intent: str,
    scope: str,
    actions: List[Dict[str, Any]],
    kb_id: str = "",
    kb_name: str = "",
    summary: Optional[Dict[str, Any]] = None,
    metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    return await db.create_admin_plan(
        plan_id=str(uuid.uuid4()),
        chat_id=chat_id,
        intent=intent,
        scope=scope,
        kb_id=kb_id,
        kb_name=kb_name,
        summary=summary or {},
        actions=actions,
        metadata=metadata or {},
    )


async def get_pending_admin_plan(chat_id: str) -> Optional[Dict[str, Any]]:
    return await db.get_latest_pending_admin_plan(chat_id)


async def cancel_admin_plan(plan_id: str) -> Optional[Dict[str, Any]]:
    return await db.update_admin_plan_status(plan_id, "cancelled")


async def execute_admin_plan(
    plan: Dict[str, Any],
    selected_indexes: Optional[List[int]] = None,
    use_llm: bool = False,
) -> Dict[str, Any]:
    actions = plan.get("actions") or []
    if selected_indexes:
        selected = {idx for idx in selected_indexes if idx >= 1}
        actions = [action for idx, action in enumerate(actions, start=1) if idx in selected]
    executed: List[Dict[str, Any]] = []
    client = HTKnowClient()
    for action in actions:
        try:
            action_type = action.get("action")
            if action_type in {"sync_file", "rebuild_file"}:
                job_id = await start_sync_job(
                    target_type="file",
                    target_id=str(action.get("file_id") or ""),
                    kb_id=str(action.get("kb_id") or ""),
                    use_llm=bool(action.get("use_llm") or use_llm),
                    force=action_type == "rebuild_file",
                )
                job = await db.get_job(job_id)
                result = {"success": True, "status": "running", "job_id": job_id, "job": job}
            elif action_type == "delete_wiki_file":
                result = await delete_by_file(str(action.get("file_id") or ""))
            elif action_type == "delete_wiki_kb":
                result = await delete_by_kb(str(action.get("kb_id") or plan.get("kb_id") or ""))
            else:
                result = {"success": False, "error": f"unknown action: {action_type}"}
            executed.append({"action": action, "result": result})
        except Exception as exc:
            executed.append({"action": action, "result": {"success": False, "error": str(exc)}})
        await asyncio.sleep(0)

    failed = [item for item in executed if not item.get("result", {}).get("success")]
    status = "failed" if failed else "executed"
    await db.update_admin_plan_status(str(plan.get("id") or ""), status, executed=executed)
    return {
        "success": not failed,
        "plan_id": plan.get("id", ""),
        "status": status,
        "executed_count": len(executed),
        "failed_count": len(failed),
        "executed": executed,
    }


async def delete_by_file(file_id: str) -> Dict[str, Any]:
    counts = await db.delete_wiki_by_file(file_id)
    return {"success": True, "scope": "file", "file_id": str(file_id), "deleted": counts}


async def delete_by_kb(kb_id: str) -> Dict[str, Any]:
    counts = await db.delete_wiki_by_kb(kb_id)
    return {"success": True, "scope": "kb", "kb_id": str(kb_id), "deleted": counts}


async def delete_by_time(
    before: Optional[datetime] = None,
    after: Optional[datetime] = None,
    kb_id: Optional[str] = None,
) -> Dict[str, Any]:
    counts = await db.delete_wiki_by_time(before=before, after=after, kb_id=kb_id)
    return {
        "success": True,
        "scope": "time",
        "kb_id": str(kb_id or ""),
        "before": before.isoformat() if before else "",
        "after": after.isoformat() if after else "",
        "deleted": counts,
    }


async def _resolve_kb_ids(client: HTKnowClient, kb_id: str, include_children: bool = False) -> List[str]:
    root = str(kb_id)
    if not include_children:
        return [root]
    tree = await client.get_kb_tree()
    found = _find_kb_node(tree, root)
    if not found:
        return [root]
    ids: List[str] = []
    _collect_kb_ids(found, ids)
    return ids or [root]


def _find_kb_node(nodes: List[Dict[str, Any]], kb_id: str) -> Optional[Dict[str, Any]]:
    for node in nodes:
        if str(node.get("id")) == str(kb_id):
            return node
        children = node.get("children") or []
        if isinstance(children, list):
            found = _find_kb_node(children, kb_id)
            if found:
                return found
    return None


def _collect_kb_ids(node: Dict[str, Any], out: List[str]) -> None:
    kid = node.get("id")
    if kid is not None:
        out.append(str(kid))
    children = node.get("children") or []
    if isinstance(children, list):
        for child in children:
            if isinstance(child, dict):
                _collect_kb_ids(child, out)


def _flatten_kb_tree(nodes: List[Dict[str, Any]], parent_path: str = "") -> List[Dict[str, Any]]:
    flattened: List[Dict[str, Any]] = []
    for node in nodes:
        if not isinstance(node, dict):
            continue
        item = dict(node)
        names = _node_names(item)
        display_name = names[0] if names else str(item.get("id") or "")
        item["_path"] = f"{parent_path}/{display_name}" if parent_path else display_name
        flattened.append(item)
        children = item.get("children") or []
        if isinstance(children, list):
            flattened.extend(_flatten_kb_tree(children, item["_path"]))
    return flattened


def _node_names(node: Dict[str, Any]) -> List[str]:
    keys = (
        "name",
        "title",
        "label",
        "kb_name",
        "knowledge_base_name",
        "knowledgeBaseName",
    )
    names: List[str] = []
    for key in keys:
        value = node.get(key)
        if value is not None:
            text = str(value).strip()
            if text and text not in names:
                names.append(text)
    return names


def _normalize_kb_text(text: str) -> str:
    return re.sub(r"[\s:：,，.。;；\"'“”‘’《》<>（）()【】\\[\\]_-]+", "", str(text or "").lower())


def _compact_file(file_info: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "file_id": str(file_info.get("file_id") or file_info.get("id") or ""),
        "kb_id": str(file_info.get("kb_id") or ""),
        "filename": str(file_info.get("filename") or file_info.get("name") or ""),
        "hash": str(file_info.get("hash") or ""),
        "status": str(file_info.get("status") or ""),
        "updated_at": str(file_info.get("updated_at") or ""),
    }


def _is_stale(file_info: Dict[str, Any], state: Dict[str, Any]) -> bool:
    source_hash = str(file_info.get("hash") or "")
    wiki_hash = str(state.get("file_hash") or "")
    if source_hash and wiki_hash and source_hash != wiki_hash:
        return True
    source_updated = str(file_info.get("updated_at") or "")
    wiki_source_updated = str(state.get("source_updated_at") or "")
    if source_updated and wiki_source_updated and source_updated != wiki_source_updated:
        return True
    return False
