"""Corpus-agnostic topic label classification and normalization for Connections."""

from __future__ import annotations

import os
import re
from typing import Any, Literal

TopicClass = Literal["theme", "locator", "heading", "noise"]

_TOPIC_PREFIX_RE = re.compile(r"^topic:\s*", re.IGNORECASE)
_BULLET_PREFIX_RE = re.compile(r"^[-*•#]+\s*")
_DIGIT_COLON_DIGIT_RE = re.compile(r"\d+:\d+")
_SECTION_ENUM_RE = re.compile(
    r"^(?:chapter|section|part|appendix|module|lesson|unit|lecture)\s+\d",
    re.IGNORECASE,
)
_CHAPTER_ABBREV_RE = re.compile(r"\b(?:ch|chap|pp|fig|table)\s*\.?\s*\d", re.IGNORECASE)
_RFC_ISO_RE = re.compile(r"\b(?:RFC|ISO|IEEE|ANSI|ASTM)\s*[- ]?\d", re.IGNORECASE)
_DECIMAL_SECTION_RE = re.compile(r"^\d+(?:\.\d+){1,3}(?:\s|$)")
_REPO_PATH_RE = re.compile(r"(?:^|[/\\])(?:src|lib|tests?|docs?|utils?|tools?)[/\\]", re.IGNORECASE)
_CODE_EXT_RE = re.compile(r"\.(?:py|ts|tsx|js|jsx|go|rs|java|rb|swift|md|json|yaml|yml|toml)\b", re.IGNORECASE)
_STOPWORDS = frozenset(
    """
    a an the in on at to for of is it and or by with from this that as be are was were
    been being have has had do does did will would could should may might must can than
    then when where which who whom whose what how why if but not no nor so such only
    also just very more most some any each all both few other another into over under
    again further once here there when all both each few more most other some such no
    """.split()
)
_SMALL_WORDS = frozenset("a an the of and or in on at to for by with from as".split())
_ACRONYMS = frozenset(
    {
        "api",
        "http",
        "https",
        "url",
        "uri",
        "html",
        "css",
        "json",
        "xml",
        "sql",
        "tcp",
        "udp",
        "dns",
        "tls",
        "ssl",
        "ssh",
        "gpu",
        "cpu",
        "ui",
        "ux",
        "ios",
        "macos",
        "pdf",
        "rfc",
        "iso",
    }
)
_SNIPPET_SOURCE_PATH_RE = re.compile(r"\*\*Source path:\*\*\s*`[^`]*`", re.IGNORECASE)
_SNIPPET_TOPICS_HEADER_RE = re.compile(r"^##\s*Topics[^\n]*\n", re.MULTILINE | re.IGNORECASE)
_ABSOLUTE_PATH_RE = re.compile(r"(?:/Users/|/home/|/var/|/tmp/|C:\\|D:\\)[^\s`\"']+")


def _normalize_raw(raw: str) -> str:
    text = (raw or "").strip()
    text = _TOPIC_PREFIX_RE.sub("", text)
    text = _BULLET_PREFIX_RE.sub("", text)
    return re.sub(r"\s+", " ", text).strip()


def _filename_stem(filename: str) -> str:
    base = os.path.basename((filename or "").strip())
    stem, _ext = os.path.splitext(base)
    return re.sub(r"[\s_-]+", " ", stem).strip().lower()


def _digit_density(text: str) -> float:
    if not text:
        return 0.0
    digits = sum(1 for c in text if c.isdigit())
    return digits / max(len(text), 1)


def _stopword_ratio(tokens: list[str]) -> float:
    if not tokens:
        return 1.0
    stops = sum(1 for t in tokens if t.lower() in _STOPWORDS)
    return stops / len(tokens)


def _simple_stem(word: str) -> str:
    w = word.lower()
    if len(w) > 4 and w.endswith("s") and not w.endswith("ss"):
        return w[:-1]
    return w


def classify_topic_label(
    raw: str,
    *,
    ingest_profile: str = "docs",
    source_filename: str = "",
    force_theme: bool = False,
) -> TopicClass:
    """Classify a raw topic string for Connections indexing."""
    if force_theme:
        text = _normalize_raw(raw)
        return "theme" if text else "noise"

    text = _normalize_raw(raw)
    if not text:
        return "noise"

    low = text.lower()
    tokens = re.findall(r"[a-z0-9]+", low)
    word_count = len(tokens)

    if len(text) > 48:
        return "noise"
    if text.endswith(".") and word_count > 4:
        return "noise"
    if _stopword_ratio(tokens) > 0.6 and word_count > 2:
        return "noise"

    if source_filename:
        stem = _filename_stem(source_filename)
        raw_low = low
        raw_stem = _filename_stem(text)
        if stem and (raw_low == stem or raw_stem == stem or low.replace(" ", "") == stem.replace(" ", "")):
            return "heading"

    if _DIGIT_COLON_DIGIT_RE.search(text):
        return "locator"
    if _SECTION_ENUM_RE.match(text):
        return "locator"
    if _CHAPTER_ABBREV_RE.search(text):
        return "locator"
    if _RFC_ISO_RE.search(text):
        return "locator"
    if _DECIMAL_SECTION_RE.match(text):
        return "locator"
    if _digit_density(text) > 0.35 and word_count <= 4:
        return "locator"

    profile = (ingest_profile or "docs").strip().lower()
    if profile == "repo":
        if "/" in text or "\\" in text:
            return "noise"
        if "::" in text or _CODE_EXT_RE.search(text):
            return "noise"
        if _REPO_PATH_RE.search(text):
            return "noise"
        if "." in text and word_count == 1 and not text[0].isupper():
            return "noise"

    if word_count > 6:
        return "noise"
    if word_count == 0:
        return "noise"

    return "theme"


def _title_word(word: str, *, is_first: bool, is_last: bool) -> str:
    low = word.lower()
    if low in _ACRONYMS:
        return low.upper()
    if not is_first and not is_last and low in _SMALL_WORDS:
        return low
    if word.isupper() and len(word) <= 5:
        return word
    return word[:1].upper() + word[1:].lower() if word else word


def normalize_theme_label(raw: str) -> tuple[str, str]:
    """Return (display_label, canonical_key)."""
    text = _normalize_raw(raw)
    if not text:
        return "", ""
    tokens = re.findall(r"[A-Za-z0-9]+", text)
    if not tokens:
        return text.title(), re.sub(r"\s+", " ", text.lower()).strip()
    display_parts = [
        _title_word(tok, is_first=(i == 0), is_last=(i == len(tokens) - 1))
        for i, tok in enumerate(tokens)
    ]
    display = " ".join(display_parts)
    canonical = " ".join(_simple_stem(t.lower()) for t in tokens)
    return display, canonical


_BIBLE_BOOKS = (
    "Genesis|Exodus|Leviticus|Numbers|Deuteronomy|Joshua|Judges|Ruth|"
    "Samuel|Kings|Chronicles|Ezra|Nehemiah|Esther|Job|Psalms?|Proverbs|Ecclesiastes|"
    "Song of Solomon|Isaiah|Jeremiah|Lamentations|Ezekiel|Daniel|Hosea|Joel|Amos|"
    "Obadiah|Jonah|Micah|Nahum|Habakkuk|Zephaniah|Haggai|Zechariah|Malachi|"
    "Matthew|Mark|Luke|John|Acts|Romans|Corinthians|Galatians|Ephesians|Philippians|"
    "Colossians|Thessalonians|Timothy|Titus|Philemon|Hebrews|James|Peter|Jude|Revelation"
)
_PASSAGE_LOCATOR_RE = re.compile(
    rf"\b(?:[1-3]\s+)?(?:{_BIBLE_BOOKS})\s+\d+(?::\d+(?:\s*[-–]\s*\d+)?)?\b",
    re.IGNORECASE,
)
_CHAPTER_HEADING_RE = re.compile(
    rf"^(?:#+\s*)?(?:[1-3]\s+)?(?:{_BIBLE_BOOKS})\s+\d+\b",
    re.IGNORECASE | re.MULTILINE,
)


def extract_passage_locator(text: str) -> str:
    """Best-effort scripture / chapter locator for Connections navigation."""
    body = (text or "").strip()
    if not body:
        return ""
    m = _PASSAGE_LOCATOR_RE.search(body)
    if m:
        return re.sub(r"\s+", " ", m.group(0)).strip()
    m = _CHAPTER_HEADING_RE.search(body)
    if m:
        return re.sub(r"^#+\s*", "", m.group(0)).strip()
    return ""


def sanitize_knowledge_snippet_for_display(snippet: str) -> str:
    """Strip ingest metadata and absolute paths from snippets shown in Connections UI."""
    text = (snippet or "").strip()
    if not text:
        return ""
    text = _SNIPPET_TOPICS_HEADER_RE.sub("", text)
    text = _SNIPPET_SOURCE_PATH_RE.sub("", text)
    text = _ABSOLUTE_PATH_RE.sub("[path]", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


def build_source_entry(src: dict[str, Any], corpus_slug: str) -> dict[str, str]:
    slug = str(src.get("slug") or "").strip()
    filename = str(src.get("filename") or slug or "source")
    rel_path = str(src.get("rel_path") or "").strip()
    if not rel_path:
        raw_path = str(src.get("path") or "").strip()
        if raw_path:
            if not raw_path.startswith("/") and ":" not in raw_path[:3]:
                rel_path = raw_path
            else:
                rel_path = os.path.basename(raw_path)
    row: dict[str, str] = {
        "slug": slug,
        "filename": filename,
        "corpus_slug": corpus_slug,
    }
    if rel_path and not rel_path.startswith("/") and ":" not in rel_path[:3]:
        row["rel_path"] = rel_path
    return row


def build_theme_index(
    *,
    corpus_slug: str,
    sources: list[dict[str, Any]],
    broad_topics: list[str] | None = None,
    corpus_topics: list[str] | None = None,
    ingest_profile: str = "docs",
) -> list[dict[str, Any]]:
    """Build normalized theme rows for connections.json."""
    slug_to_src: dict[str, dict[str, Any]] = {}
    for src in sources:
        if not isinstance(src, dict):
            continue
        sid = str(src.get("slug") or "").strip()
        if not sid or ".." in sid or "/" in sid:
            continue
        slug_to_src[sid] = src

    merged: dict[str, dict[str, Any]] = {}

    def _add_raw(
        raw: str,
        source_slug: str | None,
        *,
        force_theme: bool = False,
    ) -> None:
        display, canonical = normalize_theme_label(raw)
        if not canonical:
            return
        src = slug_to_src.get(source_slug or "") if source_slug else None
        filename = str(src.get("filename") or "") if src else ""
        if not force_theme:
            kind = classify_topic_label(
                raw,
                ingest_profile=ingest_profile,
                source_filename=filename,
                force_theme=False,
            )
            if kind != "theme":
                return
        bucket = merged.setdefault(
            canonical,
            {
                "label": display,
                "canonical_key": canonical,
                "source_slugs": [],
                "sources": [],
            },
        )
        if display and len(display) > len(str(bucket.get("label") or "")):
            bucket["label"] = display
        if source_slug and source_slug in slug_to_src:
            slugs: list[str] = bucket["source_slugs"]
            if source_slug not in slugs:
                slugs.append(source_slug)
            entries: list[dict[str, str]] = bucket["sources"]
            entry = build_source_entry(slug_to_src[source_slug], corpus_slug)
            if not any(e.get("slug") == entry.get("slug") for e in entries):
                entries.append(entry)

    for topic in broad_topics or []:
        _add_raw(str(topic), None, force_theme=False)

    for src in sources:
        if not isinstance(src, dict):
            continue
        sid = str(src.get("slug") or "").strip()
        if not sid:
            continue
        for raw_t in src.get("topics") or []:
            _add_raw(str(raw_t), sid)

    for topic in corpus_topics or []:
        _add_raw(str(topic), None, force_theme=False)

    rows: list[dict[str, Any]] = []
    for bucket in merged.values():
        slugs = bucket.get("source_slugs") or []
        sources_list = bucket.get("sources") or []
        rows.append(
            {
                "label": bucket["label"],
                "canonical_key": bucket["canonical_key"],
                "source_slugs": slugs[:40],
                "source_count": len(slugs),
                "sources": sources_list[:40],
            }
        )

    rows.sort(key=lambda r: (-int(r.get("source_count") or 0), str(r.get("label") or "").lower()))
    return rows[:80]
