"""Knowledge taxonomy — hierarchical classification for wiki articles.

Loads the taxonomy tree from the knowledge-taxonomy skill's config/taxonomy.yaml.
Provides methods to classify articles, build the tree with counts, and resolve paths.
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

import yaml

log = logging.getLogger(__name__)

# Flat representation of a taxonomy node
TaxonomyNode = dict[str, Any]


def load_taxonomy(skills_dir: Path) -> list[dict]:
    """Load taxonomy tree from the knowledge-taxonomy skill config.

    Returns the raw tree (list of top-level nodes), each with possible 'children'.
    Falls back to a minimal default if the file is missing.
    """
    yaml_path = skills_dir / "_meta" / "knowledge-taxonomy" / "config" / "taxonomy.yaml"
    if not yaml_path.is_file():
        log.warning("Taxonomy file not found: %s — using minimal default", yaml_path)
        return [{"name": "General", "slug": "general", "description": "All articles"}]

    try:
        data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
        tree = data.get("taxonomy", [])
        if not tree:
            return [{"name": "General", "slug": "general", "description": "All articles"}]
        return tree
    except Exception:
        log.exception("Failed to parse taxonomy YAML")
        return [{"name": "General", "slug": "general", "description": "All articles"}]


def flatten_taxonomy(tree: list[dict], parent_path: str = "") -> list[TaxonomyNode]:
    """Flatten the taxonomy tree into a list of nodes with full paths.

    Each node gets:
      - path: "parent-slug/child-slug"
      - depth: 0, 1, 2
      - entity_types: inherited from parent if not set
    """
    nodes: list[TaxonomyNode] = []
    for node in tree:
        slug = node.get("slug", "")
        path = f"{parent_path}/{slug}" if parent_path else slug
        depth = path.count("/")
        flat = {
            "name": node.get("name", slug),
            "slug": slug,
            "path": path,
            "depth": depth,
            "description": node.get("description", ""),
            "entity_types": node.get("entity_types", []),
        }
        nodes.append(flat)
        children = node.get("children", [])
        if children:
            # Children inherit entity_types if they don't define their own
            for child in children:
                if "entity_types" not in child and "entity_types" in node:
                    child.setdefault("entity_types", node["entity_types"])
            nodes.extend(flatten_taxonomy(children, path))
    return nodes


def classify_article(
    entity_type: str,
    category: str,
    title: str,
    flat_nodes: list[TaxonomyNode],
) -> str:
    """Determine the best taxonomy_path for an article based on its metadata.

    Priority:
    1. entity_type match on a leaf node
    2. category keyword match on node slug/description
    3. Fall back to "general"
    """
    # Ignore generic categories that don't carry classification signal
    _GENERIC_CATEGORIES = {"article", "paper", "image", "data", "code", "uncategorized", ""}
    raw_category = category.lower().strip() if category else ""
    category_lower = "" if raw_category in _GENERIC_CATEGORIES else raw_category
    title_lower = title.lower().strip() if title else ""

    # 1. entity_type match — only for specific types (person, organization, source_summary)
    #    Skip generic types like "concept" that appear across many categories.
    if entity_type and entity_type not in ("concept",):
        et_matches = [n for n in flat_nodes if entity_type in n.get("entity_types", [])]
        if et_matches:
            # Prefer the deepest match
            et_matches.sort(key=lambda n: n["depth"], reverse=True)
            return et_matches[0]["path"]

    # 2. category/title keyword match against slugs, names, and descriptions
    best_match = ""
    best_score = 0
    search_text = f"{category_lower} {title_lower}"
    search_words = [w for w in search_text.split() if len(w) > 2]

    for node in flat_nodes:
        score = 0
        slug = node["slug"]
        name_lower = node["name"].lower()
        desc_lower = node["description"].lower()
        match_text = f"{slug} {name_lower} {desc_lower}"

        # Exact category match to slug
        if category_lower and category_lower == slug:
            score += 10

        # Category words in slug or name
        if category_lower:
            for word in category_lower.split():
                if len(word) > 2 and word in slug:
                    score += 3
                if len(word) > 2 and word in name_lower:
                    score += 2

        # Search text words in name + description + slug
        for word in search_words:
            if word in match_text:
                score += 2

        # Prefer leaf nodes (deeper = more specific)
        if score > 0:
            score += node["depth"] * 0.5

        if score > best_score:
            best_score = score
            best_match = node["path"]

    return best_match if best_match else "general"


def _first_letter_bucket(title: str) -> str:
    """Return the bucket key for the first character of a title — uppercase
    ASCII letter, or ``#`` for anything else (digits, symbols, non-Latin
    scripts). Kept deterministic and tiny so the same rule can be mirrored
    on the client without special-casing Unicode normalization."""
    s = (title or "").strip()
    if not s:
        return "#"
    ch = s[0].upper()
    if "A" <= ch <= "Z":
        return ch
    return "#"


def build_taxonomy_tree_with_counts(
    tree: list[dict], articles: list[dict], *, lite: bool = False,
) -> list[dict]:
    """Build the taxonomy tree annotated with article counts per node.

    Each node in the returned tree has:
      - name, slug, description, children
      - count: number of articles directly in this node
      - total: count + sum of children totals
      - letter_counts: {"A": 12, "B": 8, …, "#": 3} — bucket counts of
        articles directly in this node, so the client can render letter
        folders without needing the full article list on initial load.
      - articles: list of {slug, title} for articles in this node (ONLY
        when ``lite=False``; omitted in lite mode so the payload shrinks
        to ~structure + counts).

    Lite mode is intended for clients that lazy-load articles per node
    (see ``articles_for_path`` below). Full mode preserves the pre-lite
    shape for backward compat.
    """
    # Index articles by their taxonomy_path
    path_articles: dict[str, list[dict]] = {}
    unclassified: list[dict] = []
    for art in articles:
        tp = art.get("taxonomy_path", "")
        if tp:
            path_articles.setdefault(tp, []).append(art)
        else:
            # Try to match by category for backwards compatibility
            cat = art.get("category", "")
            if cat:
                path_articles.setdefault(cat, []).append(art)
            else:
                unclassified.append(art)

    def _letter_counts(direct: list[dict]) -> dict[str, int]:
        buckets: dict[str, int] = {}
        for a in direct:
            key = _first_letter_bucket(a.get("title") or a.get("slug") or "")
            buckets[key] = buckets.get(key, 0) + 1
        return buckets

    def _annotate(nodes: list[dict], parent_path: str = "") -> list[dict]:
        result = []
        for node in nodes:
            slug = node.get("slug", "")
            path = f"{parent_path}/{slug}" if parent_path else slug
            children = node.get("children", [])

            # Articles directly at this node (exact path match)
            direct = path_articles.get(path, [])

            annotated_children = _annotate(children, path) if children else []
            child_total = sum(c.get("total", 0) for c in annotated_children)

            entry: dict = {
                "name": node.get("name", slug),
                "slug": slug,
                "path": path,
                "description": node.get("description", ""),
                "count": len(direct),
                "total": len(direct) + child_total,
                "letter_counts": _letter_counts(direct),
                "children": annotated_children,
            }
            if not lite:
                entry["articles"] = [
                    {"slug": a.get("slug", ""), "title": a.get("title", "")}
                    for a in direct
                ]
            result.append(entry)
        return result

    annotated = _annotate(tree)

    # Add unclassified as a virtual node if there are any
    if unclassified:
        u_entry: dict = {
            "name": "Unclassified",
            "slug": "unclassified",
            "path": "unclassified",
            "description": "Articles not yet classified into the taxonomy",
            "count": len(unclassified),
            "total": len(unclassified),
            "letter_counts": _letter_counts(unclassified),
            "children": [],
        }
        if not lite:
            u_entry["articles"] = [
                {"slug": a.get("slug", ""), "title": a.get("title", "")}
                for a in unclassified
            ]
        annotated.append(u_entry)

    return annotated


def articles_for_path(
    articles: list[dict],
    path: str,
    *,
    letter: str | None = None,
    offset: int = 0,
    limit: int = 200,
) -> dict:
    """Return a paginated slice of articles filed at a specific taxonomy path.

    Matches ``article.taxonomy_path == path`` EXACTLY — does NOT match
    descendants. For descendant-inclusive filtering, use the existing
    ``/api/wiki/articles?taxonomy_path=X`` endpoint.

    Args:
        articles: full article list (metadata — the caller is responsible
            for pre-filtering to a coherent source like
            ``KnowledgeBaseService.list_articles()``).
        path: exact taxonomy_path to match. ``"unclassified"`` matches
            articles with no taxonomy_path AND no category.
        letter: optional first-letter bucket filter. ``"A"``-``"Z"`` picks
            articles whose title starts with that letter; ``"#"`` picks
            everything that doesn't start with an ASCII letter. ``None``
            means no letter filter.
        offset: pagination offset, 0-based.
        limit: max rows to return. Hard cap at 1000 to protect the wire.

    Returns:
        ``{articles: [{slug, title}], total: N, offset: N, limit: N,
           has_more: bool}``.
    """
    if limit < 1:
        limit = 1
    if limit > 1000:
        limit = 1000
    if offset < 0:
        offset = 0

    path = path.strip()

    matched: list[dict] = []
    for art in articles:
        tp = art.get("taxonomy_path", "")
        if not tp:
            # Falls back to category, mirroring build_taxonomy_tree_with_counts
            tp = art.get("category", "")
        if path == "unclassified":
            if tp == "":
                matched.append(art)
        elif tp == path:
            matched.append(art)

    if letter:
        letter_u = letter.upper()
        filtered = []
        for art in matched:
            if _first_letter_bucket(art.get("title") or art.get("slug") or "") == letter_u:
                filtered.append(art)
        matched = filtered

    # Stable alphabetical order — the wire stays deterministic so the
    # client's virtualizer can cache offsets.
    matched.sort(key=lambda a: (a.get("title") or a.get("slug") or "").lower())

    total = len(matched)
    page = matched[offset : offset + limit]
    return {
        "articles": [
            {
                "slug": a.get("slug", ""),
                "title": a.get("title", ""),
                # Included so the client-side hide-sources filter can
                # apply without cross-referencing a (potentially absent)
                # full article list. Tiny per-row cost; large correctness win.
                "entity_type": a.get("entity_type", ""),
                "provenance": a.get("provenance", ""),
                "kb": a.get("kb", ""),
            }
            for a in page
        ],
        "total": total,
        "offset": offset,
        "limit": limit,
        "has_more": offset + limit < total,
    }


def taxonomy_for_prompt(tree: list[dict], indent: int = 0) -> str:
    """Render the taxonomy tree as indented text for LLM prompts.

    Example output:
      - People & Organizations (slug: people-organizations) — Individuals, teams, companies
        - People (slug: people) — Individual persons
        - Organizations (slug: organizations) — Companies, institutions
    """
    lines = []
    prefix = "  " * indent
    for node in tree:
        name = node.get("name", "")
        slug = node.get("slug", "")
        desc = node.get("description", "")
        lines.append(f"{prefix}- {name} (slug: {slug}) — {desc}")
        children = node.get("children", [])
        if children:
            lines.append(taxonomy_for_prompt(children, indent + 1))
    return "\n".join(lines)
