#!/usr/bin/env python3
"""checkrun — answer a test checklist in a form, get a .md of answers.

Usage:  checkrun "/path/to/ROUND9_SECTION_A_CHECKLIST.md"

Reads a checklist in the owner format:

    About 3 minutes.
    1. Do the thing.
    Works: what you see.
    Broken: what you see instead.

Shows one card per step with Worked / Broken / Skipped and a notes box.
Writes <name>_ANSWERS.md beside the checklist. Never edits the checklist.
"""
import re
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path

from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (
    QApplication, QButtonGroup, QFrame, QHBoxLayout, QLabel, QMainWindow,
    QMessageBox, QPushButton, QRadioButton, QScrollArea, QTextEdit,
    QToolButton, QVBoxLayout, QWidget,
)

STEP = re.compile(r"^\s*(\d+)\.\s+(.*)$")


def parse(text):
    """Return (duration, [ {n, do, works, broken} ], tail)."""
    duration, steps, tail = "", [], ""
    current = None
    for line in text.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        m = STEP.match(stripped)
        if m:
            do = m.group(2)
            # A check keeps the same name for its whole life, so a reworded
            # step is still recognised as one already answered. It is
            # written at the end of the line and never shown to the owner.
            tag = ""
            tm = re.search(r"\s*\[#([a-z0-9][a-z0-9-]*)\]\s*$", do)
            if tm:
                tag, do = tm.group(1), do[:tm.start()].rstrip()
            current = {"n": m.group(1), "do": do, "tag": tag,
                       "works": "", "broken": ""}
            steps.append(current)
        elif stripped.lower().startswith("works:") and current:
            current["works"] = stripped.split(":", 1)[1].strip()
        elif stripped.lower().startswith("broken:") and current:
            current["broken"] = stripped.split(":", 1)[1].strip()
        elif not steps and not duration:
            duration = stripped
        elif steps:
            tail = stripped
    return duration, steps, tail


def way_back(path):
    """The script that puts the app back where the test starts, if the crew
    wrote one.

    From a real sitting: the owner clicked out of the folder they were testing
    in and had no way back to it. Every checklist now ships a way back beside it,
    named `<checklist stem>_START.sh`, and the button that runs it sits at the
    top of the window where it cannot scroll away.

    The script says in plain words where it puts them, on its own line:

        # Back to: the Test Files folder, with the window narrow.

    Returns (script path or None, that sentence or "").
    """
    script = path.with_name(path.stem + "_START.sh")
    if not script.exists():
        return None, ""
    says = ""
    try:
        for line in script.read_text().splitlines()[:20]:
            low = line.strip().lower()
            if low.startswith("# back to:"):
                says = line.split(":", 1)[1].strip()
                break
    except OSError:
        pass
    return script, says


LOG = Path.home() / ".local/share/crew/answers-log.jsonl"


def log_answers(path, crew, total, broken, answers=None):
    """Remember that a checklist was answered, so the next one can be checked
    against it.

    From a real sitting: the owner answered fourteen checks Worked, and the crew
    reworded the same fourteen and handed them straight back. Half an hour of
    their time bought no new testing.
    """
    import json
    try:
        LOG.parent.mkdir(parents=True, exist_ok=True)
        with LOG.open("a") as fh:
            fh.write(json.dumps({
                "when": datetime.now().isoformat(timespec="seconds"),
                "checklist": str(path),
                "crew": crew,
                "steps": total,
                "broken": broken,
                "answers": answers or [],
            }) + "\n")
    except OSError:
        pass


def answered_recently(path, crew, hours=12):
    """The last checklist this crew had answered, if it was today and nothing
    in it was Broken. Returns (when, steps) or None."""
    import json
    if not LOG.exists():
        return None
    cutoff = datetime.now() - timedelta(hours=hours)
    best = None
    try:
        for line in LOG.read_text().splitlines():
            try:
                row = json.loads(line)
            except ValueError:
                continue
            if row.get("crew") != crew or row.get("broken"):
                continue
            if str(row.get("checklist")) == str(path):
                continue
            when = datetime.fromisoformat(row["when"])
            if when < cutoff:
                continue
            if best is None or when > best[0]:
                best = (when, row.get("steps", 0))
    except (OSError, KeyError, ValueError):
        return None
    return best


def same_step(a, b):
    """Is this a step already answered, however it has been reworded?

    Exact once the wording is flattened, or near enough that a person reading
    both would call them one step.
    """
    import difflib

    def flat(s):
        return re.sub(r"[^a-z0-9 ]", " ", s.lower()).split()

    fa, fb = flat(a), flat(b)
    if not fa or not fb:
        return False
    if fa == fb:
        return True
    return difflib.SequenceMatcher(None, " ".join(fa), " ".join(fb)).ratio() >= 0.85


def already_worked(crew, days=7):
    """Every step this crew already had answered Worked, newest first."""
    import json
    if not crew or not LOG.exists():
        return []
    cutoff = datetime.now() - timedelta(days=days)
    out = []
    try:
        for line in LOG.read_text().splitlines():
            try:
                row = json.loads(line)
            except ValueError:
                continue
            if row.get("crew") != crew:
                continue
            try:
                when = datetime.fromisoformat(row["when"])
            except (KeyError, ValueError):
                continue
            if when < cutoff:
                continue
            for a in row.get("answers", []):
                if a.get("verdict") == "Worked" and a.get("do"):
                    out.append((when, a["do"], a.get("tag", "")))
    except OSError:
        return []
    return sorted(out, reverse=True)


def crew_for(path):
    """Which crew a checklist belongs to, by the folder it sits in."""
    try:
        import json
        crews = json.loads((Path.home() / ".local/share/crew/pairs.json").read_text())
    except Exception:
        return ""
    best, best_len = "", 0
    for pair, cfg in crews.items():
        for folder in (cfg.get("chats", ""), cfg.get("root", "")):
            if folder and str(path).startswith(str(folder)) and len(folder) > best_len:
                best, best_len = pair, len(folder)
    return best


def send_to_crew(path, text):
    """Hand the answers to that crew's Coordinator.

    Through `crew-say`, so it works whether the crew runs in the Crews window or
    in Konsole, and presses the Enter key that agent actually needs. It goes to
    the Coordinator because only the Coordinator deals with the owner (locked rules
    16). Before 17 September 2026 this typed into a Konsole session by an old
    conversation id: with the crew in the Crews window it silently did nothing,
    and twenty answered questions sat unread.
    """
    pair = crew_for(path)
    if not pair:
        return False, ""
    try:
        done = subprocess.run([str(Path.home() / "bin/crew-say"), pair, "coordinator", text],
                              capture_output=True, text=True, timeout=300)
        return done.returncode == 0, pair
    except Exception:
        return False, pair


def architect_id():
    """The Codex conversation id the Architect resumes, from the crew ids file."""
    try:
        for line in (Path.home() / ".local/share/crew/ids").read_text().splitlines():
            if line.startswith("ARCHITECT_CODEX_ID="):
                return line.split("=", 1)[1].strip()
    except OSError:
        pass
    return ""


def send_to_architect(text):
    """Type text into the Konsole session running the Architect. True if sent."""
    target = architect_id()
    if not target:
        return False
    try:
        services = subprocess.run(["qdbus"], capture_output=True, text=True, timeout=5).stdout
    except Exception:
        return False
    for svc in re.findall(r"org\.kde\.konsole-\d+", services):
        listing = subprocess.run(["qdbus", svc], capture_output=True, text=True, timeout=5).stdout
        for session in re.findall(r"^/Sessions/\d+$", listing, re.M):
            shell = subprocess.run(
                ["qdbus", svc, session, "org.kde.konsole.Session.processId"],
                capture_output=True, text=True, timeout=5).stdout.strip()
            if not shell.isdigit():
                continue
            tree = subprocess.run(["pstree", "-a", shell], capture_output=True,
                                  text=True, timeout=5).stdout
            if target in tree:
                done = subprocess.run(
                    ["qdbus", svc, session, "org.kde.konsole.Session.sendText", text + "\r"],
                    capture_output=True, text=True, timeout=5)
                return done.returncode == 0 and "AccessDenied" not in done.stderr + done.stdout
    return False


def copy_text(text):
    """Copy to the Wayland clipboard in a way that survives this window."""
    try:
        subprocess.Popen(["wl-copy", text], stdin=subprocess.DEVNULL,
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                         start_new_session=True)
    except Exception:
        QApplication.clipboard().setText(text)


class DragGrip(QWidget):
    """The standard resize corner, drawn inside the bottom-right of a text box
    like a web page's text area: drag it to make the box taller or shorter."""

    SIZE = 16

    def __init__(self, target):
        super().__init__(target)
        self.target = target
        self.setFixedSize(self.SIZE, self.SIZE)
        self.setCursor(Qt.CursorShape.SizeVerCursor)
        self.setToolTip("Drag to resize")
        self._start_y = None
        self._start_h = None
        target.installEventFilter(self)
        self.place()

    def place(self):
        self.move(self.target.width() - self.SIZE - 2, self.target.height() - self.SIZE - 2)
        self.raise_()

    def eventFilter(self, obj, event):
        from PyQt6.QtCore import QEvent
        if obj is self.target and event.type() == QEvent.Type.Resize:
            self.place()
        return False

    def paintEvent(self, event):
        from PyQt6.QtGui import QPainter, QPen, QColor
        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setPen(QPen(QColor("#9aa3b2"), 1.4))
        n = self.SIZE
        for offset in (4, 8, 12):   # three short diagonal strokes
            painter.drawLine(n - 2, n - 2 - offset, n - 2 - offset, n - 2)

    def mousePressEvent(self, event):
        self._start_y = event.globalPosition().y()
        self._start_h = self.target.height()

    def mouseMoveEvent(self, event):
        if self._start_y is None:
            return
        new_h = int(self._start_h + event.globalPosition().y() - self._start_y)
        self.target.setFixedHeight(max(50, min(900, new_h)))

    def mouseReleaseEvent(self, event):
        self._start_y = None
        card = self.target.parentWidget()
        if card is not None and not card.underMouse():
            self.setVisible(False)


class Card(QFrame):
    def __init__(self, step, shots_dir):
        super().__init__()
        self.step = step
        self.shots_dir = shots_dir
        self.shots = []
        self.setFrameShape(QFrame.Shape.StyledPanel)
        # Named so the answered outline can be aimed at this card alone. A plain
        # "QFrame { border }" also hit every frame inside it, so one answer drew
        # a box round the question and round each hint as well.
        self.setObjectName(f"step{step['n']}")
        box = QVBoxLayout(self)

        head = QLabel(f"<b>{step['n']}. {step['do']}</b>")
        head.setWordWrap(True)
        head.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
        head.setStyleSheet("font-size: 15px; color: palette(text);")
        box.addWidget(head)

        for label, key in (("Works", "works"), ("Broken", "broken")):
            if step[key]:
                colour = "#3fb950" if key == "works" else "#f0883e"
                hint = QLabel(f"<b style='color:{colour}'>{label}:</b> {step[key]}")
                hint.setWordWrap(True)
                hint.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
                hint.setStyleSheet("color: palette(text); font-size: 13px;")
                box.addWidget(hint)

        typed = re.findall(r"[“\"]([^”\"]+)[”\"]", step["do"])
        for phrase in typed:
            copy = QPushButton(f"Copy: {phrase[:40]}")
            copy.clicked.connect(lambda _=False, t=phrase: copy_text(t))
            box.addWidget(copy)

        self.group = QButtonGroup(self)
        for i, name in enumerate(("Worked", "Broken", "Skipped")):
            b = QRadioButton(name)
            self.group.addButton(b, i)
            box.addWidget(b)
            # Pressing the answer you already chose takes it back off, so a
            # mis-click is undone the same way it was made.
            b.pressed.connect(lambda btn=b: setattr(self, "_was_on", btn.isChecked()))
            b.clicked.connect(lambda _checked=False, btn=b: self.clear_if_repeated(btn))
        # No default: an unanswered step must never read as Worked.
        self.group.setExclusive(True)
        self.group.idToggled.connect(lambda *_: self.show_state())
        self.head = head

        self.notes = QTextEdit()
        self.notes.setPlaceholderText("What you saw (only if broken, or worth saying)")
        self.notes.setFixedHeight(70)
        box.addWidget(self.notes)
        # The small two-squares copy icon chat apps use, sitting inside the
        # top-right corner of the answer box. Hidden until the pointer is over
        # this step; turns into a tick for a moment after copying.
        self.copy_note = QToolButton(self.notes)
        self.copy_note.setIcon(QIcon.fromTheme("edit-copy"))
        self.copy_note.setAutoRaise(True)
        self.copy_note.setToolTip("Copy")
        self.copy_note.setCursor(Qt.CursorShape.PointingHandCursor)
        self.copy_note.setFixedSize(26, 26)
        self.copy_note.clicked.connect(self.copy_notes)
        self.copy_note.setVisible(False)
        self.notes.installEventFilter(self)
        self.place_copy_button()
        self.grip = DragGrip(self.notes)
        self.grip.setVisible(False)

        grab = QPushButton("Grab screenshot")
        grab.clicked.connect(self.grab)
        box.addWidget(grab)
        # one row per screenshot, each with its own remove button
        self.shot_rows = QVBoxLayout()
        box.addLayout(self.shot_rows)

    def place_copy_button(self):
        """Keep the copy icon in the box's top-right corner, clear of the
        scroll bar when one is showing."""
        bar = self.notes.verticalScrollBar()
        inset = bar.width() if bar.isVisible() else 0
        self.copy_note.move(self.notes.width() - self.copy_note.width() - 6 - inset, 4)
        self.copy_note.raise_()

    def eventFilter(self, obj, event):
        from PyQt6.QtCore import QEvent
        if obj is self.notes and event.type() == QEvent.Type.Resize:
            self.place_copy_button()
        return super().eventFilter(obj, event)

    def enterEvent(self, event):
        # the copy icon and the resize corner only show while pointing here
        self.copy_note.setVisible(True)
        self.grip.setVisible(True)
        super().enterEvent(event)

    def leaveEvent(self, event):
        self.copy_note.setVisible(False)
        if self.grip._start_y is None:   # never hide it mid-drag
            self.grip.setVisible(False)
        super().leaveEvent(event)

    def copy_notes(self):
        copy_text(self.notes.toPlainText())
        self.copy_note.setIcon(QIcon.fromTheme("dialog-ok-apply"))
        self.copy_note.setToolTip("Copied")
        QTimer.singleShot(1500, lambda: (
            self.copy_note.setIcon(QIcon.fromTheme("edit-copy")),
            self.copy_note.setToolTip("Copy"),
        ))

    def grab(self):
        # Spectacle's region mode; the window hides so it is not in the shot.
        self.shots_dir.mkdir(parents=True, exist_ok=True)
        out = self.shots_dir / f"step{self.step['n']}-{len(self.shots) + 1}.png"
        window = self.window()
        window.hide()
        QApplication.processEvents()
        try:
            subprocess.run(
                ["spectacle", "-r", "-b", "-n", "-o", str(out)],
                check=False, timeout=180,
            )
        except Exception as exc:
            window.show()
            QMessageBox.warning(self, "Screenshot failed", str(exc))
            return
        window.show()
        if out.exists():
            self.shots.append(out)
            self.add_shot_row(out)

    def add_shot_row(self, path):
        row = QWidget()
        line = QHBoxLayout(row)
        line.setContentsMargins(0, 0, 0, 0)
        name = QLabel(path.name)
        name.setStyleSheet("color: palette(text);")
        line.addWidget(name, 1)
        remove = QPushButton("✕")
        remove.setFixedWidth(32)
        remove.setToolTip("Remove this screenshot")
        remove.clicked.connect(lambda _=False, p=path, r=row: self.remove_shot(p, r))
        line.addWidget(remove)
        self.shot_rows.addWidget(row)

    def remove_shot(self, path, row):
        if path in self.shots:
            self.shots.remove(path)
        try:
            path.unlink()
        except FileNotFoundError:
            pass
        row.deleteLater()

    def clear_if_repeated(self, button):
        if getattr(self, "_was_on", False):
            self.group.setExclusive(False)
            button.setChecked(False)
            self.group.setExclusive(True)
        self.show_state()

    def show_state(self):
        """An answered step is outlined in the colour of its answer, so coming
        back to a half-finished checklist shows at a glance where you were."""
        chosen = self.group.checkedId()
        colour = {0: "#3fb950", 1: "#f0883e", 2: "#8b949e"}.get(chosen)
        tick = {0: " \u2713", 1: " \u2713", 2: " \u2713"}.get(chosen, "")
        if colour:
            self.setStyleSheet(
                f"QFrame#{self.objectName()} {{ border: 2px solid {colour};"
                " border-radius: 6px; }")
        else:
            self.setStyleSheet("")
        self.head.setText(
            f"<b>{self.step['n']}. {self.step['do']}</b>"
            + (f"<span style='color:{colour}'>{tick}</span>" if colour else ""))

    def answered(self):
        return self.group.checkedId() >= 0

    def answer(self):
        return (
            self.step,
            ("Worked", "Broken", "Skipped")[self.group.checkedId()],
            self.notes.toPlainText().strip(),
            list(self.shots),
        )


class Window(QMainWindow):
    def __init__(self, path):
        super().__init__()
        self.path = Path(path)
        duration, steps, tail = parse(self.path.read_text())
        if not steps:
            raise SystemExit(f"No numbered steps found in {self.path}")
        self.tail = tail
        self.setWindowTitle(f"CHECKRUN {self.path.stem}")

        inner = QWidget()
        col = QVBoxLayout(inner)
        if duration:
            top = QLabel(f"<b>{duration}</b>")
            top.setWordWrap(True)
            col.addWidget(top)

        self.shots_dir = self.path.with_name(self.path.stem + "_SHOTS")
        self.cards = []
        for step in steps:
            card = Card(step, self.shots_dir)
            self.cards.append(card)
            col.addWidget(card)

        self.mark_already_done()

        later = QPushButton("Save for later")
        later.setToolTip("Keep what you have answered and finish another time")
        later.clicked.connect(self.save_for_later)
        col.addWidget(later)
        send = QPushButton("Submit")
        send.clicked.connect(self.write)
        col.addWidget(send)
        self.load_saved()
        col.addStretch(1)

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setWidget(inner)

        # The way back sits above the list, outside the scrolling part, so it
        # is still there when they are lost halfway down a long checklist.
        frame = QWidget()
        outer = QVBoxLayout(frame)
        outer.setContentsMargins(0, 0, 0, 0)
        outer.addWidget(self.way_back_bar())
        outer.addWidget(scroll)
        self.setCentralWidget(frame)
        self.resize(460, 1040)

    def way_back_bar(self):
        """A button that puts the app back where the test starts."""
        bar = QWidget()
        box = QVBoxLayout(bar)
        box.setContentsMargins(8, 8, 8, 4)
        self.back_script, says = way_back(self.path)

        self.back_button = QPushButton("Take me back to the start")
        self.back_button.clicked.connect(self.go_back_to_start)
        box.addWidget(self.back_button)

        self.back_says = QLabel(says or "")
        self.back_says.setWordWrap(True)
        box.addWidget(self.back_says)

        # Have these checks already been answered today?
        crew = crew_for(self.path)
        seen = answered_recently(self.path, crew) if crew else None
        raw = self.path.read_text()
        excused = any(l.strip().lower().startswith("why again:")
                      for l in raw.splitlines())
        if seen and not excused:
            when, count = seen
            warn = QLabel(
                "You answered %d steps for this crew at %s and none were "
                "Broken. If these are the same checks again, tell the crew — "
                "you should not be asked to test the same thing twice."
                % (count, when.strftime("%H:%M")))
            warn.setWordWrap(True)
            box.addWidget(warn)
        elif excused:
            why = next(l.split(":", 1)[1].strip() for l in raw.splitlines()
                       if l.strip().lower().startswith("why again:"))
            again = QLabel("Some of this repeats earlier checks: " + why)
            again.setWordWrap(True)
            box.addWidget(again)

        if self.back_script is None:
            self.back_button.setEnabled(False)
            self.back_says.setText(
                "No way back was set up for this checklist. Tell the crew.")
        elif not says:
            self.back_says.setText("Puts the app back where step 1 begins.")
        return bar

    def go_back_to_start(self):
        """Run the way-back script and say what happened, in plain words."""
        if self.back_script is None:
            return
        said = self.back_says.text()
        try:
            subprocess.Popen(
                ["bash", str(self.back_script)],
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=True,
            )
        except Exception as e:
            self.back_says.setText("The way back would not run: %s" % e)
            return
        self.back_says.setText("Putting it back. Give it a few seconds.")
        QTimer.singleShot(
            6000, lambda: self.back_says.setText(said))

    def mark_already_done(self):
        """A step already answered Worked is filled in and closed.

        From a real sitting: the crew reworded fourteen checks the owner had just
        passed and handed them back. Nobody should be made to do the same test
        twice, so the card answers itself and says when it was done.
        """
        done = already_worked(crew_for(self.path))
        if not done:
            return
        for card in self.cards:
            tag = card.step.get("tag", "")
            hit = next((w for w, text, old_tag in done
                        if (tag and old_tag == tag)
                        or (not tag and not old_tag and same_step(text, card.step["do"]))),
                       None)
            if hit is None:
                continue
            card.group.button(0).setChecked(True)
            for b in card.group.buttons():
                b.setEnabled(False)
            card.notes.setEnabled(False)
            note = QLabel("You answered this Worked on %s. Nothing to do."
                          % hit.strftime("%d %b at %H:%M"))
            note.setWordWrap(True)
            card.layout().addWidget(note)
            card.show_state()

    def progress_path(self):
        return self.path.with_name(self.path.stem + "_PROGRESS.md")

    def save_for_later(self):
        """Keep every answer given so far, blanks stay blank, and close.
        Opening the same checklist again picks up exactly here."""
        lines = [f"Progress — {self.path.stem}", datetime.now().strftime("%Y-%m-%d %H:%M"), ""]
        done = 0
        for card in self.cards:
            if card.answered():
                done += 1
                verdict = ("Worked", "Broken", "Skipped")[card.group.checkedId()].upper()
            else:
                verdict = "UNANSWERED"
            lines.append(f"{card.step['n']}. {verdict} — {card.step['do']}")
            notes = card.notes.toPlainText().strip()
            if notes:
                lines.append("   Note:")
                for note_line in notes.splitlines():
                    lines.append(f"     {note_line}")
        self.progress_path().write_text("\n".join(lines) + "\n")
        QMessageBox.information(
            self, "Saved",
            f"{done} of {len(self.cards)} steps saved.\n\nOpen this checklist again to carry on where you stopped.")
        self.close()

    def load_saved(self):
        """Refill answers from a Save for later, if there is one."""
        prog = self.progress_path()
        if not prog.exists():
            return
        by_n = {c.step["n"]: c for c in self.cards}
        current = None
        ids = {"WORKED": 0, "BROKEN": 1, "SKIPPED": 2}
        for line in prog.read_text().splitlines():
            m = re.match(r"^(\d+)\.\s+([A-Z]+)\s+—", line)
            if m:
                current = by_n.get(m.group(1))
                if current and m.group(2) in ids:
                    current.group.button(ids[m.group(2)]).setChecked(True)
                continue
            if current and line.startswith("     "):
                old = current.notes.toPlainText()
                current.notes.setPlainText((old + "\n" if old else "") + line[5:])
        for card in self.cards:
            card.show_state()
        # Land on the first step still to answer rather than at the top.
        nxt = next((c for c in self.cards if not c.answered()), None)
        if nxt is not None:
            QTimer.singleShot(150, lambda c=nxt: self.centralWidget().ensureWidgetVisible(c))

    def write(self):
        missing = [c for c in self.cards if not c.answered()]
        if missing:
            nums = ", ".join(c.step["n"] for c in missing)
            sure = QMessageBox.question(
                self,
                "Some steps not answered",
                "Step" + ("s " if len(missing) > 1 else " ") + nums
                + " not answered yet.\n\nMark "
                + ("them" if len(missing) > 1 else "it")
                + " Skipped and write anyway?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No,
            )
            if sure != QMessageBox.StandardButton.Yes:
                self.centralWidget().ensureWidgetVisible(missing[0])
                return
            for c in missing:
                c.group.button(2).setChecked(True)
        out = self.path.with_name(self.path.stem + "_ANSWERS.md")
        lines = [
            f"Answers — {self.path.stem}",
            datetime.now().strftime("%Y-%m-%d %H:%M"),
            "",
        ]
        broken = []
        for card in self.cards:
            step, verdict, notes, shots = card.answer()
            lines.append(f"{step['n']}. {verdict.upper()} — {step['do']}")
            if notes:
                # indent EVERY line of the note, so a note line can never be
                # mistaken for the start of another step
                lines.append("   Note:")
                for note_line in notes.splitlines():
                    lines.append(f"     {note_line}")
            for shot in shots:
                lines.append(f"   screenshot: {shot}")
            if verdict == "Broken":
                broken.append(step["n"])
        lines.append("")
        lines.append(
            "Broken steps: " + (", ".join(broken) if broken else "none")
        )
        out.write_text("\n".join(lines) + "\n")
        log_answers(
            self.path, crew_for(self.path), len(self.cards), broken,
            [{"do": c.step["do"], "tag": c.step.get("tag", ""),
              "verdict": ("Worked", "Broken", "Skipped")[c.group.checkedId()]}
             for c in self.cards if c.answered()])

        # Qt's clipboard dies with the process on Wayland, and this window
        # closes straight after writing -- so hand the text to wl-copy, which
        # forks and keeps serving it.
        try:
            subprocess.Popen(
                ["wl-copy", str(out)],
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=True,
            )
        except Exception:
            QApplication.clipboard().setText(str(out))

        # Paste the path straight into the Architect's Konsole pane. Konsole
        # accepts text for one exact session over D-Bus (the security-sensitive
        # D-Bus setting is enabled on purpose) -- no mouse, no focus guessing.
        sent, pair = send_to_crew(
            out, f"the owner has finished the checklist. Their answers are in {out} —"
                 " read that file, do not ask them to retype anything.")

        tail = (
            f"Handed to the {pair} Coordinator."
            if sent
            else "Nobody could be handed it — the path is on your clipboard."
        )
        QMessageBox.information(self, "Written", f"Answers saved to\n{out}\n\n{tail}")
        self.close()


if __name__ == "__main__":
    if len(sys.argv) < 2:
        raise SystemExit(__doc__)
    app = QApplication(sys.argv)
    win = Window(sys.argv[1])
    win.show()
    sys.exit(app.exec())
