# SPDX-License-Identifier: Apache-2.0
"""Locale grammars (SPEC §7): spoken phrases → language-neutral intents.

A locale is data (protocol/locales/<tag>.json). The text is normalised to lowercase words
separated by single spaces, with a space at each end, so patterns match whole words with
literal spaces instead of the dialect-dependent `\\b`, `\\w` and `\\d`. Patterns use the
regular-expression subset common to Python `re` and ECMAScript, with no capturing groups
of their own: `{count}`, `{unit}` and `{invocation}` expand from the locale and profile.
First matching rule wins; one utterance yields at most one intent. Rules that lower (lower, forget,
shorten, object) MUST come before rules that raise (raise, resume), so a sentence that does both lowers.
"""
from __future__ import annotations

import json
import re
import unicodedata
from dataclasses import dataclass
from pathlib import Path

KINDS = ("lower", "raise", "forget", "shorten", "resume", "object")
EXTENSION_KINDS = ("claim",)
TOKENS = ("{count}", "{unit}")


@dataclass(frozen=True)
class Intent:
    kind: str                       # one of KINDS (or an extension kind)
    scope: str = "room"             # "self" (the speaker's own record) or "room" (everyone in the session)
    level: int | None = None        # lower / raise
    seconds: float | None = None    # forget: span back from now (None = all); shorten: the limit
    addressed: bool = False         # the recorder's invocation name was spoken, or an explicit action (UI, DTMF)
    source: str = "speech"          # speech | ui | dtmf | sdp | preference | exchange (SPEC §7.1)


def load_locale(src: str | Path | dict) -> dict:
    return src if isinstance(src, dict) else json.loads(Path(src).read_text())


def resolve_locales(tags, available: dict) -> list[dict]:
    """BCP 47 truncation fallback (CLDR): es-MX → es. `available` maps tags to loaded grammars."""
    out: list[dict] = []
    for tag in tags:
        parts = tag.split("-")
        while parts and "-".join(parts) not in available:
            parts.pop()
        if parts and available["-".join(parts)] not in out:
            out.append(available["-".join(parts)])
    return out


def normalize(text: str, loc: dict) -> str:
    """Lowercase; optional accent folding; every run of non-letters/digits (apostrophes kept) → one space; padded."""
    n = loc.get("normalize", {})
    t = text.lower()
    for a, b in n.get("replace", []):
        if a:
            t = t.replace(a, b)
    if n.get("fold_accents"):
        t = "".join(c for c in unicodedata.normalize("NFD", t) if not unicodedata.category(c).startswith("M"))
    out, gap = [], True
    for c in t:
        if c == "'" or unicodedata.category(c)[0] in "LN":
            out.append(c)
            gap = False
        elif not gap:
            out.append(" ")
            gap = True
    return " " + "".join(out).strip() + " "


def _alt(words) -> str:
    return "|".join(re.escape(w) for w in sorted(words, key=len, reverse=True)) or "(?!)"


def _compile(pattern: str, loc: dict, invocation) -> tuple[re.Pattern, list[str]]:
    order = sorted((pattern.index(t), t) for t in TOKENS if t in pattern)
    p = pattern.replace("{invocation}", "(?:" + _alt(normalize(x, loc).strip() for x in invocation) + ")")
    p = p.replace("{count}", "([0-9]+|" + _alt(loc.get("numbers", {})) + ")")
    p = p.replace("{unit}", "(" + _alt(loc.get("units", {})) + ")")
    return re.compile(p), [t for _, t in order]


def parse(text: str, locales: list[dict], invocation=(), extensions=()) -> list[Intent]:
    """The first rule that matches, in locale order then rule order. Returns [] or [intent].
    Rules marked with an `extension` apply only when the profile enables it."""
    for loc in locales:
        t = normalize(text, loc)
        addressed = bool(invocation) and re.search(" (?:" + _alt(normalize(x, loc).strip() for x in invocation) + ") ", t) is not None
        for r in loc["rules"]:
            if r.get("extension") and r["extension"] not in extensions:
                continue
            rx, toks = _compile(r["match"], loc, invocation)
            m = rx.search(t)
            if not m:
                continue
            spec = r["intent"]
            seconds = spec.get("seconds")
            if toks:
                got = dict(zip(toks, m.groups()))
                c = got.get("{count}")
                n = 1 if c is None else (int(c) if c.isascii() and c.isdigit() else loc["numbers"].get(c))
                u = loc["units"].get(got.get("{unit}")) if "{unit}" in got else None
                if n is None or ("{unit}" in got and u is None):
                    continue
                seconds = n * u if u is not None else seconds
            return [Intent(spec["kind"], spec.get("scope", "room"), spec.get("level"), seconds, addressed)]
    return []


LOWERING = ("lower", "forget", "shorten", "object")


def lint(loc: dict) -> list[str]:
    """Problems with a locale grammar (SPEC §7.2): rule order, forbidden pattern features, missing placeholders."""
    out, seen_raise = [], None
    for r in loc.get("rules", []):
        k, m = r["intent"]["kind"], r["match"]
        if k in ("raise", "resume") and seen_raise is None:
            seen_raise = r["id"]
        if k in LOWERING and seen_raise:
            out.append(f"{r['id']}: a lowering rule after the raising rule {seen_raise}")
        if re.search(r"\\[bBwWdD]", m):
            out.append(f"{r['id']}: uses \\b, \\w or \\d")
        if re.search(r"\((?!\?)", m.replace("\\(", "")):
            out.append(f"{r['id']}: has a capturing group")
        if r["intent"].get("scope") not in ("self", "room"):
            out.append(f"{r['id']}: scope must be self or room")
    return out
