"""Three-language architecture for multilingual content ingestion.

Every content operation has three explicit language slots:

  instruction_lang   What language we *tell* the LLM to do work in.
                     Hardcoded to English — models follow English best.

  content_lang       What language the *input content* is written in.
                     Detected via langdetect or honored from source
                     frontmatter `language:` field.

  output_lang        What language the *result* (summary, concepts,
                     extracted prose) should be in. Resolved from the
                     layered policy chain — defaults to content_lang.

Policy override chain (high → low precedence):
  1. File frontmatter `language:` field (overrides everything)
  2. KB SCHEMA.md `language_policy:` field
  3. Skill YAML `language.output_policy` (from kb-ingest skill)
  4. Global config.toml [indexing] language_policy
  5. Hardcoded default "source"

Policy values:
  "source"   Output matches detected content language (default)
  "user"     Output matches the user's locale
  "english"  Output always English
  "<code>"   Specific ISO 639-1 code (e.g., "sv" = Swedish)

All language decisions are recorded on the source page frontmatter so
they are auditable: `content_language`, `output_language`, `language_policy`,
`language_policy_source`.
"""

from __future__ import annotations

import logging
from typing import Any

log = logging.getLogger(__name__)

# Languages we officially support (ISO 639-1). Detection results outside
# this set fall back to the skill's configured fallback_language.
SUPPORTED_LANGS: set[str] = {
    # Germanic
    "en", "de", "nl", "sv", "no", "da", "is",
    # Romance
    "fr", "es", "it", "pt", "ro", "ca",
    # Slavic
    "ru", "uk", "pl", "cs", "sk", "sl", "hr", "sr", "bg",
    # Finno-Ugric & Baltic
    "fi", "et", "hu", "lt", "lv",
    # CJK & SEA
    "zh", "ja", "ko", "vi", "th", "id", "ms", "tl",
    # Indic
    "hi", "bn", "pa", "ta", "te", "mr", "ur",
    # Semitic / MENA
    "ar", "he", "tr", "fa",
    # Other
    "el", "sq", "mk", "af", "sw",
}

# ISO 639-1 → human-readable English name for LLM instructions.
_LANG_NAMES: dict[str, str] = {  # arch:deterministic — ISO 639-1 code → English display name (stable reference data, not strategy); used only to inject language hints into LLM system prompts, which are English by Rule 11
    "en": "English", "de": "German", "nl": "Dutch", "sv": "Swedish",
    "no": "Norwegian", "da": "Danish", "is": "Icelandic",
    "fr": "French", "es": "Spanish", "it": "Italian", "pt": "Portuguese",
    "ro": "Romanian", "ca": "Catalan",
    "ru": "Russian", "uk": "Ukrainian", "pl": "Polish", "cs": "Czech",
    "sk": "Slovak", "sl": "Slovenian", "hr": "Croatian", "sr": "Serbian",
    "bg": "Bulgarian",
    "fi": "Finnish", "et": "Estonian", "hu": "Hungarian",
    "lt": "Lithuanian", "lv": "Latvian",
    "zh": "Chinese", "ja": "Japanese", "ko": "Korean",
    "vi": "Vietnamese", "th": "Thai", "id": "Indonesian", "ms": "Malay",
    "tl": "Tagalog",
    "hi": "Hindi", "bn": "Bengali", "pa": "Punjabi", "ta": "Tamil",
    "te": "Telugu", "mr": "Marathi", "ur": "Urdu",
    "ar": "Arabic", "he": "Hebrew", "tr": "Turkish", "fa": "Persian",
    "el": "Greek", "sq": "Albanian", "mk": "Macedonian",
    "af": "Afrikaans", "sw": "Swahili",
}

# Try to import langdetect. If it's missing, detection degrades to
# "always return fallback" — nothing crashes.
try:
    from langdetect import DetectorFactory, LangDetectException, detect_langs
    DetectorFactory.seed = 0  # deterministic results across calls
    _LANGDETECT_AVAILABLE = True
except ImportError:
    _LANGDETECT_AVAILABLE = False
    LangDetectException = Exception  # type: ignore[misc,assignment]


def language_name(code: str) -> str:
    """Human-readable name for a language code. Falls back to the code itself."""
    return _LANG_NAMES.get(code, code)


def _sample_text(text: str, max_chars: int = 4000) -> str:
    """Sample head + tail of text for detection. Avoids scanning huge files
    while still giving langdetect enough signal from both ends of a document.
    """
    if len(text) <= max_chars:
        return text
    half = max_chars // 2
    return text[:half] + "\n\n" + text[-half:]


def _looks_like_content(text: str) -> bool:
    """Reject strings that are mostly code, URLs, numbers, or symbols.

    langdetect on such input returns random results. Better to fall back
    cleanly than to guess wrong.
    """
    stripped = text.strip()
    if not stripped:
        return False
    # Count letters (Unicode-aware). If < 40% of characters are letters,
    # this isn't prose.
    letters = sum(1 for c in stripped if c.isalpha())
    if letters < 20:
        return False
    ratio = letters / len(stripped)
    return ratio >= 0.4


def detect_content_language(
    text: str,
    *,
    fallback: str = "en",
    min_chars: int = 50,
    min_confidence: float = 0.6,
) -> str:
    """Detect the natural language of source text.

    Returns an ISO 639-1 code from SUPPORTED_LANGS, or the fallback if
    detection failed / was too uncertain / produced an unsupported language.

    Args:
        text: The content to inspect.
        fallback: Language code to return on any failure. Must be in
            SUPPORTED_LANGS; if not, "en" is used.
        min_chars: Skip detection for shorter strings (unreliable).
        min_confidence: Minimum langdetect probability to accept the result.

    This function is side-effect free, deterministic (langdetect seed=0),
    and never raises.
    """
    # Validate fallback
    if fallback not in SUPPORTED_LANGS:
        fallback = "en"

    if not _LANGDETECT_AVAILABLE:
        return fallback

    if not text or len(text.strip()) < min_chars:
        return fallback

    if not _looks_like_content(text):
        return fallback

    sample = _sample_text(text)

    try:
        candidates = detect_langs(sample)
    except LangDetectException:
        return fallback
    except Exception:
        log.debug("detect_content_language: unexpected error", exc_info=True)
        return fallback

    if not candidates:
        return fallback

    top = candidates[0]
    code = getattr(top, "lang", "")
    prob = getattr(top, "prob", 0.0)

    if prob < min_confidence:
        return fallback

    if code not in SUPPORTED_LANGS:
        return fallback

    return code


def resolve_output_language(
    content_lang: str,
    policy: str,
    user_locale: str = "en",
) -> str:
    """Apply a policy to pick the output language from the content language.

    Args:
        content_lang: Detected (or declared) language of the source content.
        policy: One of "source", "user", "english", or a direct ISO 639-1 code.
        user_locale: User's UI locale (e.g., "sv-SE" or "sv"), used only when
            policy == "user".

    Returns:
        ISO 639-1 code for the output language. Never raises.
    """
    if not policy:
        policy = "source"

    policy = policy.strip().lower()

    if policy == "source":
        return content_lang if content_lang in SUPPORTED_LANGS else "en"

    if policy == "english":
        return "en"

    if policy == "both":
        # Bilingual mode: primary output matches source. The bilingual
        # directive is produced separately by build_bilingual_directive(),
        # which takes both content_lang and a secondary_lang (user_locale).
        # resolve_output_language itself returns the primary language only
        # so downstream frontmatter recording stays a single-value field.
        return content_lang if content_lang in SUPPORTED_LANGS else "en"

    if policy == "user":
        # Extract the primary subtag: "sv-SE" → "sv", "en_US" → "en"
        primary = user_locale.replace("_", "-").split("-")[0].lower()
        return primary if primary in SUPPORTED_LANGS else "en"

    # Literal language code
    if policy in SUPPORTED_LANGS:
        return policy

    # Unknown policy → safe fallback to source
    log.warning("resolve_output_language: unknown policy %r, defaulting to source", policy)
    return content_lang if content_lang in SUPPORTED_LANGS else "en"


def resolve_languages(
    source_text: str,
    *,
    source_meta: dict | None = None,
    skill_config: dict | None = None,
    kb_schema: dict | None = None,
    global_policy: str | None = None,
    user_locale: str = "en",
) -> tuple[str, str, str, str]:
    """Resolve the full language chain for an ingest operation.

    Applies the override hierarchy from lowest to highest precedence:
        1. Hardcoded default: "source"
        2. Global config.toml: global_policy
        3. Skill YAML: skill_config["output_policy"]
        4. KB SCHEMA.md: kb_schema["language_policy"]
        5. File frontmatter: source_meta["language"] (overrides detection)

    Returns:
        (content_lang, output_lang, effective_policy, policy_source)

        policy_source is one of:
          "default"     hardcoded
          "config"      config.toml
          "skill"       kb-ingest skill YAML
          "schema"      KB SCHEMA.md
          "frontmatter" the source file's own frontmatter

    Never raises. Always returns a valid tuple.
    """
    source_meta = source_meta or {}
    skill_config = skill_config or {}
    kb_schema = kb_schema or {}

    # ── Resolve content_lang ──
    # Frontmatter wins; otherwise detect from text.
    declared = source_meta.get("language") or source_meta.get("lang")
    if declared and isinstance(declared, str) and declared.lower() in SUPPORTED_LANGS:
        content_lang = declared.lower()
    else:
        content_lang = detect_content_language(
            source_text,
            fallback=skill_config.get("fallback_language", "en"),
            min_chars=int(skill_config.get("min_detection_chars", 50)),
            min_confidence=float(skill_config.get("min_confidence", 0.6)),
        )

    # ── Resolve policy via override chain ──
    # Track where each override came from so we can report it.
    policy = "source"
    policy_source = "default"

    if global_policy:
        policy = global_policy
        policy_source = "config"

    skill_policy = skill_config.get("output_policy")
    if skill_policy:
        policy = skill_policy
        policy_source = "skill"

    schema_policy = kb_schema.get("language_policy")
    if schema_policy:
        policy = schema_policy
        policy_source = "schema"

    # Frontmatter can set BOTH content_lang AND a one-off policy
    fm_policy = source_meta.get("language_policy")
    if fm_policy:
        policy = fm_policy
        policy_source = "frontmatter"

    output_lang = resolve_output_language(content_lang, policy, user_locale)
    return content_lang, output_lang, policy, policy_source


def build_language_directive(
    content_lang: str,
    output_lang: str,
) -> str:
    """Produce the English instruction block injected into classification prompts.

    This is the only place where the three-language architecture touches the
    LLM prompt. Kept separate so it can be unit-tested without running ingest.
    """
    content_name = language_name(content_lang)
    output_name = language_name(output_lang)

    # Same-language case: no translation needed, simpler instruction.
    if content_lang == output_lang:
        return (
            "\n\nLANGUAGE RULES (important):\n"
            f"- The source document is in {content_name}.\n"
            f"- Write the summary, concepts, key takeaways, and all extracted "
            f"prose in {content_name} — match the source language.\n"
            "- Keep frontmatter field names, entity_type values, predicates, "
            "and taxonomy paths in English (they are stable technical identifiers).\n"
            "- Proper nouns (people, places, organizations, products) stay in "
            "their original form — do NOT translate them.\n"
        )

    # Cross-language case: explicit translation instruction.
    return (
        "\n\nLANGUAGE RULES (important):\n"
        f"- The source document is in {content_name}.\n"
        f"- Write the summary, concepts, key takeaways, and all extracted "
        f"prose in {output_name} — translate meaning faithfully.\n"
        "- Keep frontmatter field names, entity_type values, predicates, "
        "and taxonomy paths in English (they are stable technical identifiers).\n"
        "- Proper nouns (people, places, organizations, products) stay in "
        "their original form — do NOT translate them.\n"
        f"- If the source is technical and the target language lacks a good "
        f"equivalent, keep the original term in parentheses.\n"
    )


# ════════════════════════════════════════════════════════════════════════════
# Spec v1.1 extensions
# ════════════════════════════════════════════════════════════════════════════


def build_bilingual_directive(
    content_lang: str,
    secondary_lang: str,
) -> str:
    """Produce an instruction block for bilingual output (spec §19 item 4).

    Emits a directive asking the LLM to write TWO summaries: one in the source
    language and one in a secondary language. Both go on the same source page,
    separated by level-2 headings the ingest parser recognizes.
    """
    content_name = language_name(content_lang)
    secondary_name = language_name(secondary_lang)

    if content_lang == secondary_lang:
        return build_language_directive(content_lang, content_lang)

    return (
        "\n\nLANGUAGE RULES (bilingual output):\n"
        f"- The source document is in {content_name}.\n"
        f"- Produce TWO parallel outputs:\n"
        f"  1. Primary output in {content_name} (match the source language).\n"
        f"  2. Secondary output in {secondary_name} (translate meaning faithfully).\n"
        "- Separate them with '## Summary ({primary})' and '## Summary ({secondary})' "
        "level-2 headings so the ingest parser can split them.\n".format(
            primary=content_name, secondary=secondary_name,
        )
        + "- Concepts, key takeaways, and extracted prose should appear under "
          "BOTH heading sections, translated between them.\n"
        "- Keep frontmatter field names, entity_type values, predicates, and "
        "taxonomy paths in English (stable technical identifiers).\n"
        "- Proper nouns (people, places, organizations, products) stay in "
        "their original form — do NOT translate them.\n"
    )


def verify_output_language(
    output_text: str,
    expected_lang: str,
    *,
    min_chars: int = 200,
    min_confidence: float = 0.5,
) -> dict[str, Any]:
    """Verify the LLM's output matches the expected output language.

    Spec §19 item 1 — adherence verification. Small local models often ignore
    language directives and write in English regardless. This helper detects
    the actual output language and returns a structured report.

    Returns a dict with:
        status       — "match" | "mismatch" | "unknown" | "skipped"
        detected     — detected ISO 639-1 code ("" if unknown)
        expected     — the expected code (echo)
        reason       — short explanation
        confidence   — float 0-1 (lower for heuristic paths)

    Short outputs fall back to a character-level heuristic that compares
    the script of the output against the expected language's dominant
    script — catches obvious drift even when prose-level detection can't.
    Never raises.
    """
    if not output_text:
        return {
            "status": "skipped", "detected": "", "expected": expected_lang,
            "reason": "empty output", "confidence": 0.0,
        }

    # Short-output heuristic (gap #6):
    # When the output is under min_chars, langdetect is unreliable — but
    # we can still catch gross drift by checking the script. For non-Latin
    # scripts (Cyrillic → ru/uk/bg, CJK → zh/ja/ko, Arabic → ar/he, etc.)
    # the character set itself is decisive. For Latin-script mismatches
    # (e.g. English vs Swedish) we fall back to `skipped`.
    if len(output_text) < min_chars:
        script_verdict = _heuristic_script_check(output_text, expected_lang)
        if script_verdict is not None:
            return script_verdict
        return {
            "status": "skipped",
            "detected": "",
            "expected": expected_lang,
            "reason": f"output too short for reliable detection (<{min_chars} chars)",
            "confidence": 0.0,
        }

    if not _looks_like_content(output_text):
        return {
            "status": "skipped",
            "detected": "",
            "expected": expected_lang,
            "reason": "output is not prose",
            "confidence": 0.0,
        }

    detected = detect_content_language(
        output_text,
        fallback="",
        min_chars=min_chars,
        min_confidence=min_confidence,
    )

    if not detected:
        return {
            "status": "unknown",
            "detected": "",
            "expected": expected_lang,
            "reason": "language detection inconclusive",
            "confidence": 0.0,
        }

    if detected == expected_lang:
        return {
            "status": "match",
            "detected": detected,
            "expected": expected_lang,
            "reason": "output matches expected language",
            "confidence": 0.9,
        }

    return {
        "status": "mismatch",
        "detected": detected,
        "expected": expected_lang,
        "reason": (
            f"LLM produced {language_name(detected)} but directive requested "
            f"{language_name(expected_lang)}"
        ),
        "confidence": 0.9,
    }


# Dominant scripts per language — used by the short-text heuristic.
# Each entry maps an ISO code to a set of Unicode script names (as
# returned by `unicodedata.name(c).split()[0]`). A language can have
# multiple scripts (e.g. Japanese = Hiragana + Katakana + Han).
_LANG_SCRIPTS: dict[str, set[str]] = {
    "en": {"LATIN"}, "de": {"LATIN"}, "fr": {"LATIN"}, "es": {"LATIN"},
    "it": {"LATIN"}, "pt": {"LATIN"}, "nl": {"LATIN"}, "sv": {"LATIN"},
    "no": {"LATIN"}, "da": {"LATIN"}, "fi": {"LATIN"}, "pl": {"LATIN"},
    "cs": {"LATIN"}, "hu": {"LATIN"}, "tr": {"LATIN"}, "ro": {"LATIN"},
    "id": {"LATIN"}, "vi": {"LATIN"},
    "ru": {"CYRILLIC"}, "uk": {"CYRILLIC"}, "bg": {"CYRILLIC"},
    "sr": {"CYRILLIC", "LATIN"},
    "zh": {"CJK", "HAN"}, "ja": {"HIRAGANA", "KATAKANA", "CJK", "HAN"},
    "ko": {"HANGUL", "CJK", "HAN"},
    "ar": {"ARABIC"}, "he": {"HEBREW"},
    "hi": {"DEVANAGARI"}, "th": {"THAI"},
    "el": {"GREEK"},
}


def _dominant_script(text: str) -> str:
    """Return the most common Unicode script in `text`, or empty string."""
    import unicodedata
    counts: dict[str, int] = {}
    for ch in text:
        if not ch.isalpha():
            continue
        try:
            name = unicodedata.name(ch)
        except ValueError:
            continue
        tokens = name.split()
        if not tokens:
            continue
        script = tokens[0]
        # Normalize CJK variants into "CJK"
        if script == "CJK" or "CJK" in tokens:
            script = "CJK"
        counts[script] = counts.get(script, 0) + 1
    if not counts:
        return ""
    return max(counts.items(), key=lambda kv: kv[1])[0]


def _heuristic_script_check(
    text: str, expected_lang: str,
) -> dict[str, Any] | None:
    """Char-level script comparison — fast fallback for short outputs.

    Returns a verdict dict when the heuristic can make a confident call
    (e.g. expected Swedish, got Cyrillic → mismatch), or None when it
    can't tell the difference (e.g. expected German, got English — both
    Latin script). Caller falls through to `skipped` on None.
    """
    expected_scripts = _LANG_SCRIPTS.get(expected_lang)
    if not expected_scripts:
        return None  # unknown expected script — can't heuristically check

    dominant = _dominant_script(text)
    if not dominant:
        return None  # no letters — fall through to skipped

    if dominant in expected_scripts:
        # Script matches — tentative match, but low confidence since we
        # didn't verify the specific language (English vs Swedish both LATIN).
        return {
            "status": "match",
            "detected": expected_lang,
            "expected": expected_lang,
            "reason": f"short output, dominant script {dominant} matches expected",
            "confidence": 0.5,
        }

    return {
        "status": "mismatch",
        "detected": "",
        "expected": expected_lang,
        "reason": (
            f"short output uses {dominant} script but expected language "
            f"{language_name(expected_lang)} uses {'/'.join(sorted(expected_scripts))}"
        ),
        "confidence": 0.8,
    }


# Cross-language canonical mappings for common entities.
# Used by cross_language_aliases() to seed entity registry aliases.
# Keyed by the canonical English form; values are ISO 639-1 code → native form.
_CANONICAL_ENTITY_FORMS: dict[str, dict[str, str]] = {
    "Stockholm": {"ru": "Стокгольм", "zh": "斯德哥尔摩", "ja": "ストックホルム", "ar": "ستوكهولم", "ko": "스톡홀름"},
    "Beijing":   {"zh": "北京", "ru": "Пекин", "ja": "北京", "ko": "베이징"},
    "Moscow":    {"ru": "Москва", "zh": "莫斯科", "ja": "モスクワ", "ar": "موسكو"},
    "Tokyo":     {"ja": "東京", "zh": "东京", "ru": "Токио", "ko": "도쿄"},
    "London":    {"ru": "Лондон", "zh": "伦敦", "ja": "ロンドン", "ar": "لندن"},
    "Paris":     {"ru": "Париж", "zh": "巴黎", "ja": "パリ", "ar": "باريس"},
    "Berlin":    {"ru": "Берлин", "zh": "柏林", "ja": "ベルリン"},
    "New York":  {"ru": "Нью-Йорк", "zh": "纽约", "ja": "ニューヨーク", "ar": "نيويورك"},
    "Kyiv":      {"ru": "Киев", "uk": "Київ", "zh": "基辅", "ja": "キーウ"},
    "China":     {"zh": "中国", "ru": "Китай", "ja": "中国", "ko": "중국"},
    "Russia":    {"ru": "Россия", "zh": "俄罗斯", "ja": "ロシア", "ar": "روسيا"},
    "Japan":     {"ja": "日本", "zh": "日本", "ru": "Япония", "ko": "일본"},
    "Germany":   {"de": "Deutschland", "ru": "Германия", "zh": "德国", "ja": "ドイツ"},
    "Sweden":    {"sv": "Sverige", "ru": "Швеция", "zh": "瑞典"},
    "Finland":   {"fi": "Suomi", "sv": "Finland", "ru": "Финляндия"},
    "Ukraine":   {"uk": "Україна", "ru": "Украина", "zh": "乌克兰"},
    "United States": {"zh": "美国", "ru": "США", "ja": "アメリカ合衆国", "ko": "미국", "ar": "الولايات المتحدة"},
    "European Union": {"de": "Europäische Union", "fr": "Union européenne", "es": "Unión Europea", "sv": "Europeiska unionen", "ru": "Европейский союз", "zh": "欧洲联盟"},
    "United Nations": {"fr": "Nations unies", "es": "Naciones Unidas", "ru": "Организация Объединённых Наций", "zh": "联合国", "ar": "الأمم المتحدة"},
}


# Vault-editable extensions to the canonical entity table.
# Populated at startup via load_user_aliases() from a YAML file in the
# user's vault. Merged into lookups so users can grow the list without
# touching code.
_USER_ENTITY_FORMS: dict[str, dict[str, str]] = {}
_USER_ALIASES_SOURCE: str = ""  # path the aliases were last loaded from


def load_user_aliases(path: object) -> int:
    """Load user-defined cross-language aliases from a YAML file.

    Expected format (same shape as the built-in `_CANONICAL_ENTITY_FORMS`):

        Stockholm:
          sv: Stockholm
          ru: Стокгольм
          zh: 斯德哥尔摩
        Archer Futura Technologies AB:
          sv: Archer Futura Technologies AB
          ja: アーチャー・フトゥーラ・テクノロジーズ

    The user's aliases merge on top of the built-in table: matching
    canonical keys get their alias dict extended (user values win on key
    collision so the user can override a built-in transliteration).

    Returns the number of canonical entries loaded. Never raises —
    malformed YAML or a missing file logs a debug message and returns 0.
    """
    global _USER_ENTITY_FORMS, _USER_ALIASES_SOURCE

    if path is None:
        _USER_ENTITY_FORMS = {}
        _USER_ALIASES_SOURCE = ""
        return 0

    try:
        from pathlib import Path as _Path
        p = _Path(str(path))
        if not p.is_file():
            _USER_ENTITY_FORMS = {}
            _USER_ALIASES_SOURCE = str(p)
            return 0
        import yaml  # type: ignore
        with open(p, encoding="utf-8") as f:
            data = yaml.safe_load(f) or {}
    except Exception:
        log.debug("Failed to load entity_aliases.yaml", exc_info=True)
        return 0

    if not isinstance(data, dict):
        return 0

    cleaned: dict[str, dict[str, str]] = {}
    for canonical, forms in data.items():
        if not isinstance(canonical, str) or not isinstance(forms, dict):
            continue
        form_map: dict[str, str] = {}
        for lang, val in forms.items():
            if isinstance(lang, str) and isinstance(val, str) and val:
                form_map[lang] = val
        if form_map:
            cleaned[canonical] = form_map

    _USER_ENTITY_FORMS = cleaned
    _USER_ALIASES_SOURCE = str(p)
    return len(cleaned)


def _merged_entity_forms() -> dict[str, dict[str, str]]:
    """Return built-in + user forms merged, user values winning on collision."""
    if not _USER_ENTITY_FORMS:
        return _CANONICAL_ENTITY_FORMS
    merged: dict[str, dict[str, str]] = {k: dict(v) for k, v in _CANONICAL_ENTITY_FORMS.items()}
    for canonical, forms in _USER_ENTITY_FORMS.items():
        existing = merged.get(canonical, {})
        existing.update(forms)
        merged[canonical] = existing
    return merged


def cross_language_aliases(canonical_name: str) -> list[str]:
    """Return known cross-language aliases for a canonical entity name.

    Spec §19 item 6 — cross-language entity resolution seed list. Used by the
    entity registry to auto-register aliases when a known entity is added, so
    "Stockholm" / "Стокгольм" / "斯德哥尔摩" resolve to the same slug.

    Returns an empty list if the name is not in the canonical set. Lookup is
    case-insensitive.
    """
    if not canonical_name:
        return []
    forms_table = _merged_entity_forms()
    # Exact match first
    if canonical_name in forms_table:
        return list(forms_table[canonical_name].values())
    # Case-insensitive fallback
    lower = canonical_name.lower()
    for key, forms in forms_table.items():
        if key.lower() == lower:
            return list(forms.values())
    return []


def canonical_from_alias(alias: str) -> str | None:
    """Reverse lookup: given any cross-language form, return the canonical English name.

    'Стокгольм' → 'Stockholm', '北京' → 'Beijing'. Case-insensitive exact match.
    Returns None if the alias is not recognized.
    """
    if not alias:
        return None
    for canonical, forms in _merged_entity_forms().items():
        if alias == canonical:
            return canonical
        for form in forms.values():
            if form == alias:
                return canonical
    return None
