"""MovementDispatcher — Gemini voice → locomotion (N2 Phase 3).

The Marcus phrase-confirmation pattern, ported to Sanad. Gemini Live runs in a
child subprocess; the parent supervisor (gemini/subprocess.py) parses Gemini's
OWN spoken output into BOT: transcript lines and fires `on_bot_text(line)` here.

Flow:
  Gemini speaks a canonical confirmation phrase ("Turning right." / "أستدير
  يميناً.")  →  on_bot_text() matches it against data/motions/instruction.json
  →  enqueues a canonical command  →  a worker thread drives loco_controller
  (discrete steps that self-terminate with StopMove).

Gating: every dispatch is gated on `recognition_state.movement_enabled` (the
"Enable Gemini movement" dashboard toggle) — SEPARATE from the manual
"Enable movement" arm flag (loco_controller._armed). When the flag is off,
matches are dropped. "stop" is honoured immediately (cross-thread abort + drain
+ E-STOP) regardless of cooldown.

Safety: discrete `loco.step()` self-stops; velocity caps live in LocoController;
N-step / N-degree commands are bounded by instruction.json (max_steps,
max_degrees) and check the abort flag + enable gate between each step. Numbers
are kept verbatim but the degrees→steps and steps mapping is APPROXIMATE and
must be calibrated on the real robot.
"""

from __future__ import annotations

import json
import queue
import re
import threading
import time
from pathlib import Path
from typing import Any, List, Optional

from Project.Sanad.core.logger import get_logger

log = get_logger("movement_dispatch")

_SENTINEL = object()
_STATE_CACHE_TTL = 0.5   # seconds — re-read recognition_state at most this often

# Map a resolved canonical command to a LocoController discrete-step direction.
_FIXED_STEP = {
    "move forward": "forward",
    "move backward": "backward",
    "turn right": "rotate_right",
    "turn left": "rotate_left",
    "slide left": "slide_left",
    "slide right": "slide_right",
}

# ── transcript cleaning (Marcus pattern) ──────────────────────────────────────
# Gemini's spoken text can CONTAIN our trigger phrases without intending a
# command — inside a question ("do you want me to move forward?"), a negation
# ("I'm not turning right"), a hypothetical ("I would be turning right"), a
# quote, or an echoed [STATE] tag. We drop those whole clauses before matching so
# only genuine confirmations actuate the robot.
_BRACKET_RE = re.compile(r"\[[^\]]*\]")                       # [STATE-DONE] echoes
_QUOTE_RE = re.compile(r"[\"'«»“”„‟‹›][^\"'«»“”„‟‹›]{0,80}?[\"'«»“”„‟‹›]")
_SENT_SPLIT_RE = re.compile(r"([.!?؟؛\n]+)")                  # keep delimiters
# NOTE: Arabic tokens are whitespace-delimited so we don't match a negation
# substring inside a real word — e.g. "ما" lives inside "أمام" (forward), "لا"
# inside many words. \b doesn't help for Arabic (all letters are \w), so we
# anchor on spaces/string-edges explicitly.
_NEG_RE = re.compile(
    r"\b(?:not|never|without|cannot|would|could|should|might|instead|"
    r"going to|want to|trying to|rather than)\b|\w+n['’]t\b|"
    r"(?:^|\s)(?:لا|ما|لن|لم|مش|بدون|غير|لست|ليس|بدل)(?:\s|$)")


class MovementDispatcher:
    def __init__(self, loco, instruction_path: Path, state_path: Path):
        self._loco = loco
        self._instruction_path = Path(instruction_path)
        self._state_path = Path(state_path)

        self._queue: "queue.Queue[Any]" = queue.Queue(maxsize=32)
        self._abort = threading.Event()
        self._worker: Optional[threading.Thread] = None
        self._running = False
        # Hard-stop latch set by a dashboard E-STOP. Drops all voice commands
        # until cleared (by re-enabling Gemini movement). Kept SEPARATE from the
        # movement_enabled file flag so an E-STOP doesn't trigger the spoken
        # "movement disabled" announcement.
        self._estop = False

        # dedup / cooldown
        self._last_canon = ""
        self._last_at = 0.0

        # cached enable-flag
        self._enabled_cached = False
        self._enabled_at = 0.0

        # config (filled by _load)
        self._cooldown = 1.5
        self._max_steps = 8
        self._max_degrees = 360
        self._deg_per_step = 15
        self._fixed_patterns: List[tuple] = []   # (needle_lower, canonical) sorted long→short
        self._parametric: List[tuple] = []        # (compiled_regex, template)

        self._load()

    # ── instruction.json ─────────────────────────────────────────────────────

    def _load(self):
        try:
            data = json.loads(self._instruction_path.read_text(encoding="utf-8"))
        except Exception as exc:
            log.error("could not load %s: %s — dispatcher inert", self._instruction_path, exc)
            data = {}
        self._cooldown = float(data.get("command_cooldown_sec", 1.5))
        self._max_steps = int(data.get("max_steps", 8))
        self._max_degrees = int(data.get("max_degrees", 360))
        self._deg_per_step = max(1, int(data.get("degrees_per_step", 15)))

        needles: List[tuple] = []
        for spec in (data.get("actions") or {}).values():
            canonical = spec.get("canonical", "")
            phrases = spec.get("bot_phrases", {}) or {}
            for lang_list in phrases.values():
                for p in lang_list:
                    if p:
                        # English folded to lower; Arabic unaffected by .lower()
                        needles.append((p.lower(), canonical))
        # longest needle first so "walking forward" wins over "forward"
        needles.sort(key=lambda t: len(t[0]), reverse=True)
        self._fixed_patterns = needles

        self._parametric = []
        for pa in (data.get("parametric_actions") or []):
            try:
                self._parametric.append((re.compile(pa["regex"], re.IGNORECASE), pa["canonical"]))
            except re.error as exc:
                log.warning("bad parametric regex %r: %s", pa.get("regex"), exc)
        log.info("instruction.json loaded: %d fixed phrases, %d parametric, cooldown=%.1fs",
                 len(self._fixed_patterns), len(self._parametric), self._cooldown)

    # ── lifecycle ─────────────────────────────────────────────────────────────

    def start(self):
        if self._running:
            return
        self._running = True
        self._worker = threading.Thread(target=self._worker_loop, daemon=True,
                                        name="movement-dispatch")
        self._worker.start()
        log.info("movement dispatcher started")

    def stop(self):
        self._running = False
        self._abort.set()
        try:
            self._queue.put_nowait(_SENTINEL)
        except queue.Full:
            pass

    def status(self) -> dict:
        return {
            "running": self._running,
            "movement_enabled": self._movement_enabled(force=True),
            "estopped": self._estop,
            "queue_depth": self._queue.qsize(),
            "fixed_phrases": len(self._fixed_patterns),
            "parametric": len(self._parametric),
        }

    # ── E-STOP latch ──────────────────────────────────────────────────────────

    def emergency_stop(self):
        """Latch off after a dashboard E-STOP: abort the in-flight command, drain
        the queue, and refuse new commands until clear_estop(). Does NOT touch the
        movement_enabled file flag (so the Gemini child stays quiet)."""
        self._estop = True
        self._abort.set()
        self._drain()
        log.warning("movement dispatch E-STOP latch set")

    def clear_estop(self):
        self._estop = False

    def is_estopped(self) -> bool:
        return self._estop

    # ── enable gate ───────────────────────────────────────────────────────────

    def _movement_enabled(self, force: bool = False) -> bool:
        now = time.monotonic()
        if not force and (now - self._enabled_at) < _STATE_CACHE_TTL:
            return self._enabled_cached
        try:
            from Project.Sanad.vision import recognition_state
            self._enabled_cached = bool(recognition_state.read(self._state_path).movement_enabled)
        except Exception:
            self._enabled_cached = False
        self._enabled_at = now
        return self._enabled_cached

    # ── transcript hook (called from the supervisor reader thread) ────────────

    def on_bot_text(self, text: str):
        if not text or not self._running or self._estop:
            return
        if not self._movement_enabled():
            return
        cmds = self._match(text)
        if not cmds:
            return
        now = time.monotonic()   # monotonic — immune to NTP/wall-clock jumps
        for c in cmds:
            if c == "stop":
                # Safety: preempt anything in flight immediately, then E-STOP.
                self._abort.set()
                self._drain()
                self._enqueue("stop")
                self._last_canon = "stop"
                self._last_at = now
                continue
            # cross-turn cooldown: same canonical not re-fired too soon
            if c == self._last_canon and (now - self._last_at) < self._cooldown:
                continue
            self._last_canon = c
            self._last_at = now
            self._enqueue(c)

    def _enqueue(self, cmd: str):
        try:
            self._queue.put_nowait(cmd)
        except queue.Full:
            log.warning("motion queue full — dropping %r", cmd)

    def _drain(self):
        try:
            while True:
                self._queue.get_nowait()
        except queue.Empty:
            pass

    # ── matcher ───────────────────────────────────────────────────────────────

    def _clean(self, text: str) -> str:
        """Drop clauses that are NOT genuine motion confirmations: bracketed
        [STATE] echoes, quoted spans, questions, and negation/hypothetical
        sentences. Only the surviving clauses are matched."""
        t = _BRACKET_RE.sub(" ", text)
        t = _QUOTE_RE.sub(" ", t)
        parts = _SENT_SPLIT_RE.split(t)
        kept: List[str] = []
        i = 0
        while i < len(parts):
            seg = parts[i].strip()
            delim = parts[i + 1] if i + 1 < len(parts) else ""
            is_question = ("?" in delim) or ("؟" in delim)
            if seg and not is_question and not _NEG_RE.search(seg.lower()):
                kept.append(seg)
            i += 2
        return " . ".join(kept)

    def _match(self, text: str) -> List[str]:
        """Return canonical commands in spoken order. Parametric (with numbers)
        claim their spans first so a bare phrase doesn't double-fire."""
        low = self._clean(text).lower()
        matches: List[tuple] = []      # (start, canonical)
        claimed: List[tuple] = []      # (start, end) spans already taken

        def overlaps(s, e):
            return any(s < ce and cs < e for cs, ce in claimed)

        # 1) parametric first. Claim the span even when the quantity is zero so a
        # mis-heard "0 steps" suppresses the bare phrase underneath (no surprise
        # motion) rather than falling through to a single step.
        for rx, template in self._parametric:
            for m in rx.finditer(low):
                if overlaps(*m.span()):
                    continue
                claimed.append(m.span())
                canonical = self._format(template, m.groups())
                if canonical:
                    matches.append((m.start(), canonical))

        # 2) fixed phrases (longest first), skipping claimed spans
        for needle, canonical in self._fixed_patterns:
            start = 0
            while True:
                j = low.find(needle, start)
                if j < 0:
                    break
                end = j + len(needle)
                if not overlaps(j, end):
                    matches.append((j, canonical))
                    claimed.append((j, end))
                start = end

        matches.sort(key=lambda t: t[0])
        # de-dup consecutive repeats within this single line
        out: List[str] = []
        for _, c in matches:
            if not out or out[-1] != c:
                out.append(c)
        return out

    @staticmethod
    def _format(template: str, groups) -> str:
        out = template
        for i, g in enumerate(groups, start=1):
            out = out.replace(f"${i}", str(g))
        # reject zero-quantity motions ("walk 0 steps")
        nums = re.findall(r"\d+", out)
        if nums and all(int(n) == 0 for n in nums):
            return ""
        return out

    # ── worker ────────────────────────────────────────────────────────────────

    def _worker_loop(self):
        while self._running:
            cmd = self._queue.get()
            if cmd is _SENTINEL:
                return
            if cmd != "stop":
                self._abort.clear()
            if self._estop:
                continue            # E-STOP latched — drop everything
            # force a fresh read — don't let the 0.5s cache execute a command
            # after the operator just toggled movement off.
            if cmd != "stop" and not self._movement_enabled(force=True):
                continue            # toggled off while queued — drop
            # Never step while Nav2 owns the legs (autonomous goal in progress).
            # Two stacks driving at once is the exact hazard _arbiter guards.
            # STOP always passes through (safety). Read-only check — manual loco
            # uses acquire_loco; the discrete-step voice path must only YIELD.
            if cmd != "stop" and self._nav_holds_legs():
                log.info("voice movement dropped — Nav2 owns the legs (%r)", cmd)
                continue
            try:
                self._execute(cmd)
            except Exception:
                log.exception("execute %r failed", cmd)

    def _execute(self, canonical: str):
        c = canonical.lower().strip()
        if c == "stop":
            log.info("voice → STOP")
            self._loco.estop()
            return

        m = re.match(r"walk (forward|backward) (\d+) steps?$", c)
        if m:
            direction = "forward" if m.group(1) == "forward" else "backward"
            n = min(int(m.group(2)), self._max_steps)
            log.info("voice → walk %s %d steps", direction, n)
            self._repeat_step(direction, n)
            return

        m = re.match(r"turn (right|left) (\d+) degrees?$", c)
        if m:
            direction = "rotate_right" if m.group(1) == "right" else "rotate_left"
            deg = min(int(m.group(2)), self._max_degrees)
            n = max(1, round(deg / self._deg_per_step))
            log.info("voice → turn %s %d° (~%d steps)", m.group(1), deg, n)
            self._repeat_step(direction, n)
            return

        direction = _FIXED_STEP.get(c)
        if direction:
            log.info("voice → %s", c)
            self._loco.step(direction)
            return
        log.debug("no loco mapping for canonical %r", c)

    @staticmethod
    def _nav_holds_legs() -> bool:
        """True if Nav2 currently owns the legs (in-process arbiter). Lazy
        import so a missing/absent dashboard package never breaks voice."""
        try:
            from Project.Sanad.dashboard.routes import _arbiter
            return _arbiter.nav_active()
        except Exception:
            return False

    def _repeat_step(self, direction: str, n: int):
        for _ in range(max(1, n)):
            if (self._abort.is_set() or self._estop
                    or not self._movement_enabled(force=True)
                    or self._nav_holds_legs()):
                log.info("voice multi-step aborted")
                break
            self._loco.step(direction)
