"""
lambdagent.builtin_tools.knowledge_tools — Knowledge base pipeline

ChunkSplitter  λx. split(text, strategy, chunk_size)
OCRTool        λx. ocr(file_path) → extracted text
DocGen         λx. generate(source_md, format, output_path)
KBManager      λx. manage(action, kb_name, ...)
"""
from __future__ import annotations

import json
import math
import os
import re
import subprocess
import hashlib
from typing import Any, Dict, List, Optional


# ════════════════════════════════════════════════════════════
# A34: ChunkSplitter
# ════════════════════════════════════════════════════════════

class ChunkSplitter:
    """Split long documents into retrieval-friendly chunks."""

    @staticmethod
    def split(text: str, strategy: str = "paragraph", chunk_size: int = 500,
              overlap: int = 50) -> List[str]:
        """Split text into chunks.

        Strategies:
          paragraph — split on double newlines
          heading   — split on Markdown headings (# / ## / ###)
          fixed     — fixed size with overlap
          sentence  — split on sentence boundaries
        """
        if strategy == "paragraph":
            return ChunkSplitter._split_paragraph(text, chunk_size)
        elif strategy == "heading":
            return ChunkSplitter._split_heading(text)
        elif strategy == "sentence":
            return ChunkSplitter._split_sentence(text, chunk_size, overlap)
        else:  # fixed
            return ChunkSplitter._split_fixed(text, chunk_size, overlap)

    @staticmethod
    def _split_paragraph(text: str, max_size: int) -> List[str]:
        paragraphs = re.split(r'\n\s*\n', text.strip())
        chunks = []
        current = ""
        for p in paragraphs:
            p = p.strip()
            if not p:
                continue
            if len(current) + len(p) + 2 > max_size and current:
                chunks.append(current.strip())
                current = p
            else:
                current = current + "\n\n" + p if current else p
        if current.strip():
            chunks.append(current.strip())
        return chunks if chunks else [text.strip()]

    @staticmethod
    def _split_heading(text: str) -> List[str]:
        sections = re.split(r'(?=^#{1,3}\s)', text, flags=re.MULTILINE)
        chunks = [s.strip() for s in sections if s.strip()]
        return chunks if chunks else [text.strip()]

    @staticmethod
    def _split_fixed(text: str, size: int, overlap: int) -> List[str]:
        chunks = []
        start = 0
        while start < len(text):
            end = start + size
            chunk = text[start:end].strip()
            if chunk:
                chunks.append(chunk)
            start = end - overlap if overlap > 0 else end
        return chunks if chunks else [text.strip()]

    @staticmethod
    def _split_sentence(text: str, max_size: int, overlap: int) -> List[str]:
        sentences = re.split(r'(?<=[.!?。！？\n])\s+', text)
        chunks = []
        current = ""
        for s in sentences:
            if len(current) + len(s) + 1 > max_size and current:
                chunks.append(current.strip())
                # Keep overlap
                words = current.split()
                overlap_text = " ".join(words[-overlap//4:]) if overlap > 0 else ""
                current = overlap_text + " " + s if overlap_text else s
            else:
                current = current + " " + s if current else s
        if current.strip():
            chunks.append(current.strip())
        return chunks if chunks else [text.strip()]


def chunk_split(input_val: Any) -> str:
    """Tool function for ChunkSplitter."""
    params = _parse(input_val)
    text = params.get("text", "")
    if not text:
        file_path = params.get("file_path", "")
        if file_path and os.path.isfile(file_path):
            with open(file_path, "r", encoding="utf-8", errors="replace") as f:
                text = f.read()
        else:
            return "[ERROR] text or file_path required"

    strategy = params.get("strategy", "paragraph")
    chunk_size = params.get("chunk_size", 500)
    overlap = params.get("overlap", 50)
    chunks = ChunkSplitter.split(text, strategy, chunk_size, overlap)
    return json.dumps({
        "chunks": len(chunks),
        "strategy": strategy,
        "preview": [c[:100] + "..." if len(c) > 100 else c for c in chunks[:5]],
        "total_chars": sum(len(c) for c in chunks),
    }, ensure_ascii=False, indent=2)


# ════════════════════════════════════════════════════════════
# A35: OCRTool
# ════════════════════════════════════════════════════════════

def _detect_ocr_backend() -> str:
    """Detect available OCR backend."""
    # PaddleOCR
    try:
        import paddleocr
        return "paddle"
    except ImportError:
        pass
    # Tesseract
    try:
        result = subprocess.run(["tesseract", "--version"], capture_output=True, timeout=5)
        if result.returncode == 0:
            return "tesseract"
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    return ""


def ocr_extract(input_val: Any) -> str:
    """Extract text from image or scanned PDF via OCR."""
    params = _parse(input_val)
    file_path = params.get("file_path", "")
    if not file_path:
        return "[ERROR] file_path is required"
    file_path = os.path.expanduser(file_path)
    if not os.path.exists(file_path):
        return f"[ERROR] File not found: {file_path}"

    language = params.get("language", "chi_sim+eng")
    backend = _detect_ocr_backend()

    ext = os.path.splitext(file_path)[1].lower()

    if backend == "paddle":
        return _ocr_paddle(file_path, language)
    elif backend == "tesseract":
        return _ocr_tesseract(file_path, language)
    else:
        # Last resort: try tesseract via bash
        try:
            if ext in (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".gif"):
                result = subprocess.run(
                    ["tesseract", file_path, "stdout", "-l", language],
                    capture_output=True, text=True, timeout=30,
                )
                if result.returncode == 0:
                    return result.stdout.strip() or "[EMPTY] No text extracted"
        except Exception:
            pass
        return (
            "[ERROR] No OCR backend available. Install one of:\n"
            "  pip install paddleocr paddlepaddle\n"
            "  brew install tesseract (macOS) / apt install tesseract-ocr (Linux)"
        )


def _ocr_paddle(file_path: str, language: str) -> str:
    try:
        from paddleocr import PaddleOCR
        lang = "ch" if "chi" in language else "en"
        ocr = PaddleOCR(use_angle_cls=True, lang=lang, show_log=False)
        results = ocr.ocr(file_path, cls=True)
        lines = []
        if results:
            for page in results:
                if page:
                    for line in page:
                        if line and len(line) >= 2:
                            text = line[1][0] if isinstance(line[1], (list, tuple)) else str(line[1])
                            lines.append(text)
        return "\n".join(lines) if lines else "[EMPTY] No text extracted"
    except Exception as e:
        return f"[OCR_ERROR] PaddleOCR: {e}"


def _ocr_tesseract(file_path: str, language: str) -> str:
    try:
        result = subprocess.run(
            ["tesseract", file_path, "stdout", "-l", language],
            capture_output=True, text=True, timeout=60,
        )
        if result.returncode == 0:
            text = result.stdout.strip()
            return text if text else "[EMPTY] No text extracted"
        return f"[OCR_ERROR] tesseract exit {result.returncode}: {result.stderr[:200]}"
    except subprocess.TimeoutExpired:
        return "[TIMEOUT] OCR timed out after 60s"
    except Exception as e:
        return f"[OCR_ERROR] tesseract: {e}"


# ════════════════════════════════════════════════════════════
# A36: DocGen — Document generation
# ════════════════════════════════════════════════════════════

def doc_generate(input_val: Any) -> str:
    """Generate PDF/Word/HTML from Markdown source."""
    params = _parse(input_val)
    source = params.get("source", "")
    fmt = params.get("format", "html")
    output = params.get("output", "")

    if not source:
        return "[ERROR] source (markdown file path or text) is required"

    # Read source
    if os.path.isfile(source):
        with open(source, "r", encoding="utf-8") as f:
            md_text = f.read()
        if not output:
            base = os.path.splitext(source)[0]
            output = f"{base}.{fmt}"
    else:
        md_text = source
        if not output:
            output = f"output.{fmt}"

    output = os.path.expanduser(output)
    os.makedirs(os.path.dirname(output) or ".", exist_ok=True)

    if fmt == "html":
        return _gen_html(md_text, output)
    elif fmt == "pdf":
        return _gen_pdf(md_text, output)
    elif fmt in ("docx", "word"):
        return _gen_docx(md_text, output)
    else:
        return f"[ERROR] Unsupported format: {fmt}. Use html/pdf/docx"


def _gen_html(md_text: str, output: str) -> str:
    """Markdown → HTML."""
    # Try markdown lib
    try:
        import markdown
        html = markdown.markdown(md_text, extensions=["tables", "fenced_code"])
    except ImportError:
        # Minimal conversion
        html = md_text.replace("\n\n", "</p><p>").replace("\n", "<br>")
        html = f"<p>{html}</p>"

    full_html = f"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background: #f5f5f5; }}
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }}
</style>
</head><body>
{html}
</body></html>"""

    with open(output, "w", encoding="utf-8") as f:
        f.write(full_html)
    return f"[OK] HTML generated: {output} ({len(full_html)} bytes)"


_CJK_RE = re.compile(r"[　-〿㐀-䶿一-鿿豈-﫿"
                     r"＀-￯぀-ヿ]")


def _has_cjk(text: str) -> bool:
    """True if the text contains CJK (Chinese/Japanese/Korean) characters."""
    return bool(_CJK_RE.search(text))


# Common install dirs for pandoc / xelatex / fontconfig that are frequently
# MISSING from a service's PATH (e.g. a launchd/systemd daemon gets a minimal
# PATH like /usr/bin:/bin without /opt/homebrew/bin or /Library/TeX/texbin).
_EXTRA_BIN_DIRS = [
    "/opt/homebrew/bin",          # macOS Apple-silicon Homebrew (pandoc, fc-list)
    "/usr/local/bin",             # macOS Intel Homebrew / common
    "/opt/local/bin",             # MacPorts
    "/Library/TeX/texbin",        # MacTeX (xelatex)
    "/usr/bin", "/bin",
    "/usr/local/texlive/2024/bin/universal-darwin",
    "/usr/local/texlive/2023/bin/x86_64-linux",
]


def _find_binary(name: str) -> Optional[str]:
    """Locate an executable by name, falling back to common install dirs.

    shutil.which() respects PATH; when the daemon's PATH is minimal we also
    probe well-known locations so pandoc/xelatex/fc-list are found anyway.
    """
    import shutil
    found = shutil.which(name)
    if found:
        return found
    for d in _EXTRA_BIN_DIRS:
        cand = os.path.join(d, name)
        if os.path.isfile(cand) and os.access(cand, os.X_OK):
            return cand
    return None


def _augmented_env() -> dict:
    """Return os.environ with _EXTRA_BIN_DIRS prepended to PATH.

    pandoc shells out to its --pdf-engine (xelatex); even when pandoc is called
    by absolute path it must find xelatex on PATH, so we widen PATH for the
    child process. Critical when the server runs under launchd/systemd with a
    minimal PATH.
    """
    env = dict(os.environ)
    existing = env.get("PATH", "")
    parts = [d for d in _EXTRA_BIN_DIRS if os.path.isdir(d)]
    env["PATH"] = os.pathsep.join(parts + ([existing] if existing else []))
    return env


def _find_cjk_font() -> Optional[str]:
    """Find an installed CJK font family name usable by xelatex/xeCJK.

    Returns a font *family* name (e.g. "PingFang SC") or None if no CJK
    font is found.  Tries fontconfig first (Linux/macOS), then falls back to
    a hard-coded list of common platform fonts.
    """
    # Preferred families in priority order (covers macOS / Windows / Linux).
    preferred = [
        "PingFang SC", "Songti SC", "STSong", "STHeiti", "Heiti SC",
        "Hiragino Sans GB", "Microsoft YaHei", "SimSun", "SimHei",
        "Noto Sans CJK SC", "Noto Serif CJK SC", "Source Han Sans SC",
        "Source Han Serif SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei",
        "Arial Unicode MS",
    ]
    # Query fontconfig for the set of installed CJK family names.
    installed: set = set()
    fc_list = _find_binary("fc-list")
    try:
        if not fc_list:
            raise FileNotFoundError("fc-list not found")
        out = subprocess.run(
            [fc_list, ":lang=zh", "family"],
            capture_output=True, text=True, timeout=10, env=_augmented_env(),
        )
        if out.returncode == 0:
            for line in out.stdout.splitlines():
                # Each line may list several comma-separated aliases.
                for fam in line.split(","):
                    installed.add(fam.strip())
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        installed = set()

    if installed:
        for fam in preferred:
            if fam in installed:
                return fam
        # No preferred match — return any installed CJK family.
        for fam in sorted(installed):
            if fam:
                return fam

    # fontconfig unavailable: optimistically return the top preferred name.
    # On macOS PingFang SC ships with the OS; xelatex resolves it by name.
    return preferred[0]


def _gen_pdf(md_text: str, output: str) -> str:
    """Markdown → PDF."""
    needs_cjk = _has_cjk(md_text)

    # Strategy 1: weasyprint (handles CJK via system fonts automatically)
    try:
        import weasyprint
        _gen_html(md_text, output + ".tmp.html")
        weasyprint.HTML(filename=output + ".tmp.html").write_pdf(output)
        os.unlink(output + ".tmp.html")
        return f"[OK] PDF generated: {output}"
    except ImportError:
        pass
    except Exception as e:  # weasyprint present but failed — fall through
        try:
            os.unlink(output + ".tmp.html")
        except OSError:
            pass
        # don't return — try pandoc next

    # Strategy 2: pandoc via xelatex.
    #
    # CRITICAL for Chinese reports: the default xelatex roman font
    # (lmroman) has NO CJK glyphs, so a Chinese .md silently produces a
    # stream of "Missing character" warnings and NO output file.  We must
    # pass a CJK main font (-V CJKmainfont / -V mainfont) so xeCJK kicks in.
    # Locate pandoc/xelatex by absolute path — the server's PATH (launchd /
    # systemd) often lacks /opt/homebrew/bin and /Library/TeX/texbin, which
    # would otherwise make pandoc "disappear" and silently downgrade to HTML.
    pandoc_bin = _find_binary("pandoc")
    xelatex_bin = _find_binary("xelatex")
    pdf_env = _augmented_env()
    try:
        if not pandoc_bin:
            raise FileNotFoundError("pandoc not found on PATH or common dirs")
        import tempfile
        with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as f:
            f.write(md_text)
            tmp_md = f.name

        # Enable single-backslash math \(...\) / \[...\] so LaTeX inline math
        # written by LLMs (e.g. \tilde{P}, \delta(r-\alpha)) is parsed as math
        # instead of leaking raw control sequences that crash xelatex with
        # "Missing $ inserted".
        reader = "markdown+tex_math_single_backslash+tex_math_double_backslash"
        # Pass xelatex by absolute path too, so pandoc doesn't rely on PATH.
        engine_opt = [f"--pdf-engine={xelatex_bin}"] if xelatex_bin else ["--pdf-engine=xelatex"]
        cmd = [pandoc_bin, "-f", reader, tmp_md, "-o", output] + engine_opt
        if needs_cjk:
            font = _find_cjk_font()
            if font:
                # CJKmainfont drives CJK runs; mainfont keeps latin text in
                # the same family so mixed CN/EN lines render consistently.
                cmd += ["-V", f"CJKmainfont={font}", "-V", f"mainfont={font}"]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env=pdf_env)
        os.unlink(tmp_md)

        # xelatex can exit 0 yet emit no file when fonts are missing — verify.
        if result.returncode == 0 and os.path.isfile(output) and os.path.getsize(output) > 0:
            return f"[OK] PDF generated via pandoc: {output}"

        # Retry once WITHOUT the CJK font in case the chosen family was
        # rejected by xelatex (e.g. name mismatch on this platform).
        if needs_cjk and not (os.path.isfile(output) and os.path.getsize(output) > 0):
            for fallback_font in ("Songti SC", "STSong", "Noto Serif CJK SC", "SimSun"):
                with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as f:
                    f.write(md_text)
                    tmp_md = f.name
                retry = subprocess.run(
                    [pandoc_bin, "-f", reader, tmp_md, "-o", output] + engine_opt
                    + ["-V", f"CJKmainfont={fallback_font}", "-V", f"mainfont={fallback_font}"],
                    capture_output=True, text=True, timeout=120, env=pdf_env,
                )
                os.unlink(tmp_md)
                if retry.returncode == 0 and os.path.isfile(output) and os.path.getsize(output) > 0:
                    return f"[OK] PDF generated via pandoc (font={fallback_font}): {output}"

        err = (result.stderr or result.stdout or "")[:300]
        # fall through to HTML fallback after recording the error
        _pandoc_err = err
    except FileNotFoundError as e:
        _pandoc_err = str(e)

    # Strategy 3: generate HTML as fallback
    html_path = output.replace(".pdf", ".html")
    _gen_html(md_text, html_path)
    return (f"[FALLBACK] PDF generation failed ({_pandoc_err}). "
            f"HTML saved instead: {html_path}")


def _gen_docx(md_text: str, output: str) -> str:
    """Markdown → Word."""
    try:
        from docx import Document as DocxDocument
        doc = DocxDocument()
        for line in md_text.split("\n"):
            line = line.rstrip()
            if line.startswith("# "):
                doc.add_heading(line[2:], level=1)
            elif line.startswith("## "):
                doc.add_heading(line[3:], level=2)
            elif line.startswith("### "):
                doc.add_heading(line[4:], level=3)
            elif line.startswith("- "):
                doc.add_paragraph(line[2:], style="List Bullet")
            elif line.strip():
                doc.add_paragraph(line)
        doc.save(output)
        return f"[OK] Word document generated: {output}"
    except ImportError:
        return "[ERROR] python-docx not installed. Run: pip install python-docx"
    except Exception as e:
        return f"[ERROR] docx generation failed: {e}"


# ════════════════════════════════════════════════════════════
# A37: Knowledge Base Manager
# ════════════════════════════════════════════════════════════

KB_BASE = os.path.expanduser("~/.lambdagent/knowledge_bases")


def kb_manage(input_val: Any) -> str:
    """Knowledge base management tool."""
    params = _parse(input_val)
    action = params.get("action", "list")

    if action == "create":
        return _kb_create(params.get("name", ""), params.get("description", ""))
    elif action == "add":
        return _kb_add(params.get("name", ""), params.get("file_path", ""),
                       params.get("pattern", ""), params.get("chunk", True))
    elif action == "search":
        return _kb_search(params.get("name", ""), params.get("query", ""),
                          params.get("top_k", 5))
    elif action == "list":
        return _kb_list()
    elif action == "delete":
        return _kb_delete(params.get("name", ""))
    elif action == "info":
        return _kb_info(params.get("name", ""))
    else:
        return f"[ERROR] Unknown action: {action}. Use: create/add/search/list/delete/info"


def _kb_create(name: str, description: str) -> str:
    if not name:
        return "[ERROR] name is required"
    kb_dir = os.path.join(KB_BASE, name)
    if os.path.exists(kb_dir):
        return f"[ERROR] Knowledge base '{name}' already exists"
    os.makedirs(kb_dir, exist_ok=True)
    meta = {"name": name, "description": description, "documents": [], "total_chunks": 0}
    with open(os.path.join(kb_dir, "meta.json"), "w", encoding="utf-8") as f:
        json.dump(meta, f, ensure_ascii=False, indent=2)
    return f"[OK] Knowledge base '{name}' created at {kb_dir}"


def _kb_add(name: str, file_path: str, pattern: str, chunk: bool) -> str:
    if not name:
        return "[ERROR] name is required"
    kb_dir = os.path.join(KB_BASE, name)
    if not os.path.exists(kb_dir):
        return f"[ERROR] Knowledge base '{name}' not found"

    # Load meta
    meta_path = os.path.join(kb_dir, "meta.json")
    with open(meta_path, "r") as f:
        meta = json.load(f)

    files_to_add = []
    if file_path:
        file_path = os.path.expanduser(file_path)
        if os.path.isdir(file_path):
            import glob
            pat = pattern or "**/*.*"
            files_to_add = [f for f in glob.glob(os.path.join(file_path, pat), recursive=True)
                           if os.path.isfile(f)]
        elif os.path.isfile(file_path):
            files_to_add = [file_path]
        else:
            return f"[ERROR] Path not found: {file_path}"
    else:
        return "[ERROR] file_path is required"

    # Load or create store
    import sys
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
    from lambdagent.rag import SimpleVectorStore

    store_path = os.path.join(kb_dir, "store.json")
    store = SimpleVectorStore()

    # Load existing store
    if os.path.exists(store_path):
        with open(store_path, "r") as f:
            store_data = json.load(f)
        from lambdagent.rag import Document
        for d in store_data.get("documents", []):
            store.documents.append(Document(content=d["content"], metadata=d.get("metadata", {}), doc_id=d.get("doc_id", "")))

    added = 0
    for fp in files_to_add:
        try:
            with open(fp, "r", encoding="utf-8", errors="replace") as f:
                text = f.read()
        except Exception:
            continue

        if chunk and len(text) > 500:
            chunks = ChunkSplitter.split(text, "paragraph", 500, 50)
        else:
            chunks = [text]

        for i, c in enumerate(chunks):
            meta_info = {"source": fp, "chunk_index": i}
            store.add(c, meta_info)
            added += 1

        meta["documents"].append({"path": fp, "chunks": len(chunks)})

    meta["total_chunks"] = len(store.documents)

    # Save store
    store_data = {
        "documents": [
            {"content": d.content, "metadata": d.metadata, "doc_id": d.doc_id}
            for d in store.documents
        ]
    }
    with open(store_path, "w", encoding="utf-8") as f:
        json.dump(store_data, f, ensure_ascii=False)

    with open(meta_path, "w", encoding="utf-8") as f:
        json.dump(meta, f, ensure_ascii=False, indent=2)

    return f"[OK] Added {added} chunks from {len(files_to_add)} file(s) to '{name}'. Total: {meta['total_chunks']} chunks"


def _kb_search(name: str, query: str, top_k: int) -> str:
    if not name or not query:
        return "[ERROR] name and query are required"
    kb_dir = os.path.join(KB_BASE, name)
    store_path = os.path.join(kb_dir, "store.json")
    if not os.path.exists(store_path):
        return f"[ERROR] Knowledge base '{name}' not found or empty"

    from lambdagent.rag import SimpleVectorStore, Document

    store = SimpleVectorStore()
    with open(store_path, "r") as f:
        store_data = json.load(f)
    for d in store_data.get("documents", []):
        store.documents.append(Document(content=d["content"], metadata=d.get("metadata", {}), doc_id=d.get("doc_id", "")))

    results = store.search(query, top_k=top_k)
    if not results:
        return f"[NO_MATCH] No results for '{query}' in '{name}'"

    lines = []
    for r in results:
        source = r.document.metadata.get("source", "?")
        lines.append(f"[Score: {r.score:.3f}] (from: {os.path.basename(source)})")
        lines.append(r.document.content[:300])
        lines.append("")
    return "\n".join(lines)


def _kb_list() -> str:
    if not os.path.exists(KB_BASE):
        return "[EMPTY] No knowledge bases found"
    kbs = []
    for name in sorted(os.listdir(KB_BASE)):
        meta_path = os.path.join(KB_BASE, name, "meta.json")
        if os.path.exists(meta_path):
            with open(meta_path) as f:
                meta = json.load(f)
            kbs.append(f"  {name}: {meta.get('total_chunks', 0)} chunks, {len(meta.get('documents', []))} files — {meta.get('description', '')}")
    if not kbs:
        return "[EMPTY] No knowledge bases found"
    return "Knowledge bases:\n" + "\n".join(kbs)


def _kb_delete(name: str) -> str:
    if not name:
        return "[ERROR] name is required"
    kb_dir = os.path.join(KB_BASE, name)
    if not os.path.exists(kb_dir):
        return f"[ERROR] Knowledge base '{name}' not found"
    import shutil
    shutil.rmtree(kb_dir)
    return f"[OK] Knowledge base '{name}' deleted"


def _kb_info(name: str) -> str:
    if not name:
        return "[ERROR] name is required"
    meta_path = os.path.join(KB_BASE, name, "meta.json")
    if not os.path.exists(meta_path):
        return f"[ERROR] Knowledge base '{name}' not found"
    with open(meta_path) as f:
        meta = json.load(f)
    lines = [
        f"Knowledge Base: {name}",
        f"  Description: {meta.get('description', '')}",
        f"  Total chunks: {meta.get('total_chunks', 0)}",
        f"  Documents ({len(meta.get('documents', []))}):",
    ]
    for doc in meta.get("documents", [])[:10]:
        lines.append(f"    - {doc.get('path', '?')} ({doc.get('chunks', 0)} chunks)")
    return "\n".join(lines)


# ════════════════════════════════════════════════════════════
# Shared
# ════════════════════════════════════════════════════════════

def _parse(input_val: Any) -> dict:
    if isinstance(input_val, dict):
        return input_val
    if isinstance(input_val, str):
        try:
            return json.loads(input_val)
        except (json.JSONDecodeError, ValueError):
            return {"text": input_val} if len(input_val) > 50 else {"query": input_val}
    return {}
