"""Prompt guard rail for the Automatic (text prompt) input box."""
from __future__ import annotations

import difflib
import re
import unicodedata

from .prompt_lexicon import (
    ENGLISH_OBJECT_WORDS,
    FOREIGN_STOPWORDS as _FOREIGN_STOPWORDS,
    FOREIGN_TO_ENGLISH as _FOREIGN_TO_ENGLISH,
)

# ---------------------------------------------------------------------------
# Prompt guard rail for the Automatic (cloud) text box.
#
# the cloud model segments a CONCEPT named by a short English noun phrase ("solar panel").
# Its paper says it is NOT for sentences, questions, or referring expressions,
# and Esri / Wherobots confirm it wants clear, countable objects. It also only
# understands ENGLISH: a prompt in any other language silently returns garbage,
# which reads as "the AI is bad" instead of "the prompt was off the rails".
#
# validate_prompt blocks the off-rails cases (sentences, referring expressions,
# abstract/subjective words, several objects at once, non-English input) and
# routes the user back to a 1-2 word English object, suggesting the closest
# known token - or the English translation - when it can. It also silently
# repairs what it CAN fix itself: known objects typed in another language
# (prompt_lexicon) and near-miss typos of known tokens ('buildin'), both via
# the (True, "translated", token) return. It is checked when the user COMMITS
# the prompt (Detect / Enter), not on every keystroke; the commit path in
# auto_state.py adds one server translation lookup for the words this file
# cannot resolve offline.
# ---------------------------------------------------------------------------

# One head noun plus at most one modifier; longer reads as a phrase the cloud model
# rejects. Counted after harmless quantifiers/articles are stripped.
_PROMPT_MAX_WORDS = 2
_PROMPT_MAX_CHARS = 30

# Harmless English quantifiers/articles: stripped, never blocked ("the
# buildings", "all cars" and "building" should behave identically).
_PROMPT_STRIP_WORDS = {"a", "an", "the", "all", "every", "each", "any", "my"}

# Sentence / command / question markers - the cloud model fails on these.
_PROMPT_COMMAND_WORDS = {
    "please", "find", "show", "detect", "segment", "give", "want", "need",
    "can", "could", "would", "select", "identify", "locate", "highlight",
    "where", "how", "what", "which", "extract", "get", "map", "mark",
    "draw", "outline", "make", "generate", "create", "count", "list",
    "i", "me", "you",
}
# Abstract head nouns with no visual concept.
_PROMPT_ABSTRACT = {
    "thing", "things", "stuff", "object", "objects", "item", "items",
    "feature", "features", "anything", "everything", "area", "areas",
    "region", "regions", "zone", "zones", "place", "places", "element",
}
# Subjective / non-visual qualities.
_PROMPT_SUBJECTIVE = {
    "cool", "nice", "beautiful", "interesting", "cozy", "expensive", "good",
    "bad", "important", "weird", "ugly", "pretty",
}
# Relational / referring-expression markers.
_PROMPT_REFERENTIAL = {
    "near", "next", "behind", "between", "without", "except", "that", "which",
    "who", "with", "around", "beside", "above", "below", "inside", "outside",
}

# Separators that mean "several objects at once" - the cloud model grounds ONE concept
# per run, so "building, tree" quietly biases toward garbage.
_MULTI_OBJECT_RE = re.compile(r"[,;/+&]| and | or ")


def _fold_ascii(text: str) -> str:
    """Accent-fold to plain ASCII ('bâtiment' -> 'batiment')."""
    return (
        unicodedata.normalize("NFKD", text)
        .encode("ascii", "ignore")
        .decode("ascii")
    )


def _prompt_known_tokens() -> list[str]:
    try:
        from ...core.presets.segmentation_presets import known_tokens
        return known_tokens()
    except Exception:  # noqa: BLE001
        return []


def _prompt_suggestion(norm: str, words: list[str]) -> str | None:
    """Closest known object token for an off-rails prompt, or None."""
    tokens = _prompt_known_tokens()
    if not tokens:
        return None
    word_set = set(words)
    # A multi-word token present verbatim (e.g. "swimming pool") wins outright.
    for tok in tokens:
        if " " in tok and tok in norm:
            return tok
    # A single-word token present as a whole word (avoid "car" in "cargo").
    for tok in tokens:
        if " " not in tok and tok in word_set:
            return tok
    # Otherwise the closest fuzzy match on any word.
    best, best_ratio = None, 0.0
    for w in words:
        for m in difflib.get_close_matches(w, tokens, n=1, cutoff=0.72):
            ratio = difflib.SequenceMatcher(None, w, m).ratio()
            if ratio > best_ratio:
                best, best_ratio = m, ratio
    return best


def _english_suggestion(folded: str, words: list[str]) -> str | None:
    """English translation for a non-English prompt, or None. The whole
    phrase is tried first ("panneau solaire"), then each word."""
    phrase = _FOREIGN_TO_ENGLISH.get(folded)
    if phrase:
        return phrase
    for w in words:
        hit = _FOREIGN_TO_ENGLISH.get(w)
        if hit:
            return hit
    return None


# Leading articles in the supported languages, stripped before the silent
# translation lookup so "la piscine" resolves like "piscine".
_LEAD_ARTICLES = {
    "the", "a", "an", "le", "la", "les", "l", "un", "une", "des", "du",
    "el", "los", "las", "una", "unos", "unas", "o", "os", "um", "uma",
    "uns", "umas", "il", "lo", "gli", "i", "der", "die", "das", "ein",
    "eine", "de", "d",
}


def _localized_label_index() -> dict[str, str]:
    try:
        from ...core.presets.segmentation_presets import token_by_localized_label
        return token_by_localized_label()
    except Exception:  # noqa: BLE001
        return {}


def _lookup_variants(phrase: str) -> list[str]:
    """The phrase itself plus a naive singular (trailing s/x stripped per
    word), so "piscines" and "panneaux solaires" both resolve."""
    words = phrase.split(" ")
    singular = " ".join(
        w[:-1] if len(w) > 3 and w[-1] in "sx" else w for w in words)
    return [phrase] if singular == phrase else [phrase, singular]


def english_token_for(text: str) -> str | None:
    """Silent translation: resolve a prompt typed in a supported UI language
    to its English cloud-model token, or None when unknown.

    Sources, in order: the catalogue's own localized labels (en/fr/es/pt -
    scales with the library, nothing to maintain) then the common-word
    dictionary above. Tolerant to case, accents, extra spaces, a leading
    article, and naive plurals.
    """
    norm = re.sub(r"\s+", " ", (text or "")).strip().lower().strip("?.!,;:")
    folded = _fold_ascii(norm)
    words = [w for w in folded.split(" ") if w]
    while words and words[0] in _LEAD_ARTICLES:
        words = words[1:]
    if not words:
        return None
    candidate = " ".join(words)
    index = _localized_label_index()
    for probe in _lookup_variants(candidate):
        hit = index.get(probe) or _FOREIGN_TO_ENGLISH.get(probe)
        if hit:
            return hit
    return None


_VOCAB_CACHE: set[str] | None = None


def _known_vocabulary() -> set[str]:
    """English words the typo corrector treats as already correct: catalogue
    tokens, every translation target of the lexicon, the curated English
    object list, and each individual word of the multi-word phrases. Cached
    once the catalogue tokens are importable."""
    global _VOCAB_CACHE
    if _VOCAB_CACHE is not None:
        return _VOCAB_CACHE
    vocab = set(ENGLISH_OBJECT_WORDS)
    vocab.update(_FOREIGN_TO_ENGLISH.values())
    tokens = _prompt_known_tokens()
    vocab.update(tokens)
    for phrase in list(vocab):
        vocab.update(phrase.split(" "))
    if tokens:  # a failed catalogue import stays retryable next call
        _VOCAB_CACHE = vocab
    return vocab


def _word_is_known(word: str, vocab: set[str]) -> bool:
    """The word, or its naive singular, is a recognized English object word."""
    if word in vocab or word in _PROMPT_STRIP_WORDS:
        return True
    return len(word) > 3 and word[-1] in "sx" and word[:-1] in vocab


def is_known_object(text: str) -> bool:
    """True when every core word of the prompt is a recognized English object
    word. The commit path uses this to decide whether a VALID-looking prompt
    still deserves the one-off server translation lookup (a language the
    offline lexicon does not cover, or a rare English word: both pass the
    guard, only the former needs rewriting)."""
    norm = re.sub(r"\s+", " ", (text or "")).strip().lower().strip("?.!,;:")
    words = [w for w in _fold_ascii(norm).split(" ") if w]
    core = [w for w in words if w not in _PROMPT_STRIP_WORDS] or words
    if not core:
        return True
    vocab = _known_vocabulary()
    return all(_word_is_known(w, vocab) for w in core)


def _typo_correction(words: list[str]) -> str | None:
    """Silent repair for a committed prompt that LOOKS valid but is not a
    known object word: 'buildin' -> 'building' (fuzzy), 'sol' -> 'solar
    panel' (unique prefix), 'batimen' -> 'building' (fuzzy against the
    foreign lexicon keys). Returns the token to run, or None when the prompt
    is already fine or no safe repair exists. Known English words are never
    rewritten (see ENGLISH_OBJECT_WORDS: 'cart' must stay 'cart')."""
    vocab = _known_vocabulary()
    core = [w for w in words if w not in _PROMPT_STRIP_WORDS] or words
    if len(core) > _PROMPT_MAX_WORDS:
        return None
    if all(_word_is_known(w, vocab) for w in core):
        return None
    candidate = " ".join(core)
    pool = sorted(set(_prompt_known_tokens()) | set(_FOREIGN_TO_ENGLISH.values()))
    if not pool:
        return None
    if len(candidate) >= 3:
        prefixed = [t for t in pool if t.startswith(candidate)]
        if len(prefixed) == 1:
            return prefixed[0]
    close = difflib.get_close_matches(candidate, pool, n=1, cutoff=0.8)
    if close:
        return close[0]
    close = difflib.get_close_matches(
        candidate, list(_FOREIGN_TO_ENGLISH), n=1, cutoff=0.84)
    if close:
        return _FOREIGN_TO_ENGLISH[close[0]]
    return None


def _looks_foreign(raw_norm: str, folded: str, folded_words: list[str]) -> bool:
    """True when the prompt is written in another language or script."""
    # Whole known phrase first ("panneaux solaires").
    if folded in _FOREIGN_TO_ENGLISH:
        return True
    # Any letter beyond the extended-Latin block (Cyrillic, CJK, Arabic,
    # Greek...) is never an English object word.
    if any(c.isalpha() and ord(c) > 0x024F for c in raw_norm):
        return True
    # Accented Latin letters: English cloud-model tokens are pure ASCII, and the
    # accented word almost always has a translation in the map above.
    if any(c.isalpha() and ord(c) > 0x7F for c in raw_norm):
        return True
    # Pure-ASCII prompts in fr/es/pt/de/it: caught by their function words
    # ("los edificios") or by a known object word ("batiment").
    if any(w in _FOREIGN_STOPWORDS for w in folded_words):
        return True
    return any(w in _FOREIGN_TO_ENGLISH for w in folded_words)


def validate_prompt(text: str) -> tuple[bool, str | None, str | None]:
    """Validate the committed cloud-model prompt.

    Returns ``(ok, reason, suggestion)``. ``reason`` is a short key the dock
    maps to a localized hint ("empty" / "weird" / "language" / "multi" /
    "sentence" / "referential" / "subjective" / "abstract" / "too_long");
    ``suggestion`` is the closest known object token (or the English
    translation for a non-English prompt) when one is obvious.

    Special case: ``(True, "translated", token)`` means the prompt is a KNOWN
    object typed in another supported language (or a naive plural) - the run
    should silently use ``token`` instead. Otherwise ``ok`` is True only for
    a clean 1-2 word English object the model can ground.
    """
    raw = (text or "").strip()
    if not raw:
        return (False, "empty", None)
    norm = re.sub(r"\s+", " ", raw).strip().lower().strip("?.!,;:")
    if not norm:
        return (False, "empty", None)
    words = [w for w in norm.split(" ") if w]
    folded = _fold_ascii(norm)
    folded_words = [w for w in folded.split(" ") if w]

    # Silent translation first: a known object word in the user's language is
    # VALID - the run just sends the English token (returned as ``suggestion``
    # under the "translated" reason; the dock swaps it in and tells the user).
    token = english_token_for(raw)
    if token and token != norm:
        return (True, "translated", token)

    # Untranslatable non-English input: every later hint assumes English
    # vocabulary, so explain the language rule instead of misdiagnosing.
    if _looks_foreign(norm, folded, folded_words):
        suggestion = _english_suggestion(folded, folded_words)
        if suggestion:
            # Snap the translation to the catalogue token when one matches.
            suggestion = _prompt_suggestion(
                suggestion, suggestion.split(" ")) or suggestion
        return (False, "language", suggestion)

    letters = sum(c.isalpha() for c in norm)
    if letters < max(2, (len(norm) + 1) // 2):
        return (False, "weird", _prompt_suggestion(norm, words))
    # Vowel-less letter mashes ("df", "sdfk") are keyboard noise, not objects.
    # Catalogue tokens are exempt so a legitimate acronym preset still passes.
    if norm not in set(_prompt_known_tokens()) and any(
            len(w) >= 2 and not any(c in "aeiouy" for c in w) for w in words):
        return (False, "weird", _prompt_suggestion(norm, words))
    # Several objects at once ("building, tree" / "cars and trucks").
    if _MULTI_OBJECT_RE.search(" " + norm + " "):
        first = re.split(r"[,;/+&]| and | or ", norm)[0].strip()
        first_words = [w for w in first.split(" ") if w]
        return (False, "multi", _prompt_suggestion(first, first_words))
    if "?" in raw or any(w in _PROMPT_COMMAND_WORDS for w in words):
        return (False, "sentence", _prompt_suggestion(norm, words))
    if any(w in _PROMPT_REFERENTIAL for w in words):
        return (False, "referential", _prompt_suggestion(norm, words))
    if any(w in _PROMPT_SUBJECTIVE for w in words):
        return (False, "subjective", _prompt_suggestion(norm, words))
    if any(w in _PROMPT_ABSTRACT for w in words):
        return (False, "abstract", _prompt_suggestion(norm, words))
    # Quantifiers/articles are free: "the buildings" == "buildings".
    core_words = [w for w in words if w not in _PROMPT_STRIP_WORDS] or words
    if len(core_words) > _PROMPT_MAX_WORDS or len(norm) > _PROMPT_MAX_CHARS:
        return (False, "too_long", _prompt_suggestion(norm, words))
    # Last: a valid-LOOKING prompt may still be a typo of a known token
    # ('buildin', 'solar panle'). Repair it through the same silent-swap
    # channel as the language translation, so the user sees what will run.
    correction = _typo_correction(folded_words)
    if correction and correction != norm:
        return (True, "translated", correction)
    return (True, None, None)
