"""A small page on this computer for saying yes or no to what Feature Scout found.

It runs on 127.0.0.1 only, needs a one-off pass word that lives in memory for as long as the
command is running, and never builds anything itself — it writes a decision down and says what
happens next. Every decision goes through the same code the command line uses, so there is one
set of rules about what may follow what.
"""
from __future__ import annotations

import html
import hmac
import http.server
import json
import os
import secrets
import socket
import threading
import time
import urllib.parse
from datetime import datetime
from typing import Any, Callable

from .config import DB_PATH
from .projects import Project
from .decisions import apply_commands
from .store import Store, by_value

PORT_RANGE = range(8787, 8800)
PER_SECTION = 40
COOKIE = "feature_scout"
COOKIE_LIFE_S = 30 * 24 * 60 * 60

SECTIONS = [
    ("proposed", "Waiting on you", "New ideas. Say yes to the ones you want built."),
    ("approved", "Next up", "Waiting their turn to be built."),
    ("building", "Being built now", "This one is being worked on."),
    ("stalled", "Stopped part-way",
     "Work on these was interrupted — usually because the machine restarted. Nothing reached "
     "your project. Saying yes again picks up where it left off."),
    ("going_in", "Going into your project",
     "You said yes to these. They go in on the next pass, within half an hour."),
    ("built", "Ready to try", "Built and checked. Take a look, then put it into your project."),
    ("blocked", "Couldn't be done", "Something got in the way."),
    ("failed", "Didn't work", "The change was thrown away — nothing reached your project."),
    ("shipped", "In your project", "Added to your project."),
]
CHIP = {key: label for key, label, _blurb in SECTIONS}

# The board (2026-09-09, Ken: "more like a Jira board ... backlog vs build vs done"). Every
# stage lands in one of three columns plus a put-aside pile; dragging a card between columns
# is the same decision the buttons make, and dragging within Build sets the build order.
COLUMNS = [
    ("backlog", "Backlog", "Waiting on you. Drag a card to Build to say yes."),
    ("build", "Build", "In the order they will be built. Drag to reorder."),
    ("progress", "In progress", "Being built, or on its way into your project."),
    ("done", "Done", "In your project."),
]
COLUMN_OF = {"proposed": "backlog", "blocked": "backlog", "failed": "backlog", "stalled": "backlog",
             "approved": "build",
             "building": "progress", "built": "progress", "going_in": "progress",
             "shipped": "done",
             "rejected": "rejected"}
# What a drag is allowed to mean. Nothing can be dragged INTO In progress — the scout moves
# things there by itself — and a build under way cannot be dragged out of it.
DRAG_RULES = {
    "backlog": ["approved", "rejected", "blocked", "failed"],
    "build": ["proposed", "blocked", "failed", "rejected"],
    "progress": [],
    "done": ["built"],
    "rejected": ["proposed", "approved", "built", "blocked", "failed"],
}
DRAG_VERB = {"backlog": "backlog", "build": "approve", "done": "ship", "rejected": "reject"}


def stage(idea: dict[str, Any]) -> str:
    """Where an idea sits from the owner's point of view, which is not always its status.

    Saying "put it in" only writes the decision down; the merge happens on the next pass. Without
    this the card would sit in "Ready to try" looking as though the button had done nothing.
    """
    if idea.get("status") == "built" and idea.get("ship_requested_at"):
        return "going_in"
    if idea.get("status") == "building" and idea.get("_stalled"):
        return "stalled"
    return idea.get("status", "")


BUTTONS = {
    "proposed": [("approve", "Yes, build it", "yes"), ("reject", "No thanks", "")],
    "approved": [("reject", "Changed my mind", "")],
    "built": [("ship", "Put it into my project", "yes"), ("reject", "Throw it away", "")],
    "blocked": [("approve", "Try again", ""), ("reject", "Give up on it", "")],
    "failed": [("approve", "Try again", ""), ("reject", "Give up on it", "")],
    "shipped": [("undo", "Take it back out", "")],
    "going_in": [],
    "stalled": [("approve", "Pick it up again", "yes"), ("reject", "Give up on it", "")],
}

STYLE = """
:root { color-scheme: light dark; }
body { margin:0; background:#f4f1e8; color:#1c1b18;
       font:15px/1.5 Georgia,'Iowan Old Style',serif; }
main { max-width: 96rem; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; }
h1 { font-size:1.5rem; margin:0 0 .2rem; }
.sub, .note { color:#6b6559; font-size:.9rem; margin:0 0 .8rem; }
.bar { background:#efeadd; border:1px solid #d9d4c7; border-radius:6px; padding:.55rem .9rem;
       font-size:.9rem; color:#4a453c; margin:0 0 1rem; }
.lately { margin:0 0 1rem; }
.lately summary { cursor:pointer; color:#6b6559; font-size:.9rem; }
.lately ul { margin:.4rem 0 0; padding-left:1.1rem; font-size:.88rem; color:#4a453c; }
.lately li { margin:.15rem 0; }
.lately time { color:#6b6559; margin-right:.4rem; }
.lately .problem { color:#8a5300; }
.board { display:grid; grid-template-columns:repeat(4, minmax(15rem, 1fr)); gap:.9rem; align-items:start; }
.cards { min-height:6rem; }
.sortable-ghost { opacity:.35; }
.sortable-chosen { box-shadow:0 2px 8px rgba(0,0,0,.15); }
.col { background:#ece7da; border:1px solid #d9d4c7; border-radius:8px; padding:.75rem; min-height:12rem; }
.col h2 { font-size:1.05rem; margin:0 0 .1rem; display:flex; justify-content:space-between; align-items:baseline; }
.col h2 .count { font:.8rem Georgia,serif; color:#6b6559; }
.col .blurb { color:#6b6559; font-size:.82rem; margin:0 0 .7rem; }
.col.over { outline:2px dashed #8a7a55; outline-offset:-4px; background:#e6dfcc; }
.card { background:#fffdf7; border:1px solid #d9d4c7; border-radius:6px;
        padding:.7rem .85rem; margin:0 0 .6rem; cursor:grab; }
.card.dragging { opacity:.45; }
.card.over-top { box-shadow:0 -3px 0 #8a7a55; }
.card.over-bottom { box-shadow:0 3px 0 #8a7a55; }
.card h3 { margin:0 0 .3rem; font-size:.98rem; line-height:1.3; }
.card p { margin:.3rem 0; }
.card summary { cursor:pointer; color:#6b6559; font-size:.85rem; }
.card details[open] summary { margin-bottom:.3rem; }
.chips { display:flex; flex-wrap:wrap; gap:.3rem; margin:0 0 .35rem; }
.chip { font-size:.72rem; letter-spacing:.02em; padding:.1rem .45rem; border-radius:999px;
        border:1px solid #cfc8b7; color:#5b5344; background:#f4f1e8; }
.chip.stage { background:#e3dccb; }
.chip.warn { color:#8a5300; border-color:#d9b27a; background:#fbf1de; }
.chip.ok { color:#2f6f4e; border-color:#a9d3bc; background:#e9f5ee; }
.chip.pro { color:#6b4f12; border-color:#d9c58a; background:#fbf4dc; }
.meta { color:#6b6559; font-size:.85rem; }
.warn { color:#8a5300; }
form { display:inline; }
button { font:inherit; font-size:.85rem; padding:.3rem .7rem; margin:.45rem .3rem 0 0; cursor:pointer;
         border:1px solid #b9b2a1; border-radius:4px; background:#efeadd; color:#1c1b18; }
button.yes { background:#1c1b18; color:#f4f1e8; border-color:#1c1b18; }
a { color:#5b4a2f; }
.aside { margin-top:1.25rem; border:1px dashed #cfc8b7; border-radius:8px; padding:.6rem .9rem; }
.aside.over { background:#e6dfcc; outline:2px dashed #8a7a55; outline-offset:-4px; }
.aside summary { cursor:pointer; color:#6b6559; }
.aside .card { cursor:grab; margin-top:.6rem; }
.empty { color:#6b6559; font-style:italic; }
.hint { color:#6b6559; font-size:.82rem; margin:.2rem 0 1rem; }
@media (max-width: 80rem) { .board { grid-template-columns:repeat(2, minmax(15rem, 1fr)); } }
@media (max-width: 44rem) { .board { grid-template-columns:1fr; } }
@media (prefers-color-scheme: dark) {
  body { background:#17160f; color:#ece7da; }
  .col { background:#1d1b14; border-color:#3a352a; }
  .col.over, .aside.over { background:#2a271c; }
  .card { background:#201e16; border-color:#3a352a; }
  .bar { background:#201e16; border-color:#3a352a; color:#c9c3b4; }
  .chip { background:#2b281e; border-color:#4a4536; color:#c9c3b4; }
  .chip.stage { background:#34301f; }
  button { background:#2b281e; color:#ece7da; border-color:#4a4536; }
  button.yes { background:#ece7da; color:#17160f; }
  .sub, .note, .meta, .empty, .hint, .col .blurb, .col h2 .count, .card summary, .aside summary { color:#a29b8a; }
  a { color:#d8b06a; }
}
"""

SORTABLE_HOST = "https://cdnjs.cloudflare.com"
SORTABLE_SRC = SORTABLE_HOST + "/ajax/libs/Sortable/1.15.6/Sortable.min.js"

SCRIPT = r"""
(function () {
  var K = document.body.getAttribute('data-k');
  var RULES = JSON.parse(document.body.getAttribute('data-rules'));
  var VERB = JSON.parse(document.body.getAttribute('data-verbs'));
  function post(fields) {
    var body = Object.keys(fields).map(function (k) {
      return encodeURIComponent(k) + '=' + encodeURIComponent(fields[k]); }).join('&');
    fetch('/decide', {method: 'POST', body: body, credentials: 'same-origin',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'}})
      .then(function (r) { window.location.href = r.url || '/'; })
      .catch(function () { window.location.reload(); });
  }
  function say(text) {
    var bar = document.getElementById('said'); if (!bar) return;
    bar.textContent = text; bar.hidden = false;
  }
  if (typeof Sortable === 'undefined') { say('Drag and drop could not load; the buttons still work.'); return; }
  document.querySelectorAll('.cards[data-col]').forEach(function (list) {
    var col = list.dataset.col;
    Sortable.create(list, {
      group: {
        name: 'board',
        pull: function (to, from, dragEl) { return dragEl.dataset.status !== 'building'; },
        put: function (to, from, dragEl) { return RULES[to.el.dataset.col].indexOf(dragEl.dataset.status) >= 0; }
      },
      sort: col === 'build',
      animation: 120,
      ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen',
      onAdd: function (evt) {
        var card = evt.item;
        post({k: K, verb: VERB[col], id: card.dataset.id, was: card.dataset.status});
      },
      onUpdate: function () {
        if (col !== 'build') return;
        var ids = Array.prototype.map.call(list.querySelectorAll('.card[data-status="approved"]'),
          function (c) { return c.dataset.id; });
        post({k: K, verb: 'order', ids: ids.join(',')});
      }
    });
  });
})();
"""



def free_port() -> int:
    wanted = os.environ.get("SCOUT_REVIEW_PORT")
    candidates = [int(wanted)] if wanted else list(PORT_RANGE)
    for port in candidates:
        with socket.socket() as s:
            try:
                s.bind(("127.0.0.1", port))
                return port
            except OSError:
                continue
    return 0                      # let the machine pick one


def _e(value: Any) -> str:
    return html.escape(str(value or ""), quote=True)


def _chips(idea: dict[str, Any]) -> str:
    st = stage(idea)
    chips = []
    if st not in ("proposed",):
        kind = "warn" if st in ("blocked", "failed", "stalled") else "ok" if st in ("shipped", "built") else "stage"
        chips.append(f'<span class="chip {kind}">{_e(CHIP.get(st, st))}</span>')
    if idea.get("value_score"):
        chips.append(f'<span class="chip">Value {int(idea["value_score"])} of 5</span>')
    if idea.get("plan"):
        chips.append(f'<span class="chip pro">{_e(str(idea["plan"]).title())}</span>')
    effort = EFFORT_WORDS.get(str(idea.get("effort") or "").upper())
    if effort and st in ("proposed", "approved"):
        chips.append(f'<span class="chip">Roughly {effort}.</span>')
    if idea.get("auto_ship") and st in ("approved", "building", "built"):
        chips.append('<span class="chip">ships itself</span>')
    return f'<div class="chips">{"".join(chips)}</div>' if chips else ""


def idea_card(idea: dict[str, Any], token: str, project_name: str = "") -> str:
    st = stage(idea)
    bits = [f'<div class="card" data-id="{idea["id"]}" data-status="{_e(idea["status"])}">'
            f'<h3>#{idea["id"]} {_e(idea["title"])}</h3>']
    if project_name:
        bits.append(f'<p class="meta">{_e(project_name)}</p>')
    bits.append(_chips(idea))
    bits.append(f'<p>{_e(idea.get("summary"))}</p>')

    more = []
    if idea.get("value_score") and idea.get("value_reason"):
        more.append(f'<p class="meta"><b>Value {int(idea["value_score"])} of 5</b> — {_e(idea["value_reason"])}</p>')
    if idea.get("plan"):
        why = f' — {_e(idea["plan_reason"])}' if idea.get("plan_reason") else ""
        more.append(f'<p class="meta"><b>Plan: {_e(str(idea["plan"]).title())}</b>{why}</p>')
    if idea.get("user_value"):
        more.append(f'<p class="meta">Why people want it: {_e(idea["user_value"])}</p>')
    evidence = idea.get("evidence") or []      # the store hands this back already unpacked
    if isinstance(evidence, str):
        try:
            evidence = json.loads(evidence)
        except json.JSONDecodeError:
            evidence = []
    links = [f'<a href="{_e(e.get("url"))}" target="_blank" rel="noopener noreferrer">'
             f'{_e(e.get("note") or e.get("url"))}</a>'
             for e in evidence if isinstance(e, dict) and str(e.get("url", "")).startswith("http")]
    if links:
        more.append('<p class="meta">Where this came from: ' + " · ".join(links) + "</p>")

    report = {}
    try:
        report = json.loads(idea.get("build_report") or "{}")
    except (TypeError, json.JSONDecodeError):
        report = {}
    if report.get("summary"):
        bits.append(f'<p>{_e(report["summary"])}</p>')
    if report.get("how_to_try"):
        bits.append(f'<p class="meta">How to try it: {_e(report["how_to_try"])}</p>')
    if st == "going_in":
        bits.append('<p class="meta">Waiting to go in — this happens on the next pass, within '
                    "half an hour. You'll be able to take it back out afterwards.</p>")
    if idea.get("status") == "built":
        tests = (report.get("tests") or {}) if isinstance(report.get("tests"), dict) else {}
        if tests.get("passed"):
            bits.append('<p class="meta">Your tests pass with this change.</p>')
        else:
            bits.append('<p class="meta warn">Not checked by tests — try it yourself before '
                        "putting it in.</p>")
    if st in ("blocked", "failed", "stalled"):
        blurb = next((b for k, _l, b in SECTIONS if k == st), "")
        bits.append(f'<p class="meta">{_e(blurb)}</p>')
    if idea.get("error") and idea["status"] in ("blocked", "failed"):
        bits.append(f'<p class="meta warn">{_e(idea["error"][:400])}</p>')
    if more:
        bits.append('<details><summary>More</summary>' + "".join(more) + "</details>")

    buttons = []
    for verb, label, kind in BUTTONS.get(st, []):
        buttons.append(
            f'<form method="post" action="/decide">'
            f'<input type="hidden" name="k" value="{_e(token)}">'
            f'<input type="hidden" name="verb" value="{verb}">'
            f'<input type="hidden" name="id" value="{idea["id"]}">'
            f'<input type="hidden" name="was" value="{_e(idea["status"])}">'
            f'<button class="{kind}" type="submit">{label}</button></form>')
    bits.append("".join(buttons))
    bits.append("</div>")
    return "".join(bits)


EFFORT_WORDS = {"S": "a small job", "M": "a medium job", "L": "a big job"}


def mark_if_stopped(store: Store, idea: dict[str, Any], now: float | None = None,
                    booted: float | None = None) -> dict[str, Any]:
    """Flag a build that was interrupted, so the page can offer to pick it up again."""
    from .build import was_interrupted
    if was_interrupted(store, idea, now, booted):
        return {**idea, "_stalled": True}
    return idea


def in_reading_order(ideas: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Most valuable first, across every day (see ``store.by_value``).

    Ideas carry a 1..5 value score from the researcher. Ties keep the newest day first and,
    within a day, the order the researcher ranked them in — it is asked to rank best first
    and they are numbered as they arrive.
    """
    return by_value(ideas)


def column_order(col: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Backlog: what needs a look first, then most valuable. Build: the actual build order.
    Done: what is about to go in, then what is ready, then the newest arrivals."""
    if col == "backlog":
        needs_look = [i for i in rows if stage(i) in ("blocked", "failed", "stalled")]
        rest = [i for i in rows if stage(i) not in ("blocked", "failed", "stalled")]
        return by_value(needs_look) + by_value(rest)
    if col == "build":
        building = [i for i in rows if i.get("status") == "building"]
        queued = sorted((i for i in rows if i.get("status") != "building"),
                        key=lambda i: (i.get("decided_at") or 0, i["id"]))
        return building + queued
    rank = {"going_in": 0, "built": 1, "shipped": 2}
    return sorted(rows, key=lambda i: (rank.get(stage(i), 3), -(i.get("shipped_at") or i.get("built_at") or 0), -i["id"]))


def lately(store: Store) -> str:
    """What the scout has done since you last looked: builds finished, things put in, anything
    that went wrong. This is where the emails used to go; now it is on the page."""
    rows = store.notices()
    if not rows:
        return ""
    fresh = any(r["created_at"] > time.time() - 86400 for r in rows)
    items = []
    for r in rows:
        when = datetime.fromtimestamp(r["created_at"]).strftime("%a %H:%M")
        kind = "problem" if r["kind"] in ("problem", "failed", "blocked", "paused") else _e(r["kind"])
        items.append(f"<li class='{kind}'><time>{when}</time>{_e(r['text'])}</li>")
    return (f"<details class='lately'{' open' if fresh else ''}><summary>Lately ({len(rows)})</summary>"
            f"<ul>{''.join(items)}</ul></details>")


def page(store: Store, projects: list[Project], token: str, message: str = "", nonce: str = "") -> str:
    keys = {p.key for p in projects}
    names = {p.key: p.name for p in projects}
    ideas = [mark_if_stopped(store, i) for i in store.ideas() if not keys or i["platform"] in keys]
    title = projects[0].name if len(projects) == 1 else "your projects"

    midnight = int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
    spent = sum(store.spend_since(midnight, p.key) for p in projects)
    limits = [p.spend_limit_usd for p in projects if p.spend_limit_usd > 0]
    limit_text = f" of the ${sum(limits):.2f} you allow" if limits else ""

    out = ["<!doctype html><html><head><meta charset='utf-8'>",
           "<meta name='viewport' content='width=device-width,initial-scale=1'>",
           f"<title>Feature Scout — {_e(title)}</title><style>{STYLE}</style></head>",
           f"<body data-k='{_e(token)}' data-rules='{_e(json.dumps(DRAG_RULES))}' "
           f"data-verbs='{_e(json.dumps(DRAG_VERB))}'><main>",
           f"<h1>Feature Scout</h1><p class='sub'>Ideas for {_e(title)}.</p>",
           f"<div class='bar'>${spent:.2f} of your Claude usage spent today{limit_text}.</div>",
           f"<div class='bar' id='said'{'' if message else ' hidden'}>{_e(message)}</div>",
           "<p class='hint'>Drag a card between columns to decide; drag within Build to change the order. "
           "The buttons on each card do the same.</p>",
           lately(store)]

    by_col: dict[str, list[dict[str, Any]]] = {c: [] for c, _, _ in COLUMNS}
    by_col["rejected"] = []
    for i in ideas:
        by_col.setdefault(COLUMN_OF.get(stage(i), "backlog"), []).append(i)

    out.append("<div class='board'>")
    for col, heading, blurb in COLUMNS:
        rows = column_order(col, by_col[col])
        out.append(f"<section class='col'><h2>{heading}<span class='count'>{len(rows)}</span></h2>"
                   f"<p class='blurb'>{blurb}</p>")
        if col == "backlog" and any(stage(i) == "proposed" for i in rows):
            out.append("<p class='blurb'>" + _e(SECTIONS[0][1]) + ": " + _e(SECTIONS[0][2]) + "</p>")
        out.append(f"<div class='cards' data-col='{col}'>")
        for idea in rows[:PER_SECTION]:
            out.append(idea_card(idea, token, names.get(idea["platform"], "") if len(projects) > 1 else ""))
        out.append("</div>")
        if len(rows) > PER_SECTION:
            out.append(f"<p class='note'>…and {len(rows) - PER_SECTION} more, not shown here.</p>")
        if not rows:
            out.append("<p class='empty'>Nothing here.</p>")
        out.append("</section>")
    out.append("</div>")

    put_aside = by_value(by_col["rejected"])
    out.append(f"<details class='aside' open><summary>Put aside ({len(put_aside)}) — drop a card here to say no thanks; "
               "drag one out to bring it back</summary><div class='cards' data-col='rejected'>")
    for idea in put_aside[:PER_SECTION]:
        out.append(idea_card(idea, token, names.get(idea["platform"], "") if len(projects) > 1 else ""))
    out.append("</div>")
    if len(put_aside) > PER_SECTION:
        out.append(f"<p class='note'>…and {len(put_aside) - PER_SECTION} more, not shown here.</p>")
    out.append("</details>")

    if not ideas:
        out.append("<p class='empty'>Nothing yet. Run <code>feature-scout scan</code> to go "
                   "looking for ideas.</p>")
    n = f" nonce='{_e(nonce)}'" if nonce else ""
    out.append(f"</main><script{n} src='{SORTABLE_SRC}'></script><script{n}>{SCRIPT}</script></body></html>")
    return "".join(out)


def make_server(*, projects: list[Project], token: str, port: int = 0,
                store_factory: Callable[[], Store] | None = None,
                reader_factory: Callable[[], Store] | None = None,
                host: str = "127.0.0.1", public: bool = False
                ) -> http.server.ThreadingHTTPServer:
    """The page's server.

    On someone's own computer this listens on 127.0.0.1 only. The hosted instance serves the
    same page to the open internet behind a secret link, so `public` relaxes the check that the
    request was addressed to this machine (the hosting proxy rewrites that) and hardens the
    cookie instead.
    """
    open_store = store_factory or (lambda: Store(DB_PATH))
    # Reading the page must never wait on a build that is writing.
    open_for_reading = reader_factory or (lambda: Store(DB_PATH, readonly=True))

    class Handler(http.server.BaseHTTPRequestHandler):
        server_version = "FeatureScout"

        def log_message(self, *args):        # noqa: A003 - quiet; this is somebody's terminal
            pass

        # -- helpers
        def _addressed_here(self) -> bool:
            if public:
                return True          # the hosting proxy decides what reaches us
            return (self.headers.get("Host") or "").split(":")[0] in ("127.0.0.1", "localhost")

        def _has_pass_word(self) -> bool:
            """The secret arrives in the link the first time and in a cookie after that."""
            for morsel in (self.headers.get("Cookie") or "").split(";"):
                name, _, value = morsel.strip().partition("=")
                if name == COOKIE and hmac.compare_digest(value, token):
                    return True
            return False

        def _send(self, code: int, body: str, ctype: str = "text/html; charset=utf-8", nonce: str = "") -> None:
            raw = body.encode("utf-8")
            self.send_response(code)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(raw)))
            self.send_header("Cache-Control", "no-store")
            self.send_header("Referrer-Policy", "no-referrer")
            # Scripts: only this page's own (by a one-time nonce) and the drag library's host.
            # Everything else stays shut. Without the nonce no script ran at all, which is why
            # drag-and-drop silently did nothing on 2026-09-09 while the buttons worked.
            scripts = f"script-src 'nonce-{nonce}' {SORTABLE_HOST}; connect-src 'self'; form-action 'self'; " if nonce else ""
            self.send_header("Content-Security-Policy", f"default-src 'none'; style-src 'unsafe-inline'; {scripts}".strip())
            self.send_header("X-Content-Type-Options", "nosniff")
            self.end_headers()
            self.wfile.write(raw)

        def _go(self, where: str, cookie: bool = False) -> None:
            self.send_response(303)
            self.send_header("Location", where)
            if cookie:
                bits = f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={COOKIE_LIFE_S}"
                self.send_header("Set-Cookie", bits + ("; Secure" if public else ""))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()

        def _refuse(self) -> None:
            self._send(403, "<p>This page needs the private link it was sent to you on.</p>")

        # -- requests
        def do_GET(self) -> None:                                    # noqa: N802
            parsed = urllib.parse.urlparse(self.path)
            params = urllib.parse.parse_qs(parsed.query)
            if not self._addressed_here():
                return self._refuse()

            offered = params.get("k", [""])[0]
            if offered and hmac.compare_digest(offered, token):
                # Take the secret out of the address bar, the history and any link that gets
                # shared by accident. From here on the cookie carries it.
                return self._go("/", cookie=True)
            if not self._has_pass_word():
                return self._refuse()

            if parsed.path == "/decide":
                return self._send(405, "<p>Use the buttons on the page.</p>")
            if parsed.path not in ("/", ""):
                return self._send(404, "<p>Nothing here.</p>")
            store = open_for_reading()
            nonce = secrets.token_urlsafe(16)
            try:
                self._send(200, page(store, projects, token, params.get("said", [""])[0], nonce=nonce), nonce=nonce)
            finally:
                store.conn.close()

        def do_POST(self) -> None:                                   # noqa: N802
            if not self._addressed_here():
                return self._refuse()
            # The buttons are on our own page, so a press never legitimately comes from
            # another site. Belt and braces beside the secret in the form itself.
            if self.headers.get("Sec-Fetch-Site") not in (None, "same-origin", "none"):
                return self._refuse()
            length = int(self.headers.get("Content-Length") or 0)
            form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
            # The secret has to be in the form itself, not just the cookie, so that another
            # site cannot make your browser press one of these buttons for you.
            if not hmac.compare_digest(form.get("k", [""])[0], token):
                return self._refuse()
            if urllib.parse.urlparse(self.path).path != "/decide":
                return self._send(404, "<p>Nothing here.</p>")

            verb = form.get("verb", [""])[0]
            was = form.get("was", [""])[0]
            if verb == "order":
                try:
                    ids = [int(x) for x in form.get("ids", [""])[0].split(",") if x.strip()]
                except ValueError:
                    return self._refuse()
                store = open_store()
                try:
                    said = reorder(store, projects, ids)
                finally:
                    store.conn.close()
                return self._go(f"/?said={urllib.parse.quote(said)}", cookie=True)
            try:
                idea_id = int(form.get("id", [""])[0])
            except ValueError:
                return self._refuse()

            store = open_store()
            try:
                said = decide(store, projects, verb, idea_id, was)
            finally:
                store.conn.close()
            if verb in ("approve", "ship"):
                kick_build()
            # Back to a fresh page, so a reload can't press the button twice.
            self._go(f"/?said={urllib.parse.quote(said)}", cookie=True)

    server = http.server.ThreadingHTTPServer((host, port), Handler)
    server.daemon_threads = True
    return server


def kick_build() -> bool:
    """Start a build pass now rather than at the next scheduled check (Ken, 2026-09-09:
    'When there is an item in build, build it. No reason to wait.').

    SCOUT_KICK_CMD names the pass to run (the cloud sets it to its cron job's command). The
    scout's own lock makes a pass that overlaps a running one skip, so kicking is always safe.
    Its output goes where the cron's does — the container's main log — so the same watchers
    see it. Nothing happens on a computer without the setting."""
    cmd = os.environ.get("SCOUT_KICK_CMD", "").strip()
    if not cmd:
        return False
    import shlex
    import subprocess
    try:
        out = open("/proc/1/fd/1", "ab") if os.path.exists("/proc/1/fd/1") else None
        subprocess.Popen(shlex.split(cmd), stdout=out, stderr=subprocess.STDOUT, start_new_session=True,
                         close_fds=True)
        return True
    except Exception:  # noqa: BLE001 - a kick is a convenience; the schedule still runs
        return False


def decide(store: Store, projects: list[Project], verb: str, idea_id: int, was: str = "") -> str:
    """Apply one decision, and say in plain words what happens next."""
    idea = store.idea(idea_id)
    if not idea:
        return "I can't find that one any more."
    if was and idea["status"] != was:
        return (f"#{idea_id} has moved on since the page was drawn — it's now "
                f"{_status_words(idea['status'])}. Nothing was changed.")
    allowed = {p.key for p in projects}
    if allowed and idea["platform"] not in allowed:
        return "That idea belongs to another project."

    if verb == "undo":
        from .build import undo_idea
        project = next((p for p in projects if p.key == idea["platform"]), None)
        if project is None:
            return "That project isn't set up on this computer."
        return undo_idea(project, idea, store)["message"]

    if verb == "backlog":
        # Back to the backlog: a change of mind on something queued, or a second look at
        # something put aside or that did not work. Nothing that is being built or is in.
        if idea["status"] not in ("approved", "rejected", "blocked", "failed"):
            return f"#{idea_id} can't go back to the backlog while it is {_status_words(idea['status'])}."
        store.keep_approved(idea_id, "")            # reuse: sets status below
        store.conn.execute("UPDATE ideas SET status='proposed', error=NULL WHERE id=?", (idea_id,))
        store.conn.commit()
        return f"#{idea_id} {idea['title']}: back in the backlog."

    if verb not in ("approve", "reject", "ship"):
        return "I didn't understand that."
    lines = apply_commands(store, [(verb, [idea_id])])
    said = lines[0] if lines else "Done."
    if verb == "approve":
        said += " It'll be built the next time Feature Scout runs."
    if verb == "ship" and (store.idea(idea_id) or {}).get("ship_requested_at"):
        # Only when it was actually taken — a refusal has to keep saying why.
        said = (f"#{idea_id} {idea['title']}: going into your project on the next pass — "
                "within half an hour.")
    return said


def reorder(store: Store, projects: list[Project], ids: list[int]) -> str:
    """The owner dragged the Build column into a new order: make that the build order."""
    allowed = {p.key for p in projects}
    rows = [store.idea(i) for i in ids]
    rows = [r for r in rows if r and r["status"] == "approved" and (not allowed or r["platform"] in allowed)]
    if not rows:
        return "Nothing to reorder."
    store.reorder_queue([r["id"] for r in rows])
    return "Build order updated: " + ", ".join(f"#{r['id']}" for r in rows) + "."


def _status_words(status: str) -> str:
    return {"proposed": "waiting on you", "approved": "waiting to be built",
            "building": "being built", "built": "ready to try", "shipped": "in your project",
            "rejected": "put aside", "blocked": "stuck", "failed": "not working"}.get(status, status)


def private_link(base: str, token: str) -> str:
    return f"{base.rstrip('/')}/?k={urllib.parse.quote(token)}"


def hosted_settings() -> tuple[str, str] | None:
    """The secret and the address for a Feature Scout that serves its page to the internet.

    Both come from the machine's own settings; without a secret, nothing is served at all.
    """
    token = os.environ.get("SCOUT_REVIEW_TOKEN", "").strip()
    base = os.environ.get("SCOUT_REVIEW_URL", "").strip()
    if not token or len(token) < 24:
        return None
    return token, base


def serve(projects: list[Project], *, port: int | None = None, open_browser: bool = True,
          store_factory: Callable[[], Store] | None = None, host: str = "127.0.0.1",
          public: bool = False, token: str | None = None, base_url: str = ""
          ) -> tuple[http.server.ThreadingHTTPServer, str]:
    """Start the page in the background and hand back the server and the address to open."""
    token = token or secrets.token_urlsafe(24)   # on a laptop: this run only, never written down
    server = make_server(projects=projects, token=token,
                         port=port if port is not None else free_port(),
                         store_factory=store_factory, host=host, public=public)
    url = private_link(base_url or f"http://127.0.0.1:{server.server_address[1]}", token)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    if open_browser:
        try:
            import webbrowser
            webbrowser.open(url)
        except Exception:  # noqa: BLE001 - a browser that won't open is not a failure
            pass
    return server, url
