"""Personal Wiki API — unified access to all knowledge bases.

Unified view across all KBs: articles, people, categories, search, recent changes.
The wiki is maintained by the agent — user reads, agent writes.
"""

from __future__ import annotations

import logging
import re
from datetime import UTC, datetime
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

log = logging.getLogger(__name__)

router = APIRouter(prefix="/wiki", tags=["wiki"])


def _to_logical(vault, physical_path: Path) -> str:
    """Convert a physical path to a logical vault-relative path."""
    if hasattr(vault, "paths") and vault.paths:
        return vault.paths.to_logical(physical_path)
    vault_dir = vault.vault_dir if hasattr(vault, "vault_dir") else vault._vault_dir
    return str(physical_path.relative_to(vault_dir)).replace("\\", "/")

# In-memory article cache (avoids re-reading 700+ files on every request)
_article_cache: dict = {"articles": [], "built_at": 0.0, "ttl": 60.0, "rebuilding": False}


def _try_open_obsidian(path: Path) -> bool:
    """If *path* lives inside an Obsidian vault, open it via the
    ``obsidian://`` URI scheme. Works on Windows, macOS and Linux because
    the Obsidian desktop app registers the scheme on every platform it
    ships for. Returns True on success, False otherwise."""
    import subprocess
    import sys

    check = path.parent
    for _ in range(10):
        if (check / ".obsidian").is_dir():
            try:
                from urllib.parse import quote

                rel = path.relative_to(check)
                rel_posix = str(rel).replace("\\", "/")
                obsidian_uri = (
                    f"obsidian://open?vault={quote(check.name)}&file={quote(rel_posix)}"
                )
                if sys.platform == "win32":
                    import os

                    os.startfile(obsidian_uri)  # type: ignore[attr-defined]
                elif sys.platform == "darwin":
                    subprocess.Popen(["open", obsidian_uri])
                else:
                    subprocess.Popen(["xdg-open", obsidian_uri])
                return True
            except Exception:
                return False
        if check.parent == check:
            break
        check = check.parent
    return False


def _build_article_list(vault) -> list[dict]:
    """Read all wiki files and build the article list. CPU-bound, runs in thread."""
    import os

    articles = []
    for f in _list_wiki_files(vault):
        result = _read_article_safe(vault, f)
        if not result:
            continue
        rel, meta, content = result
        content_text = content.strip()
        excerpt = content_text[:200].split("\n\n")[0] if content_text else ""
        raw_links = re.findall(r"\[\[([^\]]+)\]\]", content_text)
        links = [lnk.split("|")[0].strip() for lnk in raw_links]

        try:
            mtime = os.path.getmtime(f)
        except OSError:
            mtime = 0.0

        articles.append({
            "slug": meta.get("slug") or re.sub(r"[^a-z0-9]+", "-", f.stem.lower()).strip("-"),
            "title": meta.get("title", f.stem),
            "category": meta.get("category", ""),
            "taxonomy_path": meta.get("taxonomy_path", ""),
            "entity_type": meta.get("entity_type", ""),
            "provenance": meta.get("provenance", ""),
            "status": meta.get("status", ""),
            "excerpt": excerpt,
            "links": links[:10],
            "sources": meta.get("sources", []),
            "path": rel,
            "kb": _extract_kb_slug(rel),
            "mtime": mtime,
        })

    articles.sort(key=lambda a: a["title"])
    return articles


def _get_cached_articles(vault) -> list[dict]:
    """Return cached article list. Stale cache triggers background rebuild — never blocks."""
    import threading  # noqa: PLC0415
    import time  # noqa: PLC0415

    now = time.time()
    if _article_cache["articles"] and (now - _article_cache["built_at"]) < _article_cache["ttl"]:
        return _article_cache["articles"]

    # Stale — if we have old data, return it and rebuild in background
    if _article_cache["articles"] and not _article_cache["rebuilding"]:
        _article_cache["rebuilding"] = True

        def _rebuild():
            try:
                articles = _build_article_list(vault)
                _article_cache["articles"] = articles
                _article_cache["built_at"] = time.time()
            finally:
                _article_cache["rebuilding"] = False

        threading.Thread(target=_rebuild, daemon=True).start()
        return _article_cache["articles"]  # return stale while rebuilding

    # No cached data — rebuild synchronously so the caller gets fresh results
    articles = _build_article_list(vault)
    _article_cache["articles"] = articles
    _article_cache["built_at"] = time.time()
    return articles


def warm_article_cache(vault) -> None:
    """Pre-warm the article cache at server startup. Call from lifespan."""
    import threading
    import time

    def _warm():
        articles = _build_article_list(vault)
        _article_cache["articles"] = articles
        _article_cache["built_at"] = time.time()
        log.debug("Wiki article cache warmed: %d articles", len(articles))

    threading.Thread(target=_warm, daemon=True).start()


def invalidate_article_cache():
    """Call after any write operation that changes wiki articles.

    Clears cached articles so the next request rebuilds synchronously
    instead of returning stale data.
    """
    _article_cache["built_at"] = 0.0
    _article_cache["articles"] = []


def _classify_and_enrich_articles(all_articles: list[dict], flat_nodes: list) -> list[dict]:
    """Shared helper: auto-classify every article that lacks a taxonomy_path,
    then project the subset of fields the tree + article endpoints need."""
    from agntspace.kb.taxonomy import classify_article

    out: list[dict] = []
    for a in all_articles:
        tp = a.get("taxonomy_path", "")
        if not tp:
            tp = classify_article(
                entity_type=a.get("entity_type", ""),
                category=a.get("category", ""),
                title=a.get("title", ""),
                flat_nodes=flat_nodes,
            )
        out.append({
            "slug": a["slug"],
            "title": a["title"],
            "taxonomy_path": tp,
            "category": a.get("category", ""),
            "entity_type": a.get("entity_type", ""),
            "provenance": a.get("provenance", ""),
            "kb": a.get("kb", ""),
        })
    return out


@router.get("/taxonomy")
async def get_taxonomy(request: Request, lite: bool = False) -> dict:
    """Return the knowledge taxonomy tree annotated with article counts.

    Query params:
        lite: when true, omit the inline ``articles`` list per node. The
            response still carries ``letter_counts`` so the client can
            render letter folders without fetching article payloads. The
            client is expected to lazy-fetch articles via
            ``/api/wiki/taxonomy/articles`` on demand. Cuts the payload
            dramatically at large article counts.

    Lite is the recommended mode for the UI tree at scale (10k+ articles);
    full mode is preserved for backward compatibility with any caller
    that still reads ``node.articles`` directly.
    """
    from agntspace.kb.taxonomy import (
        build_taxonomy_tree_with_counts,
        flatten_taxonomy,
        load_taxonomy,
    )

    skills_dir: Path | None = getattr(request.app.state, "skills_dir", None)
    if not skills_dir:
        return {"tree": [], "flat": []}

    tree = load_taxonomy(skills_dir)
    flat_nodes = flatten_taxonomy(tree)

    vault = request.app.state.vault
    all_articles = _get_cached_articles(vault)

    # If cache is still warming up, build article list synchronously
    # so taxonomy tree always has correct article counts
    if not all_articles:
        all_articles = _build_article_list(vault)

    articles = _classify_and_enrich_articles(all_articles, flat_nodes)
    annotated_tree = build_taxonomy_tree_with_counts(tree, articles, lite=lite)
    return {"tree": annotated_tree, "lite": lite}


@router.get("/taxonomy/articles")
async def get_taxonomy_articles(
    request: Request,
    path: str,
    letter: str | None = None,
    offset: int = 0,
    limit: int = 200,
) -> dict:
    """Paginated article list for a single taxonomy path.

    Matches ``article.taxonomy_path == path`` EXACTLY. For descendant-
    inclusive filtering, the existing ``/api/wiki/articles?taxonomy_path=``
    endpoint is the right tool.

    Query params:
        path: exact taxonomy_path. ``"unclassified"`` matches articles
            with no taxonomy_path AND no category.
        letter: optional first-letter bucket (``"A"``-``"Z"`` or ``"#"``
            for non-letters). Mirror of the ``letter_counts`` keys on the
            taxonomy tree response.
        offset: pagination offset, 0-based.
        limit: max rows. Hard cap at 1000.

    Pairs with ``/taxonomy?lite=true`` — the client renders the letter
    folders from ``letter_counts`` and fetches each bucket's articles
    from this endpoint on demand.
    """
    from agntspace.kb.taxonomy import (
        articles_for_path,
        flatten_taxonomy,
        load_taxonomy,
    )

    skills_dir: Path | None = getattr(request.app.state, "skills_dir", None)
    if not skills_dir:
        return {"articles": [], "total": 0, "offset": offset, "limit": limit, "has_more": False}

    vault = request.app.state.vault
    all_articles = _get_cached_articles(vault)
    if not all_articles:
        all_articles = _build_article_list(vault)

    tree = load_taxonomy(skills_dir)
    flat_nodes = flatten_taxonomy(tree)
    articles = _classify_and_enrich_articles(all_articles, flat_nodes)

    return articles_for_path(
        articles,
        path=path,
        letter=letter,
        offset=offset,
        limit=limit,
    )


@router.get("/taxonomy/slugs")
async def get_taxonomy_slugs(
    request: Request, path: str,
) -> dict:
    """Return every article slug under a taxonomy path (node + descendants).

    Replaces the client-side ``_collectTaxonomySlugs`` helper that used to
    walk the inline ``articles`` list of the full-mode tree. In lite mode
    the client no longer has that list, so this endpoint owns the
    prefix-match walk.

    Returns ``{slugs: [...], count: N}``.
    """
    from agntspace.kb.taxonomy import (
        flatten_taxonomy,
        load_taxonomy,
    )

    skills_dir: Path | None = getattr(request.app.state, "skills_dir", None)
    if not skills_dir:
        return {"slugs": [], "count": 0}

    vault = request.app.state.vault
    all_articles = _get_cached_articles(vault)
    if not all_articles:
        all_articles = _build_article_list(vault)

    tree = load_taxonomy(skills_dir)
    flat_nodes = flatten_taxonomy(tree)
    articles = _classify_and_enrich_articles(all_articles, flat_nodes)

    slugs: list[str] = []
    path = path.strip()
    for art in articles:
        tp = art.get("taxonomy_path") or art.get("category") or ""
        if path == "unclassified":
            if tp == "":
                slugs.append(art.get("slug", ""))
        elif tp == path or tp.startswith(path + "/"):
            slugs.append(art.get("slug", ""))

    return {"slugs": [s for s in slugs if s], "count": len([s for s in slugs if s])}


@router.get("/articles")
async def list_articles(
    request: Request,
    category: str | None = None,
    taxonomy_path: str | None = None,
    sort: str = "title",
    limit: int = 500,
) -> list[dict]:
    """List all wiki articles across all KBs.

    Filter by category (legacy) or taxonomy_path (hierarchical).
    taxonomy_path matches prefix — "science-technology" includes all children.
    Auto-classifies articles that lack taxonomy_path in frontmatter.
    """
    from agntspace.kb.taxonomy import classify_article, flatten_taxonomy, load_taxonomy

    vault = request.app.state.vault
    all_articles = _get_cached_articles(vault)

    # Apply taxonomy auto-classification if filtering by taxonomy
    flat_nodes: list | None = None
    if taxonomy_path:
        skills_dir: Path | None = getattr(request.app.state, "skills_dir", None)
        if skills_dir:
            tree = load_taxonomy(skills_dir)
            flat_nodes = flatten_taxonomy(tree)

    # Filter
    filtered = []
    for a in all_articles:
        tp = a.get("taxonomy_path", "")
        if not tp and flat_nodes:
            tp = classify_article(
                entity_type=a.get("entity_type", ""),
                category=a.get("category", ""),
                title=a.get("title", ""),
                flat_nodes=flat_nodes,
            )
        if category and a.get("category", "") != category:
            continue
        if taxonomy_path and not (tp == taxonomy_path or tp.startswith(taxonomy_path + "/")):
            continue
        filtered.append(a)

    # Sort
    if sort == "recent":
        filtered.sort(key=lambda a: a.get("mtime", 0), reverse=True)
    elif sort == "links":
        filtered.sort(key=lambda a: len(a.get("links", [])), reverse=True)
    # default: already sorted by title from cache

    return filtered[:limit]


@router.get("/article/{slug}")
async def get_article(request: Request, slug: str) -> dict:
    """Get a single wiki article by slug. Searches across all KBs.

    The body runs in a worker thread (``asyncio.to_thread``) because every
    step underneath is synchronous filesystem I/O — disk reads, frontmatter
    parses, library globs, bibliography lookups. Without the thread hop,
    one slow article (cache miss → kb_service fallback that re-globs the
    whole vault) blocks the asyncio event loop for 5-10 seconds, starving
    every concurrent request behind it. Symptom in the UI: clicking
    article B while article A is still loading freezes the WHOLE wiki page.
    """
    import asyncio  # noqa: PLC0415

    return await asyncio.to_thread(_get_article_sync, request, slug)


def _get_article_sync(request: Request, slug: str) -> dict:
    """Synchronous body of get_article. See get_article for the threading
    rationale. All filesystem I/O lives here."""
    vault = request.app.state.vault

    # Find article by slug: check article cache first. The cache is built
    # from frontmatter ``slug`` (or kebab-of-stem fallback) for every wiki
    # file in the vault and is the canonical index. On cache miss we 404
    # directly — both previous fallbacks (wiki-route wide-glob AND
    # ``kb_svc._find_article_by_slug``) globbed every wiki file and read
    # frontmatter from each, costing 5-15 s per miss on large vaults AND
    # blocking every concurrent request behind them.
    #
    # Trade-off: a freshly created article isn't queryable until the
    # cache rebuilds (60 s TTL with stale-while-revalidate). Acceptable;
    # the user can hard-refresh or wait. The previous "always glob on
    # miss" behaviour was effectively a self-DoS at vault scale.
    articles = _get_cached_articles(vault)
    cached = next((a for a in articles if a["slug"] == slug), None)
    if not cached:
        raise HTTPException(status_code=404, detail=f"Article not found: {slug}")

    f = vault.vault_dir / cached["path"]
    result = _read_article_safe(vault, f)
    if not result:
        # File listed in cache but no longer readable on disk (deleted
        # since the cache was built). Treat as 404 — caller should retry
        # after the cache TTL expires.
        raise HTTPException(status_code=404, detail=f"Article not readable: {slug}")
    rel, meta, content = result

    raw_links = re.findall(r"\[\[([^\]]+)\]\]", content)
    links = [lnk.split("|")[0].strip() for lnk in raw_links]

    # Resolve display-name links to canonical wiki slugs.
    # Build a case-insensitive title→slug map from the article cache.
    link_slugs: dict[str, str] = {}
    if links:
        _title_to_slug: dict[str, str] = {}
        for a in articles:
            _title_to_slug[a["title"].lower()] = a["slug"]
            # Also map slug-derived form (lowercase, spaces→hyphens)
            derived = re.sub(r"[^a-z0-9]+", "-", a["title"].lower()).strip("-")
            _title_to_slug[derived] = a["slug"]
        for display_name in links:
            key = display_name.lower()
            resolved = _title_to_slug.get(key)
            if not resolved:
                # Try kebab-case form
                derived_key = re.sub(r"[^a-z0-9]+", "-", key).strip("-")
                resolved = _title_to_slug.get(derived_key)
            link_slugs[display_name] = resolved or re.sub(r"[^a-z0-9]+", "-", key).strip("-")

    source_file = meta.get("source_file", "")
    library_path = meta.get("library_path", "")
    kb_slug = _extract_kb_slug(rel)

    # Derive source_url — web URL from frontmatter, or local library path
    source_url = meta.get("source_url", "")
    image_url = ""
    # Local file path for vault/library sources
    local_source = ""
    if source_file:
        if library_path:
            clean = library_path.replace("../../", "")
            local_source = f"knowledge/{kb_slug}/{clean}" if not clean.startswith("knowledge/") else clean
        else:
            try:
                found = vault.list_files(f"knowledge/{kb_slug}/library", f"**/{source_file}")
                if found:
                    local_source = _kb_relative_path(found[0], vault)
            except Exception:
                pass

        file_path = local_source or source_url
        if file_path and re.search(r"\.(png|jpg|jpeg|gif|webp|svg)$", source_file, re.I):
            image_url = file_path

    # Scan content for inline image references
    content_images: list[str] = []
    for img_match in re.finditer(r"!\[[^\]]*\]\(([^)]+)\)", content):
        img_path = img_match.group(1)
        if img_path.startswith("../../"):
            img_path = f"knowledge/{kb_slug}/{img_path[6:]}"
        elif img_path.startswith("library/"):
            img_path = f"knowledge/{kb_slug}/{img_path}"
        if re.search(r"\.(png|jpg|jpeg|gif|webp|svg)$", img_path, re.I):
            content_images.append(img_path)

    if not image_url and content_images:
        image_url = content_images[0]

    # Resolve vault paths for all source files
    source_paths = _resolve_source_paths(request, meta.get("sources", []), kb_slug)

    # Build structured references from bibliography (proper data, no regex needed on client)
    _FAKE_DOMAINS = {"example.com", "localhost", "placeholder", "fake.com", "test.test"}

    def _is_fake(url: str) -> bool:
        return any(d in url.lower() for d in _FAKE_DOMAINS) if url else False

    references: list[dict] = []
    bib_svc = getattr(request.app.state, "bibliography_service", None)
    source_list = meta.get("sources", [])
    if bib_svc and source_list:
        for fname in source_list:
            bib = bib_svc.find_by_source(fname, kb_slug)
            ref: dict = {"source_file": fname}
            if bib:
                ref["title"] = bib.get("title", fname)
                ref["authors"] = bib.get("authors", [])
                ref["date"] = bib.get("date", "")
                ref["publisher"] = bib.get("publisher", "") if not _is_fake(bib.get("publisher", "")) else ""
                url = bib.get("url", "")
                ref["url"] = url if not _is_fake(url) else ""
            else:
                ref["title"] = fname
                ref["authors"] = []
                ref["date"] = ""
                ref["publisher"] = ""
                ref["url"] = ""
            # Resolve local vault path for the source file
            ref["local_path"] = ""
            vault_path = source_paths.get(fname, "")
            if vault_path:
                m = vault_path.replace("\\", "/")
                idx = m.find("agntspace-knowledge/")
                if idx >= 0:
                    ref["local_path"] = "knowledge/" + m[idx + len("agntspace-knowledge/"):]
            ref["is_local"] = bool(not ref["url"])
            references.append(ref)

    return {
        "slug": slug,
        "title": meta.get("title", slug.replace("-", " ").title()),
        "category": meta.get("category", ""),
        "taxonomy_path": meta.get("taxonomy_path", ""),
        "entity_type": meta.get("entity_type", ""),
        "provenance": meta.get("provenance", ""),
        "content": content,
        "links": links,
        "link_slugs": link_slugs,
        "related": meta.get("related", []),
        "sources": meta.get("sources", []),
        "source_paths": source_paths,
        "references": references,
        "source_file": source_file,
        "library_path": library_path,
        "vault_source": _resolve_vault_source(request, meta, kb_slug),
        "created_reason": meta.get("created_reason", ""),
        "source_url": source_url,
        "image_url": image_url,
        "images": content_images,
        "path": rel,
        "kb": kb_slug,
        "date_created": meta.get("date_created", meta.get("date", "")),
        "date_updated": meta.get("date_updated", ""),
        "author": meta.get("author", ""),
        "agent": meta.get("agent", ""),
        # Three-language architecture v1.1 audit fields (source_summary pages)
        "content_language": meta.get("content_language", ""),
        "output_language": meta.get("output_language", ""),
        "language_policy": meta.get("language_policy", ""),
        "language_policy_source": meta.get("language_policy_source", ""),
        "language_adherence": meta.get("language_adherence", ""),
        "secondary_output_language": meta.get("secondary_output_language", ""),
        # Phase 4.5 — extractor provenance (source_summary pages). Empty
        # on legacy pre-4.5 sources; the UI uses presence to decide
        # whether to offer a "re-ingest with {tier}" button.
        "type_name": meta.get("type_name", ""),
        "extractor_id": meta.get("extractor_id", ""),
        "extractor_version": meta.get("extractor_version", ""),
    }


@router.get("/categories")
async def list_categories(request: Request) -> list[dict]:
    """List all categories with article counts."""
    vault = request.app.state.vault
    categories: dict[str, int] = {}

    for f in _list_wiki_files(vault):
        result = _read_article_safe(vault, f)
        if not result:
            continue
        _rel, meta, _content = result
        cat = meta.get("category", "uncategorized")
        categories[cat] = categories.get(cat, 0) + 1

    return [{"name": name, "count": count} for name, count in sorted(categories.items())]


@router.get("/people")
async def list_people(request: Request) -> list[dict]:
    """List all person entities in the wiki (articles with entity_type: person)."""
    vault = request.app.state.vault
    articles = _get_cached_articles(vault)
    people = []
    for a in articles:
        if a.get("entity_type") == "person":
            people.append({
                "name": a["title"],
                "slug": a["slug"],
                "excerpt": a.get("excerpt", ""),
                "kb": a.get("kb", ""),
                "links": len(a.get("links", [])),
            })
    return sorted(people, key=lambda p: p["name"])


@router.get("/search")
async def search_wiki(request: Request, q: str, limit: int = 20) -> list[dict]:
    """Full-text search across all wiki articles."""
    vault = request.app.state.vault
    query_lower = q.lower()
    results = []

    # Use cached article list instead of re-reading all files
    articles = _get_cached_articles(vault)
    for a in articles:
        title = a.get("title", "")
        excerpt = a.get("excerpt", "")

        score = 0
        if query_lower in title.lower():
            score += 10

        # Check excerpt for quick content match
        if query_lower in excerpt.lower():
            score += 3

        if score == 0:
            # Fall back to full file read only if no title/excerpt match
            f = vault.vault_dir / a["path"]
            file_result = _read_article_safe(vault, f)
            if not file_result:
                continue
            _rel, _meta, content = file_result
            occurrences = content.lower().count(query_lower)
            score += min(occurrences, 5)
            if score == 0:
                continue
        else:
            # Read full content for snippet extraction
            f = vault.vault_dir / a["path"]
            file_result = _read_article_safe(vault, f)
            if file_result:
                _rel, _meta, content = file_result
            else:
                content = excerpt

        content_lower = content.lower()
        idx = content_lower.find(query_lower)
        if idx >= 0:
            start = max(0, idx - 80)
            end = min(len(content), idx + 120)
            snippet = content[start:end].strip()
            if start > 0:
                snippet = "..." + snippet
            if end < len(content):
                snippet += "..."
        else:
            snippet = excerpt

        results.append({
            "slug": a["slug"],
            "title": title,
            "category": a.get("category", ""),
            "snippet": snippet,
            "score": score,
            "kb": a.get("kb", ""),
        })

    results.sort(key=lambda r: r["score"], reverse=True)
    return results[:limit]


@router.get("/recent")
async def recent_changes(request: Request, limit: int = 20) -> list[dict]:
    """Recently modified wiki articles."""
    vault = request.app.state.vault
    articles = _get_cached_articles(vault)

    result = [
        {
            "slug": a["slug"],
            "title": a["title"],
            "modified": datetime.fromtimestamp(a.get("mtime", 0), tz=UTC).isoformat(),
            "kb": a.get("kb", ""),
        }
        for a in articles if a.get("mtime", 0) > 0 and a.get("entity_type") != "source_summary"
    ]
    result.sort(key=lambda a: a["modified"], reverse=True)
    return result[:limit]


@router.get("/popular")
async def popular_articles(request: Request, limit: int = 10) -> list[dict]:
    """Most linked articles — articles that appear in the most [[links]] across the wiki."""
    vault = request.app.state.vault
    articles = _get_cached_articles(vault)
    link_counts: dict[str, int] = {}
    slug_titles: dict[str, str] = {}

    # Slugs to exclude from ranking (index/catalog files, not real articles)
    _META_SLUGS = {"sources", "links", "index", "concepts", "catalog", "decisions"}

    for a in articles:
        slug = a["slug"]
        # Skip source summary articles and meta-articles from being counted as sources of links
        entity_type = a.get("entity_type", "")
        if entity_type == "source_summary" or slug in _META_SLUGS:
            continue
        slug_titles[slug] = a["title"]
        for link in a.get("links", []):
            link_slug = link.split("/")[-1].lower().replace(" ", "-")
            # Don't count references to meta-articles
            if link_slug not in _META_SLUGS:
                link_counts[link_slug] = link_counts.get(link_slug, 0) + 1

    ranked = sorted(link_counts.items(), key=lambda x: x[1], reverse=True)
    return [
        {"slug": slug, "title": slug_titles.get(slug, slug.replace("-", " ").title()), "link_count": count}
        for slug, count in ranked[:limit]
        if slug not in _META_SLUGS
    ]


@router.get("/featured")
async def featured_article(request: Request) -> dict:
    """Featured article — the most recently compiled article with the most links."""

    vault = request.app.state.vault
    articles = _get_cached_articles(vault)

    best = None
    best_score = -1.0
    for a in articles:
        link_count = len(a.get("links", []))
        excerpt_len = len(a.get("excerpt", ""))
        score = link_count + excerpt_len / 100
        if score > best_score and excerpt_len > 50:
            best_score = score
            best = {
                "slug": a["slug"],
                "title": a["title"],
                "category": a.get("category", ""),
                "excerpt": a.get("excerpt", ""),
                "link_count": link_count,
                "kb": a.get("kb", ""),
            }

    return {"article": best}


@router.get("/stats")
async def wiki_stats(request: Request) -> dict:
    """Overall wiki statistics."""
    kb_service = request.app.state.kb_service

    kbs = kb_service.list_kbs()
    total_articles = sum(kb.get("articles", 0) for kb in kbs)
    total_sources = sum(kb.get("sources", 0) for kb in kbs)

    return {
        "knowledge_bases": len(kbs),
        "total_articles": total_articles,
        "total_sources": total_sources,
        "kbs": kbs,
    }


@router.get("/ingested")
async def recent_ingestions(request: Request, limit: int = 15) -> list[dict]:
    """Recently ingested sources — shows what the agent has processed."""
    vault = request.app.state.vault
    items: list[dict] = []

    try:
        files = vault.list_files("knowledge", "**/wiki/sources/*.md")
        files = [f for f in files if "/.archives/" not in str(f).replace("\\", "/")]
    except Exception:
        log.exception("Failed to list ingested sources")
        return []

    for f in files:
        if f.name.startswith(("_", ".")):
            continue
        result = _read_article_safe(vault, f)
        if not result:
            continue
        rel, meta, content = result
        date_created = meta.get("date_created", "")
        excerpt = ""
        for line in content.split("\n"):
            line = line.strip()
            if line and not line.startswith("#") and not line.startswith("-") and not line.startswith(">"):
                excerpt = line[:200]
                break
        items.append({
            "slug": meta.get("slug") or re.sub(r"[^a-z0-9]+", "-", f.stem.lower()).strip("-"),
            "title": meta.get("source_file", f.stem.replace("-", " ").title()),
            "category": meta.get("category", ""),
            "date": date_created,
            "excerpt": excerpt,
            "kb": _extract_kb_slug(rel),
        })

    items.sort(key=lambda a: a.get("date", ""), reverse=True)
    return items[:limit]


@router.get("/stale")
async def list_stale_articles(request: Request) -> list[dict]:
    """List all stale articles (source was deleted)."""
    vault = request.app.state.vault
    articles = _get_cached_articles(vault)
    return [
        {"slug": a["slug"], "title": a["title"], "path": a.get("path", ""), "kb": a.get("kb", "")}
        for a in articles if a.get("status") == "stale"
    ]


class CreateArticleBody(BaseModel):
    kb_slug: str
    title: str
    content: str = ""
    entity_type: str = "concept"
    subdir: str | None = None


class UpdateArticleBody(BaseModel):
    content: str | None = None
    metadata: dict | None = None


class RenameArticleBody(BaseModel):
    new_title: str


@router.post("/article")
async def create_article(request: Request, body: CreateArticleBody) -> dict:
    """Create a new wiki article in a specific KB."""
    kb_service = request.app.state.kb_service
    try:
        result = kb_service.create_article(
            body.kb_slug, body.title, body.content, body.entity_type, body.subdir,
        )
    except ValueError as e:
        raise HTTPException(status_code=409, detail=str(e)) from e

    invalidate_article_cache()
    return result


@router.put("/article/{slug}")
async def update_article(request: Request, slug: str, body: UpdateArticleBody) -> dict:
    """Update a wiki article's content and/or metadata."""
    if body.content is None and body.metadata is None:
        raise HTTPException(status_code=400, detail="Provide content and/or metadata to update")

    kb_service = request.app.state.kb_service
    try:
        result = kb_service.update_article(slug, body.content, body.metadata)
    except FileNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e)) from e

    invalidate_article_cache()
    return result


@router.post("/article/{slug}/rename")
async def rename_article(request: Request, slug: str, body: RenameArticleBody) -> dict:
    """Rename a wiki article — updates file, slug, and all backlinks."""
    if not body.new_title.strip():
        raise HTTPException(status_code=400, detail="new_title is required")

    kb_service = request.app.state.kb_service
    try:
        result = kb_service.rename_article(slug, body.new_title.strip())
    except FileNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e)) from e
    except ValueError as e:
        raise HTTPException(status_code=409, detail=str(e)) from e

    invalidate_article_cache()
    return result


@router.get("/article/{slug}/backlinks")
async def get_backlinks(request: Request, slug: str, include_list: bool = False) -> dict:
    """Count how many articles link to this slug/title.
    Pass ?include_list=true to also return the list of backlinking articles."""
    vault = request.app.state.vault
    articles = _get_cached_articles(vault)
    cached = next((a for a in articles if a["slug"] == slug), None)
    title = cached["title"] if cached else slug.replace("-", " ")

    count = 0
    items: list[dict] = []
    for article_file in _list_wiki_files(vault):
        art_result = _read_article_safe(vault, article_file)
        if not art_result:
            continue
        art_rel, art_meta, art_content = art_result
        if cached and art_rel == cached.get("path"):
            continue
        if f"[[{title}]]" in art_content or f"[[{slug}]]" in art_content or f"[[{slug}|" in art_content:
            count += 1
            if include_list:
                art_title = art_meta.get("title") or art_rel.split("/")[-1].replace(".md", "")
                art_slug = art_meta.get("slug") or art_title.lower().replace(" ", "-")
                # Derive KB slug from path (knowledge/{kb}/wiki/...)
                parts = art_rel.replace("\\", "/").split("/")
                art_kb = parts[1] if len(parts) > 2 and parts[0] == "knowledge" else ""
                # Extract a short snippet around the first mention
                snippet = ""
                for needle in (f"[[{title}]]", f"[[{slug}]]", f"[[{slug}|"):
                    idx = art_content.find(needle)
                    if idx >= 0:
                        start = max(0, idx - 80)
                        end = min(len(art_content), idx + len(needle) + 80)
                        snippet = art_content[start:end].replace("\n", " ").strip()
                        break
                items.append({
                    "slug": art_slug,
                    "title": art_title,
                    "kb": art_kb,
                    "snippet": snippet,
                })

    result: dict = {"slug": slug, "backlink_count": count}
    if include_list:
        result["backlinks"] = items
    return result


@router.delete("/article/{slug}")
async def delete_article(request: Request, slug: str) -> dict:
    """Delete a wiki article across all KBs. Returns broken backlink info."""
    vault = request.app.state.vault
    kb_service = request.app.state.kb_service

    # Collect backlinks before deleting so the caller can warn about broken links
    broken_backlinks = kb_service.find_backlinks(slug)

    try:
        files = vault.list_files("knowledge", f"**/wiki/**/{slug}.md")
        files = [f for f in files if "/.archives/" not in str(f).replace("\\", "/")]
    except Exception:
        files = []

    # Also try to find by frontmatter slug (display-name filenames)
    if not files:
        found = kb_service._find_article_by_slug(slug)
        if found:
            _kb, _sd, fpath, _meta, _content = found
            files = [fpath]

    deleted = []
    for f in files:
        try:
            full = f if f.is_absolute() else vault._resolve(_kb_relative_path(f, vault))
            if full.exists():
                full.unlink()
                deleted.append(str(f.name))
        except Exception:
            continue

    # Clean up index references
    if deleted:
        seen_kbs: set[str] = set()
        for f in files:
            kb_slug_val = _extract_kb_slug(_kb_relative_path(f, vault))
            if kb_slug_val and kb_slug_val not in seen_kbs:
                seen_kbs.add(kb_slug_val)
                idx_rel = f"knowledge/{kb_slug_val}/wiki/_index.md"
                try:
                    doc = vault.read(idx_rel)
                    idx = doc.raw
                    new_lines = [l for l in idx.split("\n") if f"[[{slug}]]" not in l and f"[{slug}]" not in l]
                    vault_writer = getattr(request.app.state, "vault_writer", None)
                    if vault_writer:
                        vault_writer.write(idx_rel, "\n".join(new_lines))
                except Exception:
                    pass

    if not deleted:
        raise HTTPException(status_code=404, detail=f"Article not found: {slug}")

    invalidate_article_cache()
    return {
        "deleted": True,
        "slug": slug,
        "files": deleted,
        "broken_backlinks": [{"article": bl["slug"], "kb": bl["kb"]} for bl in broken_backlinks],
    }


@router.get("/article/{slug}/history")
async def article_history(request: Request, slug: str) -> dict:
    """List version history for a wiki article."""
    vault = request.app.state.vault
    writer = getattr(request.app.state, "vault_writer", None)
    if not writer:
        return {"versions": [], "slug": slug}

    # Find the article file across all KBs
    try:
        files = vault.list_files("knowledge", f"**/wiki/**/{slug}.md")
        files = [f for f in files if "/.archives/" not in str(f).replace("\\", "/")
                 and "/.history/" not in str(f).replace("\\", "/")]
    except Exception:
        files = []

    if not files:
        # Fallback: search by frontmatter slug (display-name files)
        kb_svc = getattr(request.app.state, "kb_service", None)
        found = kb_svc._find_article_by_slug(slug) if kb_svc else None
        if found:
            files = [found[2]]  # filepath

    if not files:
        raise HTTPException(status_code=404, detail=f"Article not found: {slug}")

    # Get relative path
    relative = _to_logical(vault, files[0])
    versions = writer.list_versions(relative)

    return {"slug": slug, "path": relative, "versions": versions}


@router.get("/article/{slug}/version/{version}")
async def article_version(request: Request, slug: str, version: str) -> dict:
    """Read a specific version of a wiki article."""
    vault = request.app.state.vault
    writer = getattr(request.app.state, "vault_writer", None)
    if not writer:
        raise HTTPException(status_code=404, detail="No writer available")

    try:
        files = vault.list_files("knowledge", f"**/wiki/**/{slug}.md")
        files = [f for f in files if "/.archives/" not in str(f).replace("\\", "/")
                 and "/.history/" not in str(f).replace("\\", "/")]
    except Exception:
        files = []

    if not files:
        raise HTTPException(status_code=404, detail=f"Article not found: {slug}")

    relative = _to_logical(vault, files[0])
    content = writer.read_version(relative, version)

    if content is None:
        raise HTTPException(status_code=404, detail=f"Version not found: {version}")

    return {"slug": slug, "version": version, "content": content}


@router.post("/reclassify")
async def reclassify_all(request: Request) -> dict:
    """Re-run taxonomy classification on all articles. Call after taxonomy changes."""
    kb_service = request.app.state.kb_service
    return kb_service.reclassify_articles()


@router.post("/reclassify/{kb_slug}")
async def reclassify_kb(request: Request, kb_slug: str) -> dict:
    """Re-run taxonomy classification on a specific KB's articles."""
    kb_service = request.app.state.kb_service
    return kb_service.reclassify_articles(kb_slug)


@router.delete("/reset")
async def reset_all_wikis(request: Request) -> dict:
    """Reset ALL wiki articles across all KBs. Keeps library/inbox sources intact."""
    kb_service = request.app.state.kb_service
    return kb_service.reset_all_wikis()


@router.delete("/reset/{kb_slug}")
async def reset_kb_wiki(request: Request, kb_slug: str) -> dict:
    """Reset all wiki articles for a specific KB. Keeps library/inbox sources intact."""
    kb_service = request.app.state.kb_service
    return kb_service.reset_wiki(kb_slug)




@router.delete("/stale")
async def delete_all_stale(request: Request) -> dict:
    """Delete all stale articles at once."""
    stale = await list_stale_articles(request)
    vault = request.app.state.vault
    deleted = []
    for article in stale:
        try:
            full = vault._resolve(article["path"])
            if full.exists():
                full.unlink()
                deleted.append(article["slug"])
        except Exception:
            continue
    return {"deleted": deleted, "count": len(deleted)}


@router.get("/file/{file_path:path}")
@router.get("/image/{file_path:path}")
async def serve_file(request: Request, file_path: str):
    """Serve a file from the vault (images, documents, any source file)."""
    from fastapi.responses import FileResponse

    vault = request.app.state.vault
    full_path = vault._resolve(file_path)

    if not full_path.exists() or not full_path.is_file():
        raise HTTPException(status_code=404, detail=f"Image not found: {file_path}")

    # Security: ensure path is within vault (check all content roots + internals)
    vault_dir = vault.vault_dir if hasattr(vault, "vault_dir") else vault._vault_dir
    root_dir = vault.paths.root_dir if (hasattr(vault, "paths") and vault.paths) else vault_dir
    try:
        full_path.resolve().relative_to(root_dir.resolve())
    except ValueError as e:
        raise HTTPException(status_code=403, detail="Access denied") from e

    return FileResponse(str(full_path))


@router.post("/open-file")
async def open_local_file(request: Request) -> dict:
    """Open a local file with the OS default handler (like double-clicking it).

    This is a local-only app — this endpoint only works on the host machine.
    """
    import subprocess
    import sys

    try:
        body = await request.json()
    except Exception:
        raw = await request.body()
        import json as _json
        body = _json.loads(raw.decode("utf-8", errors="replace"))
    file_path = body.get("path", "")

    if not file_path:
        raise HTTPException(status_code=400, detail="No path provided")

    # Decode any URL-encoded characters (spaces, unicode, etc.)
    from urllib.parse import unquote
    file_path = unquote(file_path)

    from pathlib import Path as P
    path = P(file_path)
    # Resolve vault-relative logical paths (e.g., "knowledge/general/library/...")
    # through VaultPaths so short-form prefixes map to the real physical layout.
    if not path.is_absolute() and hasattr(request.app.state, "vault"):
        path = request.app.state.vault._resolve(file_path)
    if not path.exists():
        raise HTTPException(status_code=404, detail=f"File not found: {file_path}")

    try:
        import shutil

        method = "default"

        # 1. If the file is inside an Obsidian vault, jump straight there.
        #    Works on Windows (os.startfile), macOS (open) and Linux (xdg-open)
        #    because all three honour the obsidian:// URI scheme registered
        #    by the Obsidian desktop app.
        if _try_open_obsidian(path):
            return {"opened": True, "path": file_path, "method": "obsidian"}

        # 2. Platform-native open
        if sys.platform == "win32":
            import os
            import winreg

            ext = path.suffix.lower()
            has_association = False
            try:
                with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as key:
                    val = winreg.QueryValueEx(key, "")[0]
                    has_association = bool(val)
            except OSError:
                pass

            if has_association:
                os.startfile(str(path))  # type: ignore[attr-defined]
                method = "os"
            else:
                code = shutil.which("code")
                if code:
                    subprocess.Popen([code, str(path)])
                    method = "vscode"
                else:
                    subprocess.Popen(["notepad.exe", str(path)])
                    method = "notepad"
        elif sys.platform == "darwin":
            # macOS: `open` honours LaunchServices for .md and every other
            # registered extension, so the user's preferred editor is used.
            subprocess.Popen(["open", str(path)])
            method = "os"
        else:
            # Linux: xdg-open is the standard. Fall back to a known editor
            # if xdg-utils is not installed (slim containers, minimal WMs).
            opener = shutil.which("xdg-open")
            if opener:
                subprocess.Popen([opener, str(path)])
                method = "os"
            else:
                editor = shutil.which("code") or shutil.which("gedit") or shutil.which("nano")
                if not editor:
                    raise HTTPException(
                        status_code=500,
                        detail="No file opener available. Install xdg-utils or set $EDITOR.",
                    )
                subprocess.Popen([editor, str(path)])
                method = Path(editor).name
        return {"opened": True, "path": file_path, "method": method}
    except Exception as e:
        log.exception("Failed to open file: %s", file_path)
        raise HTTPException(status_code=500, detail=f"Failed to open: {e}") from e


def _kb_relative_path(file_path: Path, vault) -> str:
    """Get vault-relative path for a KB file."""
    if hasattr(vault, "paths") and vault.paths:
        return vault.paths.to_logical(file_path)
    vault_dir = vault.vault_dir
    try:
        return str(file_path.relative_to(vault_dir)).replace("\\", "/")
    except ValueError:
        # Absolute path not under vault_dir — use as-is
        return str(file_path).replace("\\", "/")


def _extract_kb_slug(path: str) -> str:
    """Extract KB slug from a path like 'knowledge/ai-safety/wiki/article.md'."""
    parts = path.replace("\\", "/").split("/")
    try:
        kb_idx = parts.index("knowledge")
        return parts[kb_idx + 1]
    except (ValueError, IndexError):
        return ""


def _resolve_vault_source(request: Request, meta: dict, kb_slug: str) -> str:
    """Resolve the original vault path for an article.

    Priority:
    1. Article's own vault_source frontmatter
    2. Compile state sources dict (fast lookup)
    3. Source summary page's vault_source frontmatter (fallback for pre-tracking data)
    """
    direct = meta.get("vault_source", "")
    if direct:
        return direct

    source_files = meta.get("sources", [])
    if not source_files or not kb_slug:
        return ""

    try:
        kb_service = request.app.state.kb_service
        vault = request.app.state.vault

        # Try compile state first
        state = kb_service._read_compile_state(kb_slug)
        sources = state.get("sources", {})
        for fname in source_files:
            if fname in sources:
                vs = sources[fname].get("vault_source", "")
                if vs:
                    return vs

        # Fallback: read the source summary page's frontmatter
        for fname in source_files:
            slug = fname.lower().replace(" ", "-").replace(".md", "")
            source_path = f"knowledge/{kb_slug}/wiki/sources/{slug}.md"
            if vault.exists(source_path):
                try:
                    doc = vault.read(source_path)
                    vs = doc.metadata.get("vault_source", "")
                    if vs:
                        return vs
                except Exception:
                    pass
    except Exception:
        pass

    return ""


def _resolve_source_paths(request: Request, source_files: list[str], kb_slug: str) -> dict[str, str]:
    """Resolve each source filename to its vault path (absolute on disk).

    Returns a dict mapping source filename → absolute file path.
    Sources live in the KB library folder; we also check compile state for vault_source.
    """
    if not source_files or not kb_slug:
        return {}

    result: dict[str, str] = {}
    try:
        vault = request.app.state.vault
        kb_service = request.app.state.kb_service

        # Load compile state once for all sources
        state = kb_service._read_compile_state(kb_slug)
        sources_state = state.get("sources", {})

        for fname in source_files:
            # 1. Check compile state for vault_source (original file outside vault)
            if fname in sources_state:
                vs = sources_state[fname].get("vault_source", "")
                if vs and Path(vs).is_file():
                    result[fname] = vs
                    continue

            # 2. Try to find in KB library folder
            try:
                found = vault.list_files(f"knowledge/{kb_slug}/library", f"**/{fname}")
                if found:
                    result[fname] = str(found[0])
                    continue
            except Exception:
                pass

            # 3. Try inbox
            try:
                found = vault.list_files(f"knowledge/{kb_slug}/inbox", f"**/{fname}")
                if found:
                    result[fname] = str(found[0])
            except Exception:
                pass
    except Exception:
        pass

    return result


def _list_wiki_files(vault) -> list[Path]:
    """List all wiki article .md files across all KBs.

    Filters out .index.md, .concepts.md, .gitkeep, and archived snapshots.
    """
    try:
        files = vault.list_files("knowledge", "**/wiki/**/*.md")
    except Exception:
        log.exception("Failed to list wiki files")
        return []
    return [
        f for f in files
        if not f.name.startswith(("_", "."))
        and "/.archives/" not in str(f).replace("\\", "/")
        and "/.history/" not in str(f).replace("\\", "/")
    ]


def _read_article_safe(vault, file_path: Path) -> tuple[str, dict, str] | None:
    """Read a wiki article, returning (rel_path, metadata, content) or None on error."""
    try:
        rel = _kb_relative_path(file_path, vault)
        doc = vault.read(rel)
        return rel, doc.metadata, doc.content
    except Exception as e:
        log.debug("Skipped article with bad frontmatter: %s (%s)", file_path.name, e.__class__.__name__)
        return None


# ── Gap 1: Curate — draft review queue + verify flow ──


@router.get("/drafts")
async def list_drafts(request: Request) -> list[dict]:
    """List all draft articles needing human review (status != verified)."""
    articles = _get_cached_articles(request.app.state.vault)
    drafts = []
    for a in articles:
        status = a.get("status", "draft")
        if status not in ("verified", "active"):
            drafts.append({
                "slug": a.get("slug", ""),
                "title": a.get("title", ""),
                "path": a.get("path", ""),
                "entity_type": a.get("entity_type", ""),
                "status": status,
                "kb": a.get("kb", ""),
                "date_created": a.get("date_created", a.get("date", "")),
                "author": a.get("author", ""),
                "agent": a.get("agent", ""),
            })
    return drafts


@router.post("/article/{slug}/verify")
async def verify_article(request: Request, slug: str) -> dict:
    """Mark a draft article as verified (human-reviewed)."""
    vault = request.app.state.vault
    writer = request.app.state.vault_writer

    # Find the article
    articles = _get_cached_articles(vault)
    match = next((a for a in articles if a.get("slug") == slug), None)
    if not match:
        raise HTTPException(status_code=404, detail=f"Article not found: {slug}")

    path = match["path"]
    doc = vault.read(path)
    raw = doc.raw

    # Update status in frontmatter
    if "status:" in raw:
        import re as _re
        updated = _re.sub(r"status:\s*\S+", "status: verified", raw, count=1)
    else:
        # Add status to frontmatter
        updated = raw.replace("---\n", "---\nstatus: verified\n", 1)

    writer.write(path, updated)
    invalidate_article_cache()
    return {"slug": slug, "status": "verified"}


@router.post("/article/{slug}/reject")
async def reject_article(request: Request, slug: str, reason: str = "") -> dict:
    """Mark a draft article as rejected (needs rework or deletion)."""
    vault = request.app.state.vault
    writer = request.app.state.vault_writer

    articles = _get_cached_articles(vault)
    match = next((a for a in articles if a.get("slug") == slug), None)
    if not match:
        raise HTTPException(status_code=404, detail=f"Article not found: {slug}")

    path = match["path"]
    doc = vault.read(path)
    raw = doc.raw

    import re as _re
    if "status:" in raw:
        updated = _re.sub(r"status:\s*\S+", "status: rejected", raw, count=1)
    else:
        updated = raw.replace("---\n", "---\nstatus: rejected\n", 1)

    if reason:
        updated += f"\n\n> **Rejected**: {reason}\n"

    writer.write(path, updated)
    invalidate_article_cache()
    return {"slug": slug, "status": "rejected", "reason": reason}


# ── Gap 2: Export — wiki article export ──


@router.get("/export")
async def export_wiki(request: Request, kb: str = "", format: str = "markdown") -> dict:
    """Export wiki articles as downloadable archive.

    Formats: markdown (default), json
    Optional kb filter.
    """

    vault = request.app.state.vault
    articles = _get_cached_articles(vault)

    if kb:
        articles = [a for a in articles if a.get("kb") == kb]

    if format == "json":
        export = []
        for a in articles:
            try:
                doc = vault.read(a["path"])
                export.append({
                    "slug": a.get("slug", ""),
                    "title": a.get("title", ""),
                    "entity_type": a.get("entity_type", ""),
                    "status": a.get("status", "draft"),
                    "kb": a.get("kb", ""),
                    "metadata": doc.metadata,
                    "content": doc.content,
                })
            except Exception:
                continue
        return {"format": "json", "count": len(export), "articles": export}

    # Markdown format — return file list with content
    export = []
    for a in articles:
        try:
            doc = vault.read(a["path"])
            export.append({
                "path": a["path"],
                "title": a.get("title", ""),
                "raw": doc.raw,  # Full markdown with frontmatter
            })
        except Exception:
            continue
    return {"format": "markdown", "count": len(export), "articles": export}


# ── Gap 3: Wiki Health Metrics ──


@router.get("/health")
async def wiki_health(request: Request) -> dict:
    """Aggregate wiki health score and metrics across all KBs.

    Health score (0.0-1.0) based on:
    - Article quality (source coverage, TODO density, link density)
    - Freshness (recency of updates)
    - Structural integrity (orphans, broken links)
    - Source-to-article ratio
    """
    vault = request.app.state.vault
    kb_service = request.app.state.kb_service

    articles = _get_cached_articles(vault)
    kbs = kb_service.list_kbs()

    total_articles = len(articles)
    total_sources = sum(kb.get("sources", 0) for kb in kbs)

    if total_articles == 0:
        return {
            "score": 0.0, "status": "empty",
            "metrics": {}, "issues": ["No articles in wiki"],
        }

    # Metrics collection
    draft_count = 0
    verified_count = 0
    rejected_count = 0
    stale_count = 0
    todo_articles = 0
    orphan_count = 0
    total_links = 0
    total_todos = 0
    source_summary_count = 0
    days_since_update: list[float] = []

    now = datetime.now(UTC)
    for a in articles:
        status = a.get("status", "draft")
        if status == "draft":
            draft_count += 1
        elif status == "verified":
            verified_count += 1
        elif status == "rejected":
            rejected_count += 1
        elif status == "stale":
            stale_count += 1

        etype = a.get("entity_type", "")
        if etype == "source_summary":
            source_summary_count += 1
            continue  # Don't count source summaries in quality metrics

        # Read content for quality checks
        try:
            doc = vault.read(a["path"])
            content = doc.content

            # TODO density
            todos = content.count("[TODO:")
            total_todos += todos
            if todos > 0:
                todo_articles += 1

            # Link density
            import re as _re
            links = len(_re.findall(r"\[\[.+?\]\]", content))
            total_links += links
            if links == 0:
                orphan_count += 1

            # Freshness
            date_str = doc.metadata.get("date_updated", doc.metadata.get("date_created", doc.metadata.get("date", "")))
            if date_str:
                try:
                    from datetime import datetime as _dt
                    d = _dt.strptime(str(date_str)[:10], "%Y-%m-%d")
                    days_since_update.append((now - d).days)
                except (ValueError, TypeError):
                    pass
        except Exception:
            continue

    # Content articles (excluding source summaries)
    content_articles = total_articles - source_summary_count

    # Calculate health score components (0-1 each)
    # 1. Verification coverage: what % are verified?
    verification_score = verified_count / max(content_articles, 1)

    # 2. TODO density: lower is better (0 TODOs = 1.0)
    todo_ratio = todo_articles / max(content_articles, 1)
    todo_score = max(0.0, 1.0 - todo_ratio)

    # 3. Link density: articles should have cross-references
    orphan_ratio = orphan_count / max(content_articles, 1)
    link_score = max(0.0, 1.0 - orphan_ratio)

    # 4. Freshness: median days since update (< 30 days = 1.0, > 365 = 0.0)
    if days_since_update:
        import statistics
        median_age = statistics.median(days_since_update)
        freshness_score = max(0.0, min(1.0, 1.0 - (median_age - 30) / 335))
    else:
        freshness_score = 0.5

    # 5. Source coverage: source-to-article ratio (>= 1.0 means well-sourced)
    source_ratio = total_sources / max(content_articles, 1)
    source_score = min(1.0, source_ratio)

    # 6. Staleness: stale articles drag down health
    stale_ratio = stale_count / max(content_articles, 1)
    stale_score = max(0.0, 1.0 - stale_ratio * 5)  # 20% stale = 0.0

    # Weighted aggregate
    score = (
        verification_score * 0.15
        + todo_score * 0.15
        + link_score * 0.20
        + freshness_score * 0.20
        + source_score * 0.15
        + stale_score * 0.15
    )

    status = "healthy" if score >= 0.7 else "degraded" if score >= 0.4 else "critical"

    # Build issues list
    issues: list[str] = []
    if draft_count > content_articles * 0.5:
        issues.append(f"{draft_count} articles still in draft (review needed)")
    if todo_articles > content_articles * 0.3:
        issues.append(f"{todo_articles} articles have [TODO:] gaps")
    if orphan_count > content_articles * 0.2:
        issues.append(f"{orphan_count} articles have no cross-references (orphans)")
    if stale_count > 0:
        issues.append(f"{stale_count} articles marked stale (source deleted)")
    if rejected_count > 0:
        issues.append(f"{rejected_count} articles rejected (need rework or deletion)")
    if total_sources == 0:
        issues.append("No sources ingested yet")

    return {
        "score": round(score, 3),
        "status": status,
        "metrics": {
            "total_articles": total_articles,
            "content_articles": content_articles,
            "source_summaries": source_summary_count,
            "total_sources": total_sources,
            "draft": draft_count,
            "verified": verified_count,
            "rejected": rejected_count,
            "stale": stale_count,
            "todo_articles": todo_articles,
            "total_todos": total_todos,
            "orphan_articles": orphan_count,
            "total_links": total_links,
            "avg_links_per_article": round(total_links / max(content_articles, 1), 1),
            "source_to_article_ratio": round(source_ratio, 2),
            "median_age_days": round(statistics.median(days_since_update), 0) if days_since_update else None,
            "verification_pct": round(verification_score * 100, 1),
        },
        "component_scores": {
            "verification": round(verification_score, 3),
            "completeness": round(todo_score, 3),
            "cross_references": round(link_score, 3),
            "freshness": round(freshness_score, 3),
            "source_coverage": round(source_score, 3),
            "staleness": round(stale_score, 3),
        },
        "issues": issues,
    }
