#!/usr/bin/env python3
"""splice-ai — SPLICE AI engine CLI.

Subcommands:
  suggest "<full cmdline>"   print corrected cmdline (rc 0) or nothing (rc 1)
  ask "<natural language>"   propose ONE shell command (context-aware);
                             --attach FILE|- prepends approved terminal output
  chat "<question>"          concise coding/help answer (context-aware);
                             --attach FILE|- as above
  explain "<cmdline>"        tiered: explain_local first (JSON source:'local',
                             zero tokens); model tier ONLY with --ai
  rescue --cmd C --exit N [--stderr-file F] [--cwd D]
                             local-only failure rescue via rescue_rules.resolve
                             then doctors.diagnose → JSON; NEVER calls a model
  classify "<cmdline>"|-     deterministic guard.classify verdict → JSON
                             (the F4 sentry runs this on every AI suggestion)
  test                       self-test of the shell + CLI layer

Token thrift: `suggest` is local-first — cache, then a curated typo table,
then a zero-token difflib fuzzy match over PATH executables + bash builtins/
keywords + the shell's aliases/functions (env SPLICE_SHELL_CMDS). Only then,
and only if allowed (settings.ai_enabled and SPLICE_NO_AI unset), does it make
a cheap haiku call. Outcomes (including negatives) are cached so repeat typos
cost zero tokens. `rescue`, `classify` and bare `explain` never spend tokens.
"""
import argparse
import difflib
import importlib
import json
import os
import re
import shlex
import shutil
import subprocess
import sys


def _bootstrap_share():
    env = os.environ.get("SPLICE_SHARE")
    if env and os.path.isdir(env):
        return os.path.abspath(env)
    here = os.path.dirname(os.path.abspath(sys.argv[0]))
    dev = os.path.normpath(os.path.join(here, "..", "share", "splice"))
    if os.path.isfile(os.path.join(dev, "splice_common.py")):
        return dev
    return "/usr/share/splice"


SHARE = _bootstrap_share()
if SHARE not in sys.path:
    sys.path.insert(0, SHARE)
import splice_common as sc  # noqa: E402

WRAPPERS = {
    "sudo", "doas", "env", "nohup", "time", "command", "builtin", "exec",
    "stdbuf", "nice", "ionice", "setsid", "watch",
}

# Curated typo table: bad token -> replacement (checked against installed
# commands before use, so we never suggest something that can't run).
TYPO_TABLE = {
    "ap-get": "apt-get",
    "apt-gte": "apt-get",
    "atp-get": "apt-get",
    "apt-egt": "apt-get",
    "sl": "ls",
    "gti": "git",
    "igt": "git",
    "gt": "git",
    "grpe": "grep",
    "gerp": "grep",
    "greo": "grep",
    "dokcer": "docker",
    "docekr": "docker",
    "dcoker": "docker",
    "pyhton": "python3",
    "pyhton3": "python3",
    "pytohn": "python3",
    "pytohn3": "python3",
    "pythno": "python3",
    "sudp": "sudo",
    "suod": "sudo",
    "sduo": "sudo",
    "sudoo": "sudo",
    "cd..": "cd ..",
    "claer": "clear",
    "celar": "clear",
    "clera": "clear",
    "mkae": "make",
    "amke": "make",
    "maek": "make",
    "vmi": "vim",
    "ivm": "vim",
    "systemclt": "systemctl",
    "systmectl": "systemctl",
    "sytemctl": "systemctl",
    "systemctll": "systemctl",
    "chmdo": "chmod",
    "chomd": "chmod",
    "chwon": "chown",
    "tial": "tail",
    "tali": "tail",
    "haed": "head",
    "crul": "curl",
    "curll": "curl",
    "cutl": "curl",
    "wgte": "wget",
    "wegt": "wget",
    "pign": "ping",
    "pnig": "ping",
    "exti": "exit",
    "eixt": "exit",
    "kilall": "killall",
    "sssh": "ssh",
    "shh": "ssh",
    "mikdir": "mkdir",
    "mkdri": "mkdir",
    "touhc": "touch",
    "ehco": "echo",
    "ecoh": "echo",
    "whcih": "which",
    "hsitory": "history",
    "jounralctl": "journalctl",
    "journlactl": "journalctl",
}

CACHE_MAX = 500
ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
REFUSAL_PREFIXES = (
    "i ", "i'", "i can", "sorry", "unable", "cannot", "can't",
    "as an ai", "there is no", "unfortunately",
)

# ---------------------------------------------------------------------------
# suggest machinery
# ---------------------------------------------------------------------------
_BK = None  # lazy builtins+keywords set


def _builtins_keywords():
    global _BK
    if _BK is None:
        try:
            r = subprocess.run(
                ["bash", "-c", "compgen -b; compgen -k"],
                capture_output=True, text=True, timeout=5,
            )
            _BK = set(r.stdout.split())
        except (OSError, subprocess.TimeoutExpired):
            _BK = set()
    return _BK


def _shell_cmds():
    raw = os.environ.get("SPLICE_SHELL_CMDS", "")
    return {w for w in raw.split(":") if w}


def _path_commands():
    """All executable names on PATH. Built lazily, only on the fuzzy path."""
    names = set()
    for d in os.environ.get("PATH", "").split(os.pathsep):
        if not d:
            continue
        try:
            with os.scandir(d) as it:
                for e in it:
                    try:
                        if e.is_file(follow_symlinks=True) and os.access(e.path, os.X_OK):
                            names.add(e.name)
                    except OSError:
                        continue
        except OSError:
            continue
    return names


def _known(word, shell_cmds):
    return bool(shutil.which(word)) or word in _builtins_keywords() or word in shell_cmds


def _candidate(line):
    """The word that should name a command: skip leading VAR= assignments and
    wrapper words (plus the wrapper's options / env assignments)."""
    try:
        toks = shlex.split(line)
    except ValueError:
        toks = line.split()
    i, n = 0, len(toks)
    while i < n:
        t = toks[i]
        if ASSIGN_RE.match(t):
            i += 1
            continue
        if t in WRAPPERS:
            i += 1
            while i < n and (toks[i].startswith("-") or ASSIGN_RE.match(toks[i])):
                i += 1
            continue
        return t
    return None


def _replace_token(line, bad, repl):
    """Replace the first standalone occurrence of `bad` in `line`."""
    pat = r"(?<!\S)" + re.escape(bad) + r"(?!\S)"
    new, n = re.subn(pat, lambda m: repl, line, count=1)
    return new if n else None


def _cache_path():
    return os.path.join(sc.cache_dir(), "suggest.json")


def _load_cache():
    try:
        with open(_cache_path()) as f:
            d = json.load(f)
        if isinstance(d, dict) and isinstance(d.get("entries"), dict):
            return d
    except (OSError, ValueError):
        pass
    return {"version": 1, "entries": {}}


def _save_cache(cache):
    ents = cache["entries"]
    if len(ents) > CACHE_MAX:
        for k in list(ents)[: len(ents) - CACHE_MAX]:
            del ents[k]
    try:
        sc.atomic_write_json(_cache_path(), cache)
    except OSError:
        pass


def _clean_model_line(out):
    """Normalize a command-only model reply: strip fences/backticks, first line."""
    out = out.strip()
    if out.startswith("```"):
        lines = [l for l in out.splitlines() if not l.strip().startswith("```")]
        out = "\n".join(lines).strip()
    if len(out) >= 2 and out[0] == "`" and out[-1] == "`":
        out = out[1:-1].strip()
    for line in out.splitlines():
        if line.strip():
            return line.strip()
    return ""


def _ai_suggest(line, settings):
    prompt = (
        "Fix the typo in this shell command. Reply with ONLY the corrected "
        "command, or NONE.\nCommand: " + line
    )
    out = sc.run_claude(
        prompt,
        model=settings.get("fix_model") or "haiku",
        timeout=settings.get("suggest_timeout_s", 12),
    )
    if not out:
        return None
    ans = _clean_model_line(out)
    if not ans or ans.upper() == "NONE":
        return None
    if ans == line or len(ans) > 400:
        return None
    if ans.lower().startswith(REFUSAL_PREFIXES):
        return None
    return ans


def cmd_suggest(line):
    line = line.strip()
    if not line:
        return 1
    settings = sc.load_settings()
    ai_allowed = bool(settings.get("ai_enabled", True)) and not os.environ.get("SPLICE_NO_AI")

    # (1) cache
    cache = _load_cache()
    ent = cache["entries"].get(line)
    if isinstance(ent, dict):
        s = ent.get("s")
        if s:
            print(s)
            return 0
        # negative entry: honor it unless it was local-only and AI is now allowed
        if ent.get("ai") or not ai_allowed:
            return 1

    # (2) local: candidate word
    cand = _candidate(line)
    if not cand:
        return 1
    shell_cmds = _shell_cmds()
    if _known(cand, shell_cmds):
        return 1  # nothing to fix

    fixed = None
    # (2a) curated typo table (verify the replacement actually resolves)
    repl = TYPO_TABLE.get(cand)
    if repl and _known(repl.split()[0], shell_cmds):
        fixed = _replace_token(line, cand, repl)

    # (2b) difflib fuzzy over PATH + builtins + keywords + shell cmds
    if fixed is None:
        names = _path_commands() | _builtins_keywords() | shell_cmds
        names.discard(cand)
        cutoff = 0.75 if len(cand) >= 5 else 0.8
        m = difflib.get_close_matches(cand, names, n=1, cutoff=cutoff)
        if m:
            fixed = _replace_token(line, cand, m[0])

    # (3) model fallback, gated
    used_ai = False
    if fixed is None and ai_allowed:
        used_ai = True
        fixed = _ai_suggest(line, settings)

    # (4) cache the outcome (negatives too, so misses don't re-bill)
    if fixed and fixed != line:
        cache["entries"].pop(line, None)
        cache["entries"][line] = {"s": fixed, "ai": used_ai}
        _save_cache(cache)
        print(fixed)
        return 0
    cache["entries"].pop(line, None)
    cache["entries"][line] = {"s": None, "ai": used_ai}
    _save_cache(cache)
    return 1


# ---------------------------------------------------------------------------
# sibling share modules (guard / rescue_rules / doctors / explain_local).
# Every caller must degrade gracefully when one is absent or broken — the
# terminal works fully without any sibling installed.
# ---------------------------------------------------------------------------
def _local_module(name):
    try:
        return importlib.import_module(name)
    except Exception:
        return None


# ---------------------------------------------------------------------------
# local-only subcommands (F1/F2/F4): zero tokens, ever
# ---------------------------------------------------------------------------
def cmd_rescue(args):
    """Local failure rescue: rescue_rules.resolve(cmd, exit, output, cwd),
    then doctors.diagnose(same signature). Prints one JSON object with a
    'source' key ('local' | 'none'). NEVER calls the model — the GTK [A]sk
    path uses `ask --attach` for that, explicitly."""
    output = ""
    if args.stderr_file:
        try:
            with open(args.stderr_file, errors="replace") as f:
                output = f.read()
        except OSError:
            output = ""
    cwd = args.cwd or os.getcwd()
    for modname, func in (("rescue_rules", "resolve"), ("doctors", "diagnose")):
        mod = _local_module(modname)
        fn = getattr(mod, func, None) if mod else None
        if not fn:
            continue
        try:
            res = fn(args.cmd, args.exit, output, cwd)
        except Exception:
            res = None
        if res:
            out = {"source": "local"}
            out.update(res if isinstance(res, dict) else {"suggestion": str(res)})
            print(json.dumps(out))
            return 0
    print(json.dumps({"source": "none"}))
    return 1


def cmd_classify(cmdline):
    """Deterministic verdict for one command line via guard.classify → JSON.
    The GTK sentry (F4) runs this on every AI suggestion before insertion."""
    if cmdline == "-":
        cmdline = sys.stdin.read().strip()
    mod = _local_module("guard")
    fn = getattr(mod, "classify", None) if mod else None
    if not fn:
        print(json.dumps({"class": "unknown", "error": "guard module unavailable"}))
        return 1
    try:
        try:
            verdict = fn(cmdline, os.getcwd())
        except TypeError:
            verdict = fn(cmdline)
    except Exception as e:  # a broken guard must never break the caller
        print(json.dumps({"class": "unknown", "error": str(e)}))
        return 1
    if not isinstance(verdict, dict):
        verdict = {"class": str(verdict)}
    print(json.dumps(verdict))
    return 0


# ---------------------------------------------------------------------------
# context-aware commands
# ---------------------------------------------------------------------------
ATTACH_HEADER = "TERMINAL CONTEXT (user-approved attachment):"


def _read_attachment(spec, settings):
    """Read an --attach payload (path, or '-' for stdin), capped at
    settings.max_attach_lines keeping the tail (where the error lives),
    with a '[...truncated]' marker when cut."""
    try:
        if spec == "-":
            text = sys.stdin.read()
        else:
            with open(spec, errors="replace") as f:
                text = f.read()
    except OSError:
        return None
    try:
        max_lines = int(settings.get("max_attach_lines", 120) or 120)
    except (TypeError, ValueError):
        max_lines = 120
    lines = text.splitlines()
    if len(lines) > max_lines:
        lines = ["[...truncated]"] + lines[-max_lines:]
    text = "\n".join(lines).strip()
    return text or None


def _ctx_blob():
    """Enabled-context blob via `splice-ctx blob` (local, token-budgeted)."""
    here = os.path.dirname(os.path.abspath(sys.argv[0]))
    exe = os.path.join(here, "splice-ctx")
    if not (os.path.isfile(exe) and os.access(exe, os.X_OK)):
        exe = shutil.which("splice-ctx")
    if not exe:
        return ""
    try:
        r = subprocess.run([exe, "blob"], capture_output=True, text=True, timeout=20)
    except (OSError, subprocess.TimeoutExpired):
        return ""
    if r.returncode != 0:
        return ""
    return r.stdout.strip()


def _fail_model():
    print(
        "splice-ai: model call failed (is the `claude` CLI installed and logged in?)",
        file=sys.stderr,
    )
    return 1


def cmd_ask(query, attach=None):
    settings = sc.load_settings()
    parts = []
    if attach:
        att = _read_attachment(attach, settings)
        if att:
            parts.append(ATTACH_HEADER + "\n" + att)
    blob = _ctx_blob()
    if blob:
        parts.append(blob)
    parts.append(
        "Task: " + query
        + "\nReply with ONLY the shell command, no backticks, no prose."
    )
    prompt = "\n\n".join(parts)
    out = sc.run_claude(prompt, model=settings.get("ask_model") or None, timeout=90)
    if not out:
        return _fail_model()
    line = _clean_model_line(out)
    if not line:
        return _fail_model()
    print(line)
    return 0


def cmd_chat(question, attach=None):
    settings = sc.load_settings()
    parts = []
    if attach:
        att = _read_attachment(attach, settings)
        if att:
            parts.append(ATTACH_HEADER + "\n" + att)
    blob = _ctx_blob()
    if blob:
        parts.append(blob)
    parts.append(question + "\n\nBe concise. No preamble.")
    prompt = "\n\n".join(parts)
    out = sc.run_claude(prompt, model=settings.get("ask_model") or None, timeout=120)
    if not out:
        return _fail_model()
    print(out)
    return 0


def cmd_explain(line, use_ai=False):
    """Tiered explain (F6). Default: explain_local only — instant, zero
    tokens, JSON {"source":"local", ...} or {"source":"none"} (rc 1). The
    model tier (existing haiku path) runs ONLY with --ai and prints
    {"source":"ai","text":...}."""
    if not use_ai:
        mod = _local_module("explain_local")
        fn = getattr(mod, "explain", None) if mod else None
        res = None
        if fn:
            try:
                res = fn(line)
            except Exception:
                res = None
        if res:
            out = {"source": "local"}
            if isinstance(res, dict):
                out.update(res)
            elif isinstance(res, list):
                out["tokens"] = res  # explain_local contract: list of items
            else:
                out["text"] = str(res)
            print(json.dumps(out))
            return 0
        print(json.dumps({"source": "none"}))
        return 1
    settings = sc.load_settings()
    prompt = (
        "Explain this shell command in one short paragraph. Be concise, no "
        "preamble.\nCommand: " + line
    )
    out = sc.run_claude(prompt, model=settings.get("fix_model") or "haiku", timeout=60)
    if not out:
        return _fail_model()
    print(json.dumps({"source": "ai", "text": out}))
    return 0


# ---------------------------------------------------------------------------
# self-test (`splice-ai test` / `python3 splice-ai test`)
#
# Sibling modules (guard.py, rescue_rules.py, doctors.py, explain_local.py)
# may not be installed yet; the test stages minimal stubs honoring the
# v1.3.0 contracts in a TEMP share dir only — stubs are NEVER shipped.
# ---------------------------------------------------------------------------
GUARD_STUB = '''\
"""TEST STUB guard.py — honors the v1.3.0 guard contract. Never shipped."""
import json, os, sys, time


def classify(cmd, cwd=None):
    if "rm -rf" in cmd or cmd.strip().startswith("sudo rm"):
        return {"class": "destructive", "effect": "recursive delete", "expanded": cmd}
    if cmd.split()[:1] in (["ls"], ["grep"], ["stat"], ["df"]):
        return {"class": "read-only", "effect": "", "expanded": cmd}
    return {"class": "mutating", "effect": "", "expanded": cmd}


def _check(cmd, cwd):
    if os.environ.get("GUARDTEST_HANG"):
        time.sleep(10)
    if "guardtest-block" in cmd:
        print(json.dumps({"level": "block", "reason": "protected path (simulated)",
                          "effect": "would delete a protected tree", "expanded": cmd}))
        return 3
    if "guardtest-protected-sim" in cmd and "rm" in cmd:
        print(json.dumps({"level": "confirm",
                          "effect": "recursively deletes 1 dir, 1 file (simulated)",
                          "expanded": cmd, "trash_cmd": "true"}))
        return 3
    return 0


if __name__ == "__main__":
    if len(sys.argv) >= 3 and sys.argv[1] == "check":
        sys.exit(_check(sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else ""))
    sys.exit(0)
'''

RESCUE_STUB = '''\
"""TEST STUB rescue_rules.py — contract: resolve(cmd, exit_code, output, cwd) -> dict|None."""


def resolve(cmd, exit_code, output, cwd):
    if "unzip" in cmd and "command not found" in (output or ""):
        return {"suggestion": "sudo apt install unzip",
                "explanation": "unzip is not installed", "danger": "mutating"}
    return None
'''

DOCTORS_STUB = '''\
"""TEST STUB doctors.py — contract: diagnose(cmd, exit_code, output, cwd) -> dict|None."""


def diagnose(cmd, exit_code, output, cwd):
    if "EADDRINUSE" in (output or ""):
        return {"suggestion": "kill 12345",
                "card": "port 3000 held by node (pid 12345)"}
    return None
'''

EXPLAIN_STUB = '''\
"""TEST STUB explain_local.py — contract: explain(cmdline) -> dict|None."""


def explain(cmdline):
    if cmdline.startswith("tar "):
        return {"tokens": [{"t": "-x", "d": "extract"}, {"t": "-z", "d": "gzip"}]}
    return None
'''


def cmd_test():
    import base64 as b64mod
    import pty
    import select
    import tempfile
    import time

    fails = []

    def check(name, cond, detail=""):
        print(("  [ok]   " if cond else "  [FAIL] ") + name
              + ("" if cond else " — " + " ".join(str(detail).split())[:300]))
        if not cond:
            fails.append(name)

    me = os.path.abspath(sys.argv[0])
    rcfile = os.path.join(SHARE, "splice.bashrc")
    tmp = tempfile.mkdtemp(prefix="splice-selftest-")
    try:
        # -- stage a fake share: real helpers + sibling-contract stubs --
        tshare = os.path.join(tmp, "share")
        os.makedirs(tshare)
        shutil.copy(os.path.join(SHARE, "splice_common.py"), tshare)
        shutil.copy(rcfile, tshare)
        for fname, src in (("guard.py", GUARD_STUB),
                           ("rescue_rules.py", RESCUE_STUB),
                           ("doctors.py", DOCTORS_STUB),
                           ("explain_local.py", EXPLAIN_STUB)):
            with open(os.path.join(tshare, fname), "w") as f:
                f.write(src)
        bare = os.path.join(tmp, "bare-share")  # share with NO sibling modules
        os.makedirs(bare)
        shutil.copy(os.path.join(SHARE, "splice_common.py"), bare)

        home = os.path.join(tmp, "home")
        os.makedirs(home)
        env = dict(os.environ, SPLICE_SHARE=tshare, HOME=home, SPLICE="1",
                   SPLICE_NO_AI="1", TERM="xterm-256color",
                   SPLICE_GUARD_TIMEOUT="1")
        env.pop("PROMPT_COMMAND", None)

        def ai(*args, env_extra=None, stdin=None):
            e = dict(env)
            if env_extra:
                e.update(env_extra)
            return subprocess.run([sys.executable, me, *args],
                                  capture_output=True, text=True, env=e,
                                  input=stdin, timeout=60)

        # 1) rcfile parses
        r = subprocess.run(["bash", "-n", rcfile], capture_output=True, text=True)
        check("bash -n splice.bashrc", r.returncode == 0, r.stderr)

        # 2) rescue: rule hit / doctor hit / miss / degraded (no siblings)
        errf = os.path.join(tmp, "stderr.txt")
        with open(errf, "w") as f:
            f.write("bash: unzip: command not found\n")
        r = ai("rescue", "--cmd", "unzip x.zip", "--exit", "127",
               "--stderr-file", errf, "--cwd", tmp)
        d = json.loads(r.stdout or "{}")
        check("rescue rule hit", r.returncode == 0 and d.get("source") == "local"
              and d.get("suggestion") == "sudo apt install unzip",
              r.stdout + r.stderr)
        with open(errf, "w") as f:
            f.write("Error: listen EADDRINUSE: address already in use :::3000\n")
        r = ai("rescue", "--cmd", "node server.js", "--exit", "1",
               "--stderr-file", errf)
        d = json.loads(r.stdout or "{}")
        check("rescue doctor hit", r.returncode == 0 and d.get("source") == "local"
              and "12345" in d.get("suggestion", ""), r.stdout + r.stderr)
        r = ai("rescue", "--cmd", "true", "--exit", "1")
        d = json.loads(r.stdout or "{}")
        check("rescue miss -> none", r.returncode == 1 and d.get("source") == "none",
              r.stdout)
        r = ai("rescue", "--cmd", "unzip x.zip", "--exit", "127",
               "--stderr-file", errf, env_extra={"SPLICE_SHARE": bare})
        d = json.loads(r.stdout or "{}")
        check("rescue degrades w/o sibling modules",
              r.returncode == 1 and d.get("source") == "none", r.stdout + r.stderr)

        # 3) classify
        r = ai("classify", "rm -rf /")
        d = json.loads(r.stdout or "{}")
        check("classify destructive", r.returncode == 0
              and d.get("class") == "destructive", r.stdout + r.stderr)
        r = ai("classify", "-", stdin="ls -la\n")
        d = json.loads(r.stdout or "{}")
        check("classify stdin read-only", r.returncode == 0
              and d.get("class") == "read-only", r.stdout + r.stderr)
        r = ai("classify", "rm -rf /", env_extra={"SPLICE_SHARE": bare})
        d = json.loads(r.stdout or "{}")
        check("classify degrades w/o guard", r.returncode == 1
              and d.get("class") == "unknown", r.stdout + r.stderr)

        # 4) explain: local tier / miss / --ai wiring (no claude → clean fail)
        r = ai("explain", "tar -xzf a.tgz")
        d = json.loads(r.stdout or "{}")
        check("explain local tier", r.returncode == 0 and d.get("source") == "local"
              and d.get("tokens"), r.stdout + r.stderr)
        r = ai("explain", "frobnicate --now")
        d = json.loads(r.stdout or "{}")
        check("explain local miss -> none", r.returncode == 1
              and d.get("source") == "none", r.stdout)
        r = ai("explain", "tar -xzf a.tgz", "--ai",
               env_extra={"PATH": "/usr/bin:/bin"})
        check("explain --ai fails cleanly w/o claude", r.returncode == 1
              and "model call failed" in r.stderr, r.stdout + r.stderr)

        # 5) --attach plumbing (in-process; no model call)
        att = os.path.join(tmp, "attach.txt")
        with open(att, "w") as f:
            f.write("\n".join("line%d" % i for i in range(10)))
        got = _read_attachment(att, {"max_attach_lines": 5}) or ""
        lines = got.splitlines()
        check("attach tail-truncation", len(lines) == 6
              and lines[0] == "[...truncated]" and lines[-1] == "line9", got)
        r = ai("ask", "why did it fail", "--attach", att,
               env_extra={"PATH": "/usr/bin:/bin"})
        check("ask --attach accepted, fails cleanly w/o claude",
              r.returncode == 1 and "model call failed" in r.stderr,
              r.stdout + r.stderr)

        # 6) suggest regression (local typo table, zero tokens)
        r = ai("suggest", "gti status")
        check("suggest still works", r.returncode == 0
              and r.stdout.strip() == "git status", r.stdout + r.stderr)

        # 7) live bash session under a pty: guard veto / fail-open / OSC 777
        with open(os.path.join(home, ".bashrc"), "w") as f:
            f.write("PS1='@@PS@@ '\n")
        pdir = os.path.join(tmp, "guardtest-protected-sim")

        pid, master = pty.fork()
        if pid == 0:  # child
            os.chdir(tmp)
            try:
                os.execve("/bin/bash",
                          ["bash", "--rcfile",
                           os.path.join(tshare, "splice.bashrc"), "-i"], env)
            finally:
                os._exit(127)

        state = {"buf": b"", "pos": 0}

        def expect(needle, timeout_s=10):
            nb = needle if isinstance(needle, bytes) else needle.encode()
            deadline = time.time() + timeout_s
            while True:
                i = state["buf"].find(nb, state["pos"])
                if i >= 0:
                    state["pos"] = i + len(nb)
                    return True
                if time.time() > deadline:
                    return False
                rd, _, _ = select.select([master], [], [], 0.25)
                if rd:
                    try:
                        data = os.read(master, 65536)
                    except OSError:
                        return False
                    if not data:
                        return False
                    state["buf"] += data

        def send(s):
            os.write(master, s if isinstance(s, bytes) else s.encode())

        def wait_gone(path, timeout_s=6):
            deadline = time.time() + timeout_s
            while time.time() < deadline:
                if not os.path.exists(path):
                    return True
                time.sleep(0.1)
            return False

        try:
            check("pty: first prompt", expect("@@PS@@"))

            # innocent command runs untouched
            send("echo $((21 * 2))\n")
            check("pty: innocent command runs", expect("42") and expect("@@PS@@"))
            check("pty: no guard on innocent", b"GUARD" not in state["buf"])

            # OSC 777 exit-code report
            send("false\n")
            b64_false = b64mod.b64encode(b"false").decode()
            check("pty: OSC777 exit report",
                  expect("\x1b]777;splice-exit;1;" + b64_false + "\x07"))
            expect("@@PS@@")

            # destructive command → GUARD block; Esc vetoes
            os.makedirs(pdir, exist_ok=True)
            open(os.path.join(pdir, "keep.txt"), "w").close()
            send("rm -rf %s\n" % pdir)
            check("pty: GUARD block shown", expect("GUARD ⌁"))
            check("pty: guard offers options",
                  expect("[Enter] run  [e]dit  [t]rash-instead  [Esc] cancel"))
            send("\x1b")  # Esc
            check("pty: prompt back after veto", expect("@@PS@@"))
            time.sleep(0.3)
            check("pty: Esc vetoed (dir survives)", os.path.isdir(pdir))

            # same command, Enter confirms and runs
            send("rm -rf %s\n" % pdir)
            check("pty: GUARD shown again", expect("GUARD ⌁"))
            send("\n")  # Enter → run
            check("pty: prompt back after confirm", expect("@@PS@@"))
            check("pty: Enter ran the command", wait_gone(pdir))

            # block level + documented 'run unguarded once' escape
            bdir = os.path.join(tmp, "guardtest-block-sim")
            os.makedirs(bdir, exist_ok=True)
            send("rm -rf %s\n" % bdir)
            check("pty: block level vetoes",
                  expect("blocked:") and expect("@@PS@@"))
            check("pty: block leaves dir intact", os.path.isdir(bdir))
            send("run unguarded once\n")
            check("pty: escape phrase ack", expect("guard disarmed"))
            expect("@@PS@@")
            send("rm -rf %s\n" % bdir)
            check("pty: unguarded-once runs",
                  expect("@@PS@@") and wait_gone(bdir))

            # guard timeout fails OPEN (stub hangs, SPLICE_GUARD_TIMEOUT=1)
            os.makedirs(pdir, exist_ok=True)
            send("export GUARDTEST_HANG=1\n")
            expect("@@PS@@")
            t0 = time.time()
            send("rm -rf %s\n" % pdir)
            check("pty: guard timeout fails open",
                  expect("@@PS@@") and wait_gone(pdir))
            check("pty: fail-open was fast", time.time() - t0 < 6,
                  "%.1fs" % (time.time() - t0))
            send("unset GUARDTEST_HANG\n")
            expect("@@PS@@")
            send("exit\n")
        finally:
            try:
                os.close(master)
            except OSError:
                pass
            try:
                os.waitpid(pid, 0)
            except (ChildProcessError, OSError):
                pass
    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    if fails:
        print("SELF-TEST: FAIL — " + ", ".join(fails))
        return 1
    print("SELF-TEST: PASS")
    return 0


def main():
    p = argparse.ArgumentParser(prog="splice-ai", description="SPLICE AI engine")
    # NB: dest must not be "cmd" — the rescue subcommand has a --cmd option.
    sub = p.add_subparsers(dest="sub", required=True)
    sub.add_parser("suggest", help="fix a mistyped command line").add_argument("cmdline")
    sp = sub.add_parser("ask", help="propose one shell command")
    sp.add_argument("query")
    sp.add_argument("--attach", metavar="FILE",
                    help="prepend approved terminal output ('-' = stdin)")
    sp = sub.add_parser("chat", help="concise context-aware answer")
    sp.add_argument("question")
    sp.add_argument("--attach", metavar="FILE",
                    help="prepend approved terminal output ('-' = stdin)")
    sp = sub.add_parser("explain", help="explain a command (local-first)")
    sp.add_argument("cmdline")
    sp.add_argument("--ai", action="store_true",
                    help="allow the model tier (haiku) — otherwise local only")
    sp = sub.add_parser("rescue",
                        help="local-only failure rescue → JSON (zero tokens)")
    sp.add_argument("--cmd", required=True, help="the failed command line")
    sp.add_argument("--exit", type=int, default=1, help="its exit code")
    sp.add_argument("--stderr-file", default=None,
                    help="file with the captured output/stderr")
    sp.add_argument("--cwd", default=None)
    sub.add_parser("classify",
                   help="deterministic guard verdict → JSON ('-' = stdin)"
                   ).add_argument("cmdline")
    sub.add_parser("test", help="self-test of the shell + CLI layer")
    a = p.parse_args()
    if a.sub == "suggest":
        return cmd_suggest(a.cmdline)
    if a.sub == "ask":
        return cmd_ask(a.query, attach=a.attach)
    if a.sub == "chat":
        return cmd_chat(a.question, attach=a.attach)
    if a.sub == "rescue":
        return cmd_rescue(a)
    if a.sub == "classify":
        return cmd_classify(a.cmdline)
    if a.sub == "test":
        return cmd_test()
    return cmd_explain(a.cmdline, use_ai=a.ai)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(130)
