"""wivexx_flow_sim.py — conversation-FLOW simulator (v1.42+ mid-sentence commands).

Drives the REAL helpers from wivexx_api in the SAME order as `_handle_turn_impl`:
  detect_lang → canonicalize → pronoun-resolve → _is_dismiss → inline extract
  (EN then ES) → compound split → waterfall route → else chat.

The inline / dismiss / compound functions are the REAL ones under test. The final
per-command waterfall step is an AST-extracted regex router (same technique as
wivexx_convo_sim.py) so NO effectors run — no app launches, no volume changes, no
Ollama/Claude calls. Pure decision-path check.

Simulates MANY multi-turn conversation types (casual, gaming across several games,
work) in English AND Spanish, each turn asserting the expected flow outcome. A
mismatch = a conversation-flow bug.

Run:  python wivexx_flow_sim.py
"""
import ast, os, re, sys, tempfile

PROJ = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, PROJ)
try:
    sys.stdout.reconfigure(encoding="utf-8")
except Exception:
    pass

import wivexx_i18n as i18n
import wivexx_intent_gate as gate
import wivexx_api as w   # real functions under test (no effectors invoked here)

# Pin to the no-API / Ollama path the user cares about: with no key,
# `_to_english_for_commands` returns None for a mixed-Spanish turn (no Haiku
# translation), so a Spanish turn stays Spanish and the ES inline extractor owns
# it — exactly the production Ollama behavior. Also spares the user's tokens.
w._HAS_API = False
w._client = None
# Deterministic no-API canonicalization: never let a disk-cached full-sentence
# translation stand in for the real offline (quick_english-only) behavior.
i18n.cmd_cache_get = lambda text: None

# ── AST-extract the waterfall trigger regexes (route without side effects) ─────
FLAGS = {"IGNORECASE": re.IGNORECASE, "I": re.IGNORECASE, "VERBOSE": re.VERBOSE,
         "X": re.VERBOSE, "DOTALL": re.DOTALL, "MULTILINE": re.MULTILINE}
def _flag(node):
    if isinstance(node, ast.Attribute):
        return FLAGS.get(node.attr, 0)
    if isinstance(node, ast.BinOp):
        return _flag(node.left) | _flag(node.right)
    return 0
RX = {}
_tree = ast.parse(open(os.path.join(PROJ, "wivexx_api.py"), encoding="utf-8").read())
for node in ast.walk(_tree):
    if (isinstance(node, ast.Assign) and len(node.targets) == 1
            and isinstance(node.targets[0], ast.Name) and node.targets[0].id.endswith("_RE")
            and isinstance(node.value, ast.Call) and getattr(node.value.func, "attr", "") == "compile"
            and node.value.args and isinstance(node.value.args[0], ast.Constant)):
        fl = _flag(node.value.args[1]) if len(node.value.args) > 1 else 0
        try:
            RX[node.targets[0].id] = re.compile(node.value.args[0].value, fl)
        except re.error:
            pass

REGISTRY = {"discord", "spotify", "chrome", "steam", "obs", "lightroom",
            "premiere pro", "premiere", "notepad", "valorant", "league",
            "photoshop", "excel", "word", "slack", "zoom", "teams", "browser",
            "youtube", "twitch", "firefox", "edge", "vs code", "code"}

ORDER = [
    ("selfclose", "_SELF_CLOSE_RE"), ("capability", "_CAPABILITY_QUERY_RE"),
    ("timer_status", "_ACTIVE_TIMER_QUERY_RE"), ("time", "_CURRENT_TIME_RE"),
    ("date", "_CURRENT_DATE_RE"), ("version", "_VERSION_QUERY_RE"),
    ("tldr", "_LATEST_UPDATE_RE"), ("battery", "_BATTERY_RE"),
    ("workmode_on", "_WORK_MODE_ON_RE"), ("workmode_off", "_WORK_MODE_OFF_RE"),
    ("remember", "_REMEMBER_RE"), ("forget", "_FORGET_RE"),
    ("recall", "_WHAT_REMEMBER_RE"), ("goal_add", "_ADD_GOAL_RE"),
    ("goal_done", "_COMPLETE_GOAL_RE"), ("goals", "_GOALS_QUERY_RE"),
    ("social", "_SOCIAL_REPORT_RE"), ("caption", "_CAPTION_RE"),
    ("disk", "_DISK_ANALYZE_RE"), ("pending", "_PENDING_QUERY_RE"),
    ("lock", "_LOCK_RE"),
    ("shutdown_cancel", "_SHUTDOWN_CANCEL_RE"), ("shutdown", "_SHUTDOWN_RE"),
    ("joke", "_JOKE_RE"), ("random", "_RANDOM_RE"),
    ("youtube", "_YOUTUBE_RE"), ("note", "_NOTE_TRIGGER_RE"),
    ("media", "_MEDIA_KEY_RE"),
    ("audio_out", "_AUDIO_OUTPUT_RE"), ("volume", "_VOLUME_RE"),
    ("weather", "_WEATHER_TRIGGER_RE"), ("calc", "_CALC_RE"),
    ("clipboard", "_CLIP_RE"), ("winctl", "_WINCTL_GATE_RE"),
    ("cal_create", "_CAL_CREATE_RE"), ("cal_query", "_CAL_QUERY_RE"),
    ("gaming", "_GAMING_MODE_RE"), ("speedtest", "_SPEEDTEST_RE"),
    ("optimize", "_OPTIMIZE_RE"), ("hud", "_PERF_HUD_RE"),
    ("enroll", "_ENROLL_RE"), ("label", "_SCREEN_LABEL_RE"),
]
_PRON = {"it", "that", "that app", "the app", "them"}
_last_app = [None]

def route_one(q_en):
    ql = q_en.lower().strip(" .,!?")
    for name, rxname in ORDER:
        rx = RX.get(rxname)
        if rx and rx.search(ql):
            return name
    if (re.search(r"\b(take|capture|grab|snap)\b.*\bscreen\s?shot\b|\bscreenshot\b", ql)
            and gate.classify(q_en)["kind"] != "chat"):
        return "screenshot"
    if RX.get("_TIMER_TRIGGER_RE") and RX["_TIMER_TRIGGER_RE"].search(ql):
        return "timer"
    m = RX["_CLOSE_VERB_RE"].search(ql) if RX.get("_CLOSE_VERB_RE") else None
    if m:
        tgt = m.group(1).strip(" .,!?")
        if re.match(r"(?:everything|all(?:\s+(?:apps|windows|my\s+apps|programs|the\s+apps))?)$", tgt):
            return "close:all"           # close-all category (real handler, see wivexx_api)
        if tgt in _PRON:
            return "close:" + _last_app[0] if _last_app[0] else "MISS(close-pron)"
        names = [t.strip() for t in re.split(r"\s+and\s+", tgt)]
        hits = [n for n in names if any(v in n for v in REGISTRY)]
        if hits:
            _last_app[0] = hits[-1]; return "close:" + "+".join(hits)
    m = RX["_APP_INTENT_RE"].search(ql) if RX.get("_APP_INTENT_RE") else None
    if m and gate.classify(q_en)["kind"] != "chat":
        tgt = re.sub(r"[,\s]*(?:please|for me|now)\W*$", "", m.group(1).strip(" .,!?"))
        if tgt in _PRON:
            return "open:" + _last_app[0] if _last_app[0] else "MISS(open-pron)"
        names = [t.strip() for t in re.split(r"\s+and\s+", tgt)]
        hits = [n for n in names if any(v in n for v in REGISTRY)]
        if hits:
            _last_app[0] = hits[-1]; return "open:" + "+".join(hits)
    return "chat"

def split_compound(q):
    m = re.match(r"^(.{3,70}?)\s+(?:and\s+(?:then\s+|also\s+)?|then\s+)(.{3,90})$", q.strip(), re.I)
    if not m:
        return None
    a, b = m.group(1).strip(), m.group(2).strip()
    if a.split()[0].lower() in w._COMPOUND_VERBS and b.split()[0].lower() in w._COMPOUND_VERBS:
        return a, b
    return None

# ── the driver: mirrors _handle_turn_impl's ordering, real fns, no effectors ──
def simulate(utter):
    """Return a normalized outcome tuple describing how the real pipeline would
    route this turn: ('dismiss',) | ('inline', cmds, remainder) |
    ('multi', rA, rB) | ('cmd', route) | ('chat',)."""
    lang = i18n.detect_lang(utter)
    q = utter
    if lang != "en":
        try:
            qen = w._to_english_for_commands(utter)
            if qen and qen.strip():
                q = qen.strip()
        except Exception:
            pass
    # pronoun resolve (real)
    try:
        r = w._resolve_app_pronoun(q)
        if r:
            q = r
    except Exception:
        pass
    if w._is_dismiss(q):
        return ("dismiss",)
    # inline EN (real, on canonicalized text)
    inl = w._extract_inline_commands(q)
    inl_es = False
    if not inl and lang == "es":
        inl = w._extract_inline_commands_es(utter)
        inl_es = bool(inl)
    if inl:
        routes = [route_one(c if not inl_es else (w._to_english_for_commands(c) or c))
                  for c in inl["commands"]]
        return ("inline", inl["commands"], routes, inl["remainder"], inl_es)
    # compound
    parts = split_compound(q)
    if parts:
        return ("multi", route_one(parts[0]), route_one(parts[1]))
    # intent gate + waterfall
    verdict = gate.classify(q)["kind"]
    if verdict == "chat":
        return ("chat",)
    rt = route_one(q)
    return ("chat",) if rt == "chat" else ("cmd", rt)


# Conversation scripts. Each entry: (utterance, kind, detail)
#   kind "chat"    → must converse, no command (detail ignored)
#   kind "cmd"     → pure command; detail = route prefix that must match
#   kind "inline"  → mid-sentence; detail = (list of route prefixes, remainder substr)
#   kind "multi"   → compound; detail ignored (both halves must be commands)
#   kind "dismiss" → must dismiss
CONVOS = {
"EN · casual catch-up": [
    ("hey wivexx good morning, how'd you sleep", "chat", None),
    ("i had the weirdest dream about being late to school again", "chat", None),
    ("anyway what's the weather looking like today", "cmd", "weather"),
    ("man it's freezing in here, honestly i might just stay in", "chat", None),
    ("oh hey pause the music for a sec, i think someone's at the door", "inline", (["media"], "door")),
    ("false alarm, it was just the mailman, play the music again", "inline", (["media"], "mailman")),
    ("do you ever get bored just sitting in the tray all day", "chat", None),
    ("that's kind of deep for a monday honestly", "chat", None),
    ("what time is it anyway", "cmd", "time"),
    ("okay that's all for now, thanks", "dismiss", None),
],
"EN · gaming — Elden Ring": [
    ("dude this boss in elden ring is destroying me", "chat", None),
    ("i've died like 20 times to malenia already", "chat", None),
    ("turn up the volume, i want to hear the attack cues better, any tips for her", "inline", (["volume"], "tips")),
    ("should i go melee or should i try a mage build for this fight", "chat", None),
    ("okay take a screenshot of my build real quick so i remember it", "inline", (["screenshot"], "build")),
    ("ugh died again, mute the music, it's stressing me out", "inline", (["volume"], "stressing")),
    ("what's the best weapon against her waterfowl dance", "chat", None),
    ("open discord, my friend wants to co-op this boss", "inline", (["open:discord"], "co-op")),
    ("actually you know what let's take a break", "chat", None),
],
"EN · gaming — Valorant/FPS": [
    ("queuing up ranked in valorant, wish me luck", "chat", None),
    ("i main jett but everyone keeps instalocking her", "chat", None),
    ("launch discord so i can hear my squad, are you into shooters", "inline", (["open:discord"], "shooters")),
    ("bro this guy is cracked, he's hitting every headshot", "chat", None),
    ("turn it down a bit, my mom's asleep, what's a good crosshair setup", "inline", (["volume"], "crosshair")),
    ("we're on match point, take a screenshot if we win this", "inline", (["screenshot"], "win")),
    ("gg we clutched it, that was insane", "chat", None),
    ("set a timer for 10 minutes, quick break before next queue", "inline", (["timer"], "break")),
],
"EN · gaming — Minecraft/chill": [
    ("just chilling in minecraft building a base tonight", "chat", None),
    ("i found a huge cave system full of diamonds earlier", "chat", None),
    # honesty: "play some lofi" names specific content Wivexx can't queue (no music
    # integration), so it stays in chat rather than pressing play-nothing.
    ("play some lofi, this is perfect building music, what should i build next", "chat", None),
    ("play the music though, what should i build next", "inline", (["media"], "build")),
    ("i'm thinking a giant castle on the mountain", "chat", None),
    ("do creepers still blow up your builds in the new update", "chat", None),
    ("open spotify actually, i want my own playlist", "inline", (["open:spotify"], "playlist")),
    ("can't decide what to build, flip a coin for me", "cmd", "random"),
    ("roll a dice to see how many floors i dig", "cmd", "random"),
],
"EN · gaming — League of Legends": [
    ("i'm hardstuck silver in league of legends this split", "chat", None),
    ("my adc keeps feeding, turn up the volume so i can hear pings, any jungle tips", "inline", (["volume"], "jungle")),
    ("should i gank top or just farm my jungle right now", "chat", None),
    ("open discord, i need to shotcall for the team", "inline", (["open:discord"], "team")),
    ("flip a coin to decide if i dodge this game", "cmd", "random"),
],
"EN · casual — cooking": [
    ("i'm making carbonara tonight for the first time", "chat", None),
    ("set a timer for 12 minutes for the pasta, does it really need guanciale", "inline", (["timer"], "guanciale")),
    ("my hands are messy, next song please, this playlist is great", "inline", (["media"], "playlist")),
    ("what's the weather like, i might grill on the balcony instead", "cmd", "weather"),
    ("honestly cooking relaxes me after a long day", "chat", None),
],
"EN · casual — gym": [
    ("about to hit legs day, dreading it honestly", "chat", None),
    ("start a timer for 90 seconds, that's my rest between sets", "inline", (["timer"], "rest")),
    ("play the music, i need hype songs, whats a good pre workout", "inline", (["media"], "workout")),
    ("skip to the next song, this one's too slow", "inline", (["media"], "slow")),
    ("do you think creatine is actually worth it", "chat", None),
],
"EN · work — coding": [
    ("been debugging this null pointer for an hour", "chat", None),
    ("take a screenshot of this stack trace, what usually causes it", "inline", (["screenshot"], "causes")),
    ("stop the video, i can't focus with it playing", "inline", (["media"], "focus")),
    ("lock the pc, i'm heading into a standup", "inline", (["lock"], "standup")),
    ("what's a clean way to handle optionals in this case", "chat", None),
    ("close all my apps", "cmd", "close:all"),
],
"EN · work — deep focus": [
    ("morning, i've got a big deadline today so let's lock in", "chat", None),
    ("enable work mode, i need zero distractions for the next few hours", "cmd", "workmode_on"),
    ("set a timer for 50 minutes, pomodoro style, and remind me to drink water", "multi", None),
    ("i'm writing a proposal for a new client, kinda nervous about it", "chat", None),
    ("open slack real quick, my boss might have messages, then close it", "inline", (["open:slack", "close:slack"], "boss")),
    ("what do you think makes a proposal actually stand out", "chat", None),
    ("note that the client call is thursday at 3pm", "cmd", "note"),
    ("okay turn work mode off, i need a mental break", "cmd", "workmode_off"),
    ("how much battery do i have, i'm not near a charger", "cmd", "battery"),
],
"EN · work — meetings/calendar": [
    ("busy day, what's on my calendar today", "cmd", "cal_query"),
    ("ugh back to back meetings again", "chat", None),
    ("schedule a design review tomorrow at 2pm", "cmd", "cal_create"),
    ("open zoom, my standup starts in a minute, what's a good icebreaker", "inline", (["open:zoom"], "icebreaker")),
    ("i always freeze up when it's my turn to talk", "chat", None),
    ("mute the sound, someone's vacuuming outside", "inline", (["volume"], "vacuuming")),
    ("take a note: follow up with marketing about the launch", "cmd", "note"),
    ("lock the pc real quick, i'm grabbing lunch with a coworker", "inline", (["lock"], "lunch")),
    ("actually lock my computer", "cmd", "lock"),
],
"EN · edge traps (v1.43 bug-hunt fixes)": [
    ("hey can you increase the brightness a bit, it's dark in here", "chat", None),  # no brightness handler → honest chat, NOT volume
    ("raise the brightness please", "chat", None),
    ("mute", "cmd", "volume"),                         # bare imperative now works
    ("unmute please", "cmd", "volume"),
    ("raise the volume a little", "cmd", "volume"),     # real volume still routes
    ("the whole point is moot honestly", "chat", None), # 'moot' must not hit mute
    ("i stayed mute the entire meeting", "chat", None), # conversational 'mute' → chat
],
"ES · charla casual": [
    ("buenos días wivexx, cómo amaneciste", "chat", None),
    ("tuve un sueño rarísimo anoche la verdad", "chat", None),
    ("oye pausa la música tantito, creo que tocaron la puerta", "inline", (["media"], "puerta")),
    ("falsa alarma, era el cartero, pon la música otra vez", "inline", (["media"], "cartero")),
    ("qué clima va a hacer hoy", "cmd", "weather"),
    ("hace un frío horrible, mejor me quedo en casa", "chat", None),
    ("bueno eso es todo por ahora, gracias", "dismiss", None),
],
"ES · gaming — fortnite": [
    ("estoy jugando fortnite con mis primos", "chat", None),
    ("sube el volumen, no escucho los pasos, algún truco para construir rápido", "inline", (["volume"], "truco")),
    ("abre discord para hablar con el escuadrón", "inline", (["open:discord"], "escuadrón")),
    ("lanza una moneda, no sé si entrar a otra partida", "inline", (["random"], "partida")),
    ("me conviene ser agresivo o esconderme al inicio", "chat", None),
],
"ES · casual — cocina": [
    ("voy a hacer tacos al pastor hoy en la noche", "chat", None),
    ("pon un temporizador de 15 minutos para la carne, qué salsa recomiendas", "inline", (["timer"], "salsa")),
    ("pon la música, ya casi termino de picar", "inline", (["media"], "termino")),
    ("está increíble, toma captura de mi platillo, se ve delicioso", "inline", (["screenshot"], "delicioso")),
    ("qué clima va a hacer mañana", "cmd", "weather"),  # ES query+trailing-chat is a known anchoring limit
    ("quiero hacer carne asada si no llueve", "chat", None),
],
"ES · gaming — variado": [
    ("este jefe del elden ring está imposible amigo", "chat", None),
    ("ya morí como veinte veces contra malenia", "chat", None),
    ("sube el volumen, quiero oír mejor los ataques, algún consejo para vencerla", "inline", (["volume"], "consejo")),
    ("me conviene ir melee o mejor un build de magia", "chat", None),
    ("oye toma una captura, quiero acordarme de mi build", "inline", (["screenshot"], "build")),
    ("morí otra vez, silencia la música, me estresa un montón", "inline", (["volume"], "estresa")),
    ("abre discord, un amigo quiere jugar conmigo", "inline", (["open:discord"], "amigo")),
],
"ES · trabajo": [
    ("buenos días, hoy tengo una entrega importante", "chat", None),
    # "COMMAND, <short justification>" — runs the command, no chat to converse
    ("activa el modo trabajo, necesito concentrarme", "inline", (["workmode_on"], "")),
    ("qué tengo agendado para hoy", "cmd", "cal_query"),
    ("agenda una reunión mañana a las 3 de la tarde", "cmd", "cal_create"),
    ("abre slack rápido, mi jefe puede tener mensajes, qué opinas de las juntas largas", "inline", (["open:slack"], "juntas")),
    ("apunta que la llamada con el cliente es el jueves", "cmd", "note"),
    ("cuánta batería me queda", "cmd", "battery"),
    ("bloquea la pc, ya me voy a comer", "inline", (["lock"], "comer")),
    # ES inline is broader than EN's curated set (any compound-verb fragment): it
    # turns off work mode AND converses the "I need a break" tail — acceptable.
    ("desactiva el modo trabajo, necesito un descanso", "inline", (["workmode_off"], "descanso")),
],
}


def main():
    total = fails = 0
    fail_lines = []
    for title, convo in CONVOS.items():
        _last_app[0] = None   # fresh pronoun context per conversation
        print(f"\n=== {title} ===")
        for utter, kind, detail in convo:
            total += 1
            out = simulate(utter)
            ok = False
            got = out[0]
            if kind == "chat":
                ok = got == "chat"
            elif kind == "dismiss":
                ok = got == "dismiss"
            elif kind == "cmd":
                ok = got == "cmd" and out[1].startswith(detail)
            elif kind == "multi":
                ok = got == "multi" and "chat" not in (out[1], out[2]) and \
                     not out[1].startswith("MISS") and not out[2].startswith("MISS")
            elif kind == "inline":
                want_routes, rem_sub = detail
                if got == "inline":
                    routes = out[2]; remainder = out[3]
                    routes_ok = (len(routes) == len(want_routes)
                                 and all(r.startswith(wr) for r, wr in zip(routes, want_routes)))
                    if rem_sub == "":       # command ran, no chat remainder (run-silent)
                        ok = routes_ok and remainder.strip() == ""
                    else:
                        ok = routes_ok and rem_sub.lower() in remainder.lower()
            mark = "PASS" if ok else "FAIL"
            if not ok:
                fails += 1
                fail_lines.append((title, utter, kind, detail, out))
            # compact per-turn line
            desc = {"inline": lambda: f"inline{out[2]} rem={out[3]!r}" if got == "inline" else got,
                    "cmd": lambda: f"cmd:{out[1]}" if got == "cmd" else got,
                    "multi": lambda: f"multi({out[1]}+{out[2]})" if got == "multi" else got,
                    "chat": lambda: got, "dismiss": lambda: got}.get(kind, lambda: got)()
            print(f"  {mark} [{kind:>7}] {desc:<34} | {utter[:60]}")
    print(f"\n=== FLOW RESULT: {total-fails}/{total} ok, {fails} FAIL ===")
    if fail_lines:
        print("\n--- failures ---")
        for title, utter, kind, detail, out in fail_lines:
            print(f"  [{title}] want {kind}={detail}")
            print(f"      utter: {utter!r}")
            print(f"      got:   {out}")
    sys.exit(1 if fails else 0)


if __name__ == "__main__":
    main()
