"""The shim's /progress contract, exercised against the real posix hand-off.

`ui.html` is one page for three operating systems, so the stage-and-elapsed
line it renders is only as good as the weakest orchestrator behind it. These
drive the real `posix.sh` and the real `serve-ui.py` -- no mocks, no source
reading -- because the posix half is the one that had no coverage.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import time
from pathlib import Path
from urllib.request import urlopen

import pytest

REPO_ROOT = Path(__file__).resolve().parent.parent
SHIM_DIR = REPO_ROOT / "scripts" / "desktop-update"

# posix.sh re-execs itself through these to detach from Electron's process
# group; without them the hand-off never reaches its own main flow.
requires_posix_handoff = pytest.mark.skipif(
    not (os.path.exists("/bin/bash") and os.path.exists("/usr/bin/python3")),
    reason="posix.sh detaches through /bin/bash and /usr/bin/python3",
)


# ── serve-ui.py: what the page actually receives ───────────────────────────


@pytest.fixture
def progress(tmp_path):
    """The real loopback server, over a status file the test drives."""
    status = tmp_path / "hermes-update-status"
    proc = subprocess.Popen(
        [
            sys.executable,
            str(SHIM_DIR / "serve-ui.py"),
            str(SHIM_DIR / "ui.html"),
            str(status),
            str(time.time()),
        ],
        stdout=subprocess.PIPE,
        text=True,
    )
    try:
        port = int(proc.stdout.readline().strip())

        class Progress:
            def publish(self, state: str, message: str) -> None:
                status.write_text(json.dumps({"status": state, "message": message}))

            def corrupt(self) -> None:
                status.write_text("}not json{")

            def poll(self) -> dict:
                with urlopen(f"http://127.0.0.1:{port}/progress", timeout=5) as r:
                    return json.loads(r.read())

        yield Progress()
    finally:
        proc.kill()
        proc.wait(timeout=5)


def test_elapsed_advances_between_publishes(progress):
    """The clock is stamped per request, not frozen at the last publish.

    Stages are minutes apart during a real update, so a value written into
    the status file would sit still through exactly the long waits this line
    exists to disprove.
    """
    progress.publish("running", "Updating code and dependencies")
    first = progress.poll()
    time.sleep(1.2)
    second = progress.poll()

    assert first["status"] == "running"
    assert second["message"] == first["message"] == "Updating code and dependencies"
    assert second["elapsed_seconds"] > first["elapsed_seconds"]


def test_every_state_carries_a_clock(progress):
    """Including the stage-less running state posix publishes at window open."""
    for state, message in [
        ("running", ""),
        ("running", "Installing the new app"),
        ("done", ""),
        ("manual", "Reopen Hermes to finish."),
        ("error", "Update failed."),
    ]:
        progress.publish(state, message)
        served = progress.poll()

        assert served["status"] == state
        assert served["message"] == message
        assert served["elapsed_seconds"] >= 0


def test_unreadable_status_still_serves_a_running_state(progress):
    """A window frozen on its last frame is the bug being fixed here."""
    progress.corrupt()
    served = progress.poll()

    assert served["status"] == "running"
    assert served["elapsed_seconds"] >= 0


# ── posix.sh: the stages it publishes at its own gates ─────────────────────

# Stands in for `hermes update`, and reports the stage that was on screen
# while it ran -- the update child is the only thing that can observe the
# window's state at the exact moment of the longest wait in the hand-off.
FAKE_HERMES = """#!/bin/bash
# The hand-off probes `update --help` for --keep-stash support before the
# real update call; answer it without consuming a counted call so the
# exits.N mapping below still refers to actual update attempts.
case "$*" in *--help*) echo "--keep-stash"; exit 0 ;; esac
n="$(cat "$HERMES_TEST_CALLS" 2>/dev/null || echo 0)"; n=$((n + 1))
printf '%s' "$n" > "$HERMES_TEST_CALLS"
for f in "$TMPDIR"/hermes-update-status.[0-9]*; do
  case "$f" in *.tmp) continue ;; esac
  cp "$f" "$HERMES_TEST_CAPTURE.$n" 2>/dev/null
done
exit "$(cat "$HERMES_TEST_EXITS.$n" 2>/dev/null || echo 0)"
"""


def _run_handoff(tmp_path, exits: dict[int, int]) -> list[dict]:
    """Run the real hand-off end to end; return the stage seen at each call."""
    install_root = tmp_path / "hermes-agent"
    (install_root / "venv" / "bin").mkdir(parents=True)
    hermes = install_root / "venv" / "bin" / "hermes"
    hermes.write_text(FAKE_HERMES)
    hermes.chmod(0o755)

    capture = tmp_path / "seen"
    calls = tmp_path / "calls"
    for call, code in exits.items():
        (tmp_path / f"exits.{call}").write_text(str(code))

    env = {
        **os.environ,
        "TMPDIR": str(tmp_path),
        "HERMES_TEST_CAPTURE": str(capture),
        "HERMES_TEST_CALLS": str(calls),
        "HERMES_TEST_EXITS": str(tmp_path / "exits"),
    }
    # The hand-off daemonizes and the launcher exits immediately; the result
    # file is the orchestrator's own completion signal.
    subprocess.run(
        [
            "/bin/bash",
            str(SHIM_DIR / "posix.sh"),
            "--install-root",
            str(install_root),
            "--no-ui",
        ],
        env=env,
        timeout=60,
        check=True,
    )

    result = tmp_path / ".hermes-update-result.json"
    deadline = time.monotonic() + 45
    while time.monotonic() < deadline and not result.exists():
        time.sleep(0.1)
    assert result.exists(), "hand-off never wrote its result file"

    seen = sorted(tmp_path.glob("seen.*"), key=lambda p: int(p.suffix[1:]))

    return [json.loads(p.read_text()) for p in seen]


@requires_posix_handoff
def test_update_gate_publishes_its_stage_before_running(tmp_path):
    """`hermes update` is the longest wait in the hand-off and the one the
    Discord report sat through; the window must name it while it happens."""
    stages = _run_handoff(tmp_path, {1: 0})

    assert len(stages) == 1
    assert stages[0]["status"] == "running"
    assert stages[0]["message"] == "Updating code and dependencies"


@requires_posix_handoff
def test_retry_gate_publishes_a_distinct_stage(tmp_path):
    """A failed first attempt doubles the wait -- the second pass must not
    look like the first one hanging."""
    stages = _run_handoff(tmp_path, {1: 1, 2: 0})

    assert [s["message"] for s in stages] == [
        "Updating code and dependencies",
        "Retrying update",
    ]
