"""Drive the real CLI with a FIFO update-cache result under a Linux PTY.

Run from the checkout with the project Python. No provider requests are sent.
The FIFO releases the real cached update result only after the prompt renders.
"""
import argparse
import errno
import json
import os
from pathlib import Path
import pty
import select
import signal
import struct
import subprocess
import sys
import tempfile
import termios
import time
import fcntl


def run_case(root, output, name, behind, early=False, cancel=False):
    with tempfile.TemporaryDirectory(prefix="hermes_test_notice_") as home:
        hh = Path(home) / ".hermes"
        hh.mkdir()
        (hh / "config.yaml").write_text(
            "model:\n  default: test-model\n  provider: custom\n"
            "  base_url: http://127.0.0.1:9/v1\n"
            "display:\n  interface: cli\n  skip_banner: false\n"
            "memory:\n  provider: ''\n", encoding="utf-8")
        cache = hh / "notice-result.json"
        # This harness tests display timing, not source-check cache policy.
        payload = json.dumps({"behind": behind}).encode()
        if early:
            cache.write_bytes(payload)
        else:
            os.mkfifo(cache)
        env = {"PATH": os.environ["PATH"], "HOME": home, "HERMES_HOME": str(hh),
               "PYTHONPATH": str(root), "PYTHONUNBUFFERED": "1",
               "TERM": "xterm-256color", "LANG": "C.UTF-8",
               "OPENAI_API_KEY": "local-not-used", "PROMPT_TOOLKIT_NO_CPR": "1"}
        master, slave = pty.openpty()
        fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
        bootstrap = ("import json; from pathlib import Path; import hermes_cli.main as m; "
                     "import hermes_cli.banner as b; from hermes_cli import source_check; "
                     f"source_check.check_for_updates = lambda **kw: json.loads(Path({str(cache)!r}).read_text()); "
                     "print('LOADED', m.__file__, b.__file__, flush=True); m.main()")
        proc = subprocess.Popen([sys.executable, "-c", bootstrap, "chat"], cwd=root,
                                env=env, stdin=slave, stdout=slave, stderr=slave,
                                start_new_session=True)
        os.close(slave)
        data = bytearray()

        def pump_until(predicate, timeout=30):
            deadline = time.monotonic() + timeout
            while time.monotonic() < deadline:
                if predicate(bytes(data)):
                    return True
                if select.select([master], [], [], 0.1)[0]:
                    try:
                        chunk = os.read(master, 65536)
                    except OSError as exc:
                        if exc.errno == errno.EIO:
                            return predicate(bytes(data))
                        raise
                    if not chunk:
                        return predicate(bytes(data))
                    data.extend(chunk)
            return predicate(bytes(data))

        try:
            ready = pump_until(lambda b: b"\x1b[?2004h" in b)
            assert ready, f"{name}: prompt did not render: {data[-1500:]!r}"
            offset = len(data)
            if not early and not cancel:
                fd = os.open(cache, os.O_WRONLY | os.O_NONBLOCK)
                os.write(fd, payload)
                os.close(fd)
                pump_until(lambda b: b"to update" in b[offset:] or b"update available" in b[offset:], 3)
                # prompt_toolkit prints above the prompt via run_in_terminal (input detached,
                # cooked mode); type only after the prompt is redrawn, like a user would.
                redraw = len(data)
                pump_until(lambda b: b"\x1b[?2004h" in b[redraw:], 3)
            elif cancel:
                os.write(master, b"\x03")
            os.write(master, b"/quit\r")
            exited = pump_until(lambda b: proc.poll() is not None, 60)
            proc.wait(timeout=30)
            text = bytes(data).decode(errors="replace")
            (output / f"{name}.pty").write_bytes(data)
            result = {"case": name, "ready": ready, "exited": exited,
                      "returncode": proc.returncode, "garbled": "?[1;33m" in text,
                      "notice": "commits behind" in text or "update available" in text,
                      "loaded_worktree": str(root / "hermes_cli/banner.py") in text,
                      "raw_path": str(output / f"{name}.pty")}
            assert result["loaded_worktree"] and proc.returncode == 0, result
            return result
        finally:
            (output / f"{name}.pty").write_bytes(data)
            if proc.poll() is None:
                os.killpg(proc.pid, signal.SIGKILL)
                proc.wait(timeout=30)
            os.close(master)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--root", required=True)
    parser.add_argument("--expect", choices=("clean", "garbled"), required=True)
    args = parser.parse_args()
    root = Path(args.root).resolve()
    args.output.mkdir(parents=True, exist_ok=True)
    cases = [("late-count", 3, False, False), ("late-current", 0, False, False),
             ("late-unknown-count", -1, False, False), ("early-count", 3, True, False),
             ("cancel-pending", 3, False, True)]
    results = [run_case(root, args.output, *case) for case in cases]
    (args.output / "results.json").write_text(json.dumps(results, indent=2) + "\n")
    print(json.dumps(results, indent=2))
    for row in results:
        expected_notice = row["case"] not in ("late-current", "cancel-pending")
        assert row["notice"] == expected_notice, row
        expected_garble = args.expect == "garbled" and row["case"].startswith("late-") and expected_notice
        assert row["garbled"] == expected_garble, row


if __name__ == "__main__":
    main()
