"""
Render the cockpit web page from a set of assessments.

The card is built around what Steven scans for, in order: the governing value
first, then the recommended maximum bid, then CAP Clean, then which platform the
car is on. The condition grade sits as a coloured circle on the photo. Below
that hero block sit the supporting figures and a link to bid.

House style: no hyphens and no dashes in any text the app writes. Manufacturer
trim names keep their real spelling.
"""

import html
import os
import re
import datetime
from .pricing import pounds


def _engine_label(car):
    """A short, readable engine for the review card: litres and fuel, since the
    model and trim (which often carries the family name) is in the title."""
    eng = car.engine or ""
    litres = re.search(r"\b\d\.\d\b", eng)
    fuel = ""
    for fu in ("Petrol", "Diesel", "Hybrid", "Electric"):
        if fu.lower() in eng.lower():
            fuel = fu
            break
    label = " ".join(x for x in [litres.group(0) if litres else "", fuel] if x)
    return label or (eng[:20] if eng else "not read")

_ASSETS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets")


# The one and only hide control, used on every page. A single quiet link opens a
# reason field, and one button saves the car and the reason to the database. The
# trigger says "Would not buy this" so it reads as opening a note. The reason box
# stays hidden (the hidden attribute, backed by .hidebox[hidden]{display:none} so
# CSS does not override it) until the link is clicked, then it reveals the field
# with a Send button and a Cancel button.
HIDE_BLOCK = """
        <div class="hide-row"><button class="hidelink" onclick="openHide(this)">Would not buy this</button></div>
        <div class="hidebox" hidden>
          <input class="hidereason" type="text" placeholder="Why would you not buy this car">
          <div class="hidebtns">
            <button class="confirm" onclick="confirmHide(this)">Send</button>
            <button class="cancel" onclick="cancelHide(this)">Cancel</button>
          </div>
        </div>"""

# The shared JS for the hide control. Pasted into any page that shows it so the
# behaviour never drifts between pages. Expects a flash(msg) helper to exist.
HIDE_JS = """
    async function post(url, body){
      const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
      if(!r.ok) throw new Error('save failed');
      return r.json();
    }
    function hideCardData(el){ const c=el.closest('.card'); return {
      reg:c.dataset.reg, make:c.dataset.make, model:c.dataset.model, name:c.dataset.name,
      max_bid: c.dataset.maxbid?+c.dataset.maxbid:null, reserve: c.dataset.reserve?+c.dataset.reserve:null }; }
    function openHide(btn){ const box=btn.closest('.card').querySelector('.hidebox'); box.hidden=false; const i=box.querySelector('.hidereason'); if(i) i.focus(); }
    function cancelHide(btn){ btn.closest('.hidebox').hidden = true; }
    async function confirmHide(btn){
      const reason = btn.closest('.hidebox').querySelector('.hidereason').value.trim();
      try {
        await post('/api/hide', {...hideCardData(btn), reason});
        btn.closest('.card').classList.add('gone');
        if (typeof applyView === 'function') applyView();
      } catch(e){ flash('Could not save. Open the page at its web address, not from a file.'); }
    }"""


def _logo(name):
    """Read a logo as a data URI so it embeds straight into the page. Returns ''
    if the asset is missing, and the pill falls back to text."""
    path = os.path.join(_ASSETS, name)
    try:
        with open(path, encoding="utf-8") as f:
            return f.read().strip()
    except Exception:
        return ""


MW_LOGO = _logo("mw_logo.datauri")
CW_LOGO = _logo("cw_logo.datauri")


def _esc(value) -> str:
    return html.escape(str(value if value is not None else ""))


def _platform_class(source):
    s = (source or "").strip().lower()
    if "motorway" in s:
        return "motorway"
    if "carwow" in s:
        return "carwow"
    return "other"


def _platform_pill(source):
    cls = _platform_class(source)
    if cls == "motorway":
        inner = (f'<img class="plat-logo" src="{MW_LOGO}" alt="Motorway">'
                 if MW_LOGO else f'<span class="plat-word">{_esc(source)}</span>')
    elif cls == "carwow":
        inner = (f'<img class="plat-logo" src="{CW_LOGO}" alt="Carwow">'
                 if CW_LOGO else '<span class="plat-word cw">carwow</span>')
    elif source:
        inner = f'<span class="plat-word">{_esc(source)}</span>'
    else:
        return ""
    return f'<span class="plat plat-{cls}">{inner}</span>'


def _photo(car, extra=""):
    grade = car.grade
    grade_badge = (
        f'<span class="grade grade-{_esc(grade)}" title="Condition grade {_esc(grade)}">{_esc(grade)}</span>'
        if grade is not None else ""
    )
    platform = _platform_pill(car.source)
    if car.photo_url:
        inner = f'<img src="{_esc(car.photo_url)}" alt="{_esc(car.make)} {_esc(car.model)}">'
        cls = "photo"
    else:
        inner = '<span class="noimg">No photo yet</span>'
        cls = "photo nophoto"
    return f'<div class="{cls}">{inner}{platform}{grade_badge}{extra}</div>'


def _title(car):
    bits = [str(car.year or ""), car.make, car.model, car.derivative]
    return " ".join(b for b in bits if b).strip()


def _headroom(car, max_bid):
    if car.reserve is None or max_bid is None:
        return ""
    diff = max_bid - car.reserve
    if diff >= 0:
        return f'<span class="head over">{pounds(diff)} over reserve</span>'
    return f'<span class="head under">{pounds(-diff)} under reserve</span>'


def _chip(label, value, cls=""):
    c = f"chip {cls}".strip()
    return f'<span class="{c}"><span class="cl">{_esc(label)}</span>{value}</span>'


def _stat(label, value, cls="stat"):
    return f'<div class="{cls}"><span class="cl">{_esc(label)}</span><b class="v">{value}</b></div>'


def _paid_compare(car, max_bid):
    if car.actually_paid is None:
        return ""
    diff = max_bid - car.actually_paid
    if diff >= 0:
        verdict = f"Recommendation sits {pounds(diff)} above what you paid."
        cls = "ok"
    else:
        verdict = f"Recommendation sits {pounds(-diff)} below what you paid."
        cls = "warn"
    return (f'<div class="paid {cls}"><span class="cl">You actually paid</span> '
            f'<b>{pounds(car.actually_paid)}</b><p>{_esc(verdict)}</p></div>')


def _nreg(reg):
    return "".join((reg or "").upper().split())


def _shortlist_card(a, bidset=None):
    bidset = bidset or set()
    car, p = a.car, a.pricing
    starred = _nreg(car.reg) in bidset
    seen_before = any(f.startswith("Seen before") or f.startswith("In both") for f in a.flags)
    headroom = (p.max_bid - car.reserve) if (p.max_bid is not None and car.reserve is not None) else 0
    name = _title(car)
    search = " ".join(str(x) for x in [car.reg, car.make, car.model, car.derivative] if x).lower()

    flags = "".join(f'<li>{_esc(f)}</li>' for f in a.flags)
    flags_block = f'<ul class="flags">{flags}</ul>' if flags else ""
    seen_badge = '<span class="repeat">seen before</span>' if seen_before else ""
    bid_link = (
        f'<a class="bid" href="{_esc(car.listing_url)}" target="_blank" rel="noopener">Open listing to bid</a>'
        if car.listing_url else '<span class="bid disabled">No listing link</span>'
    )
    star = (f'<button class="starbtn{" on" if starred else ""}" aria-pressed="{"true" if starred else "false"}" '
            f'onclick="toggleBid(this)" title="Add to my shortlist" aria-label="Add to my shortlist">★</button>')
    hide_block = HIDE_BLOCK

    valuation = "".join([
        _chip("Glass's", pounds(p.glass_retail)),
        _chip("Glass's plus 15", pounds(p.calc_glass)),
        _chip("Cazana", pounds(p.cazana_retail)),
        _chip("Cazana plus 15", pounds(p.calc_cazana)),
    ])
    mileage_v = f"{car.mileage:,}" if car.mileage is not None else "not read"
    owners_v = _esc(car.owners) if car.owners is not None else "not read"
    if car.distance_miles is not None:
        dist_label, dist_v = "Distance", f"{car.distance_miles:g} mi"
    elif car.location:
        dist_label, dist_v = "Location", _esc(car.location)
    else:
        dist_label, dist_v = "Distance", "not read"
    quickstats = f"""
        <div class="statcols">
          <div class="col left">
            {_stat("Year", _esc(car.year))}
            {_stat("Mileage", mileage_v)}
            {_stat("Owners", owners_v)}
          </div>
          <div class="col right">
            {_stat("CAP Clean", pounds(car.cap_clean), "capstat")}
            {_stat(dist_label, dist_v)}
          </div>
        </div>"""

    return f"""
    <article class="card" data-reg="{_esc(car.reg)}" data-make="{_esc(car.make)}" data-model="{_esc(car.model)}"
      data-name="{_esc(name)}" data-maxbid="{p.max_bid if p.max_bid is not None else ''}"
      data-reserve="{car.reserve if car.reserve is not None else ''}" data-platform="{_platform_class(car.source)}"
      data-headroom="{headroom}" data-search="{_esc(search)}">
      {_photo(car, star)}
      <div class="body">
        <header>
          <h2>{_esc(name)}</h2>
          <span class="reghead">{seen_badge}<span class="reg">{_esc(car.reg) or "no plate"}</span></span>
        </header>

        {quickstats}

        <div class="{'hero go' if (p.max_bid is not None and car.reserve is not None and p.max_bid >= car.reserve) else 'hero no'}">
          <div class="gov">
            <div class="gov-lbl"><span class="cl">Retails for</span><span class="src">via {_esc(p.governing_source)}</span></div>
            <span class="big">{pounds(p.governing_value)}</span>
          </div>
          <div class="hero-row">
            <div class="bid-amt">
              <span class="cl">Max bid</span>
              <span class="mid">{pounds(p.max_bid)}</span>
              {_headroom(car, p.max_bid)}
            </div>
            <div class="res-amt">
              <span class="cl">Reserve</span>
              <span class="mid">{pounds(car.reserve)}</span>
            </div>
          </div>
        </div>

        {flags_block}
        {_paid_compare(car, p.max_bid)}
        <details class="valn"><summary>Valuation detail</summary><div class="chips">{valuation}</div></details>
        {bid_link}
        {hide_block}
      </div>
    </article>
    """


def _plain_card(a, kind):
    car = a.car
    reasons = "".join(f'<li>{_esc(r)}</li>' for r in a.reasons)
    flags = "".join(f'<li>{_esc(f)}</li>' for f in a.flags)
    flags_block = f'<ul class="flags">{flags}</ul>' if flags else ""
    return f"""
    <article class="card slim {kind}">
      <div class="body">
        <header>
          <h2>{_esc(_title(car))}</h2>
          <span class="reg">{_esc(car.reg) or "no plate"}</span>
        </header>
        <ul class="reasons">{reasons}</ul>
        {flags_block}
      </div>
    </article>
    """


def _review_card(a):
    car = a.car
    if car.distance_miles is not None:
        dlabel, dval = "Distance", f"{car.distance_miles:g} mi"
    elif car.location:
        dlabel, dval = "Location", _esc(car.location)
    else:
        dlabel, dval = "Distance", "not read"
    sh = car.service_history or "not stated"
    name = _title(car)
    reg = car.reg or ("listing " + car.listing_url.rsplit("/", 1)[-1] if car.listing_url else "no plate")
    search = " ".join(str(x) for x in [car.reg, car.make, car.model, car.derivative] if x).lower()
    return f"""
    <article class="card review" data-reg="{_esc(reg)}" data-make="{_esc(car.make)}"
      data-model="{_esc(car.model)}" data-name="{_esc(name)}" data-search="{_esc(search)}">
      {_photo(car)}
      <div class="body">
        <header><h2>{_esc(name)}</h2><span class="reg">{_esc(car.reg) or "no plate"}</span></header>
        <div class="statcols">
          <div class="col left">
            {_stat("Year", _esc(car.year))}
            {_stat("Engine", _esc(_engine_label(car)))}
            {_stat("Gearbox", _esc(car.transmission or "not read"))}
            {_stat("Fuel", _esc(car.fuel or "not read"))}
            {_stat("Mileage", f"{car.mileage:,}" if car.mileage is not None else "not read")}
          </div>
          <div class="col right">
            {_stat("Reserve", pounds(car.reserve), "capstat")}
            {_stat("CAP Clean", pounds(car.cap_clean))}
            {_stat("Owners", _esc(car.owners) if car.owners is not None else "not read")}
            {_stat("History", _esc(sh))}
            {_stat(dlabel, dval)}
          </div>
        </div>
        {HIDE_BLOCK}
      </div>
    </article>"""


def _note_card(a):
    car = a.car
    name = _title(car)
    reg = car.reg or "no plate"
    stats = "".join([
        _stat("Engine", _esc(_engine_label(car))),
        _stat("Gearbox", _esc(car.transmission or "not read")),
        _stat("Fuel", _esc(car.fuel or "not read")),
        _stat("Mileage", f"{car.mileage:,}" if car.mileage is not None else "not read"),
        _stat("Owners", _esc(car.owners) if car.owners is not None else "not read"),
        _stat("Reserve", pounds(car.reserve)),
        _stat("CAP Clean", pounds(car.cap_clean)),
    ])
    return f"""
    <article class="card" data-reg="{_esc(car.reg or name)}" data-name="{_esc(name)}">
      {_photo(car)}
      <div class="body">
        <header><h2>{_esc(name)}</h2><span class="reg">{_esc(car.reg) or "no plate"}</span></header>
        <div class="notestats">{stats}</div>
        <textarea class="note" rows="3" placeholder="Why would you not buy this one? (engine, gearbox, model, value, anything)" oninput="noteChange(this)"></textarea>
      </div>
    </article>"""


def render_notes(cars, today=None) -> str:
    today = today or datetime.date.today()
    nice_date = today.strftime("%d %B %Y")
    cards = "".join(_note_card(a) for a in cars) or '<p class="empty">Nothing to explain.</p>'
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, why not these</title>
<style>
  :root {{ --ink:#13202b; --soft:#64757f; --line:#e4e9ef; --bg:#eef1f5; --card:#fff; --no:#b03a2e;
    --g1:#0c8a4a; --g3:#e08a00; --g4:#d9534f; --g5:#9b2d22; --motorway:#0a5fb4; --carwow:#7b3ff2; --accent:#0a5fb4; }}
  * {{ box-sizing:border-box; }}
  body {{ margin:0; background:var(--bg); color:var(--ink);
    font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; line-height:1.4;
    font-variant-numeric:tabular-nums; -webkit-font-smoothing:antialiased; }}
  .topbar {{ background:var(--ink); color:#fff; padding:14px 22px; }}
  .topbar h1 {{ margin:0; font-size:19px; }}
  .topbar .sub {{ color:#9fb1c0; font-size:13px; margin-top:2px; }}
  .picker {{ position:sticky; top:0; z-index:9; background:#fff; border-bottom:1px solid var(--line); padding:12px 22px; box-shadow:0 2px 8px rgba(20,30,40,.06); }}
  .picker p {{ margin:0 0 6px; font-size:13px; color:var(--soft); }} .picker b {{ color:var(--accent); }}
  .picker textarea {{ width:100%; height:64px; font-family:ui-monospace,Menlo,monospace; font-size:12px; border:1px solid var(--line); border-radius:8px; padding:7px; }}
  .wrap {{ max-width:1200px; margin:0 auto; padding:20px; }}
  .grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:18px; }}
  .card {{ background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden; box-shadow:0 1px 3px rgba(20,30,40,.08); display:flex; flex-direction:column; }}
  .photo {{ position:relative; aspect-ratio:16/10; background:#dde4ea; }}
  /* Only the car photo fills the box. The logo img inside the pill is sized by
     .plat-logo, so scope this to the direct child or it crops the logo. */
  .photo > img {{ width:100%; height:100%; object-fit:cover; display:block; }}
  .nophoto {{ display:flex; align-items:center; justify-content:center; }} .noimg {{ color:#92a3b1; font-size:14px; }}
  .plat {{ position:absolute; top:10px; left:10px; height:26px; border-radius:7px; display:flex; align-items:center; padding:0 8px; box-shadow:0 1px 5px rgba(0,0,0,.35); }}
  .plat-logo {{ height:15px; display:block; }} .plat-word {{ font-size:13px; font-weight:800; color:#fff; }} .plat-word.cw {{ color:#16c8d6; font-size:15px; }}
  .plat-motorway {{ background:#f2f500; padding:0 7px; }} .plat-carwow {{ background:#fff; }} .plat-other {{ background:#55606b; }}
  .grade {{ position:absolute; bottom:10px; right:10px; width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:21px; border:3px solid #fff; box-shadow:0 2px 6px rgba(0,0,0,.35); background:#55606b; }}
  .grade-1 {{ background:var(--g1); }} .grade-2 {{ background:#f6cb00; color:#13202b; }} .grade-3 {{ background:var(--g3); }} .grade-4 {{ background:var(--g4); }} .grade-5 {{ background:var(--g5); }}
  .body {{ padding:14px 16px 16px; display:flex; flex-direction:column; gap:11px; }}
  header {{ display:flex; justify-content:space-between; align-items:baseline; gap:10px; }}
  header h2 {{ font-size:16px; margin:0; line-height:1.25; }}
  .reg {{ font-weight:700; background:#f4d000; color:#13202b; padding:2px 9px; border-radius:5px; font-size:12.5px; letter-spacing:.5px; white-space:nowrap; }}
  .notestats {{ display:grid; grid-template-columns:1fr 1fr; gap:3px 14px; }}
  .stat {{ font-size:13.5px; }} .stat .cl {{ display:inline; font-size:10px; margin-right:6px; color:var(--soft); text-transform:uppercase; letter-spacing:.5px; }} .stat .v {{ font-weight:700; }}
  textarea.note {{ width:100%; border:1px solid var(--line); border-radius:9px; padding:9px; font-size:14px; font-family:inherit; resize:vertical; }}
  textarea.note:focus {{ outline:2px solid var(--accent); border-color:var(--accent); }}
  .empty {{ color:var(--soft); }}
  footer {{ color:var(--soft); font-size:12px; text-align:center; padding:28px 0; }}
</style>
</head>
<body>
  <div class="topbar">
    <h1>BidBrain, why not these</h1>
    <div class="sub">{nice_date}. {len(cars)} cars you rejected that the rules did not catch. Write why next to each, then copy the box below to me.</div>
  </div>
  <div class="picker">
    <p>Notes written: <b id="count">0</b>. Copy this box and paste it to BidBrain when done.</p>
    <textarea id="allnotes" readonly placeholder="Your notes appear here as you type"></textarea>
  </div>
  <div class="wrap"><div class="grid">{cards}</div></div>
  <footer>BidBrain learning step. Your reasons become the next rules.</footer>
  <script>
    const KEY='bidbrain_notes';
    let notes=JSON.parse(localStorage.getItem(KEY)||'{{}}');
    const names={{}};
    document.querySelectorAll('.card').forEach(c=>names[c.dataset.reg]=c.dataset.name);
    function refresh(){{
      const items=Object.entries(notes).filter(([r,n])=>(n||'').trim());
      document.getElementById('count').textContent=items.length;
      document.getElementById('allnotes').value=items.map(([r,n])=>r+'  '+(names[r]||'')+'  ::  '+n.trim()).join('\\n');
      localStorage.setItem(KEY,JSON.stringify(notes));
    }}
    function noteChange(t){{ const reg=t.closest('.card').dataset.reg; notes[reg]=t.value; refresh(); }}
    document.querySelectorAll('.card').forEach(card=>{{ const ta=card.querySelector('textarea.note'); if(ta&&notes[card.dataset.reg]) ta.value=notes[card.dataset.reg]; }});
    refresh();
  </script>
</body>
</html>
"""


def render_review(keepers, today=None) -> str:
    today = today or datetime.date.today()
    nice_date = today.strftime("%d %B %Y")
    cards = "".join(_review_card(a) for a in keepers) or '<p class="empty">No cars passed the gate.</p>'
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain review</title>
<style>
  :root {{ --ink:#13202b; --soft:#64757f; --line:#e4e9ef; --bg:#eef1f5; --card:#fff; --no:#b03a2e;
    --g1:#0c8a4a; --g3:#e08a00; --g4:#d9534f; --g5:#9b2d22; --motorway:#0a5fb4; --carwow:#7b3ff2; }}
  * {{ box-sizing:border-box; }}
  body {{ margin:0; background:var(--bg); color:var(--ink);
    font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; line-height:1.4;
    font-variant-numeric:tabular-nums; -webkit-font-smoothing:antialiased; }}
  .topbar {{ background:var(--ink); color:#fff; padding:14px 22px; }}
  .topbar h1 {{ margin:0; font-size:19px; }}
  .topbar .sub {{ color:#9fb1c0; font-size:13px; margin-top:2px; }}
  .picker {{ position:sticky; top:0; z-index:9; background:#fff; border-bottom:1px solid var(--line);
    padding:12px 22px; box-shadow:0 2px 8px rgba(20,30,40,.06); }}
  .picker p {{ margin:0 0 6px; font-size:13px; color:var(--soft); }}
  .picker b {{ color:var(--no); }}
  .picker textarea {{ width:100%; height:54px; font-family:ui-monospace,Menlo,monospace; font-size:12px;
    border:1px solid var(--line); border-radius:8px; padding:7px; resize:vertical; }}
  .wrap {{ max-width:1200px; margin:0 auto; padding:20px; }}
  .grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:18px; }}
  .card {{ background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden;
    box-shadow:0 1px 3px rgba(20,30,40,.08); display:flex; flex-direction:column; transition:opacity .15s, outline .15s; }}
  .card.out {{ opacity:.4; outline:3px solid var(--no); outline-offset:-3px; }}
  .photo {{ position:relative; aspect-ratio:16/10; background:#dde4ea; }}
  /* Only the car photo fills the box. The logo img inside the pill is sized by
     .plat-logo, so scope this to the direct child or it crops the logo. */
  .photo > img {{ width:100%; height:100%; object-fit:cover; display:block; }}
  .nophoto {{ display:flex; align-items:center; justify-content:center; }}
  .noimg {{ color:#92a3b1; font-size:14px; }}
  .plat {{ position:absolute; top:10px; left:10px; height:26px; border-radius:7px; display:flex; align-items:center;
    padding:0 8px; box-shadow:0 1px 5px rgba(0,0,0,.35); }}
  .plat-logo {{ height:15px; display:block; }} .plat-word {{ font-size:13px; font-weight:800; color:#fff; }}
  .plat-word.cw {{ color:#16c8d6; font-size:15px; }}
  .plat-motorway {{ background:#f2f500; padding:0 7px; }} .plat-carwow {{ background:#fff; }} .plat-other {{ background:#55606b; }}
  .grade {{ position:absolute; bottom:10px; right:10px; width:44px; height:44px; border-radius:50%; display:flex;
    align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:21px; border:3px solid #fff;
    box-shadow:0 2px 6px rgba(0,0,0,.35); background:#55606b; }}
  .grade-1 {{ background:var(--g1); }} .grade-2 {{ background:#f6cb00; color:#13202b; }}
  .grade-3 {{ background:var(--g3); }} .grade-4 {{ background:var(--g4); }} .grade-5 {{ background:var(--g5); }}
  .body {{ padding:14px 16px 16px; display:flex; flex-direction:column; gap:12px; flex:1 1 auto; }}
  header {{ display:flex; justify-content:space-between; align-items:baseline; gap:10px; }}
  header h2 {{ font-size:16px; margin:0; line-height:1.25; min-height:2.5em; }}
  .reg {{ font-weight:700; background:#f4d000; color:#13202b; padding:2px 9px; border-radius:5px; font-size:12.5px; letter-spacing:.5px; white-space:nowrap; }}
  .cl {{ display:block; font-size:10.5px; text-transform:uppercase; letter-spacing:.7px; color:var(--soft); font-weight:600; }}
  .statcols {{ display:flex; justify-content:space-between; gap:14px; }}
  .col {{ display:flex; flex-direction:column; gap:7px; }} .col.right {{ align-items:flex-end; text-align:right; }}
  .stat {{ font-size:15px; }} .stat .cl {{ display:inline; font-size:10.5px; margin-right:6px; letter-spacing:.5px; }}
  .stat .v {{ font-weight:700; }}
  .capstat {{ background:#e9f1fc; border:1px solid #b6d2f0; border-radius:8px; padding:4px 10px; color:#0a5fb4; font-size:15px; }}
  .capstat .cl {{ display:inline; color:#0a5fb4; opacity:.85; margin-right:6px; font-size:10px; }}
  .capstat .v {{ font-weight:800; }}
  .empty {{ color:var(--soft); }}
  .card.gone {{ display:none; }}
  /* Hide: a quiet link that opens a tidy reason panel, saving to the database. */
  .hide-row {{ margin-top:auto; text-align:center; }}
  .hidelink {{ background:none; border:none; color:var(--soft); font-size:12.5px; cursor:pointer; padding:4px; }}
  .hidelink:hover {{ color:var(--no); text-decoration:underline; }}
  .hidebox {{ display:flex; flex-direction:column; gap:8px; margin-top:4px; background:#fbf3f1;
    border:1px solid #ecd9d5; border-radius:10px; padding:10px; }}
  .hidebox[hidden] {{ display:none; }}
  .hidebox input {{ border:1px solid var(--line); border-radius:9px; padding:9px; font-size:14px; }}
  .hidebtns {{ display:flex; gap:8px; }}
  .hidebox .confirm {{ flex:1; background:var(--no); color:#fff; border:none; border-radius:9px; padding:9px 12px; font-weight:700; cursor:pointer; }}
  .hidebox .cancel {{ flex:1; background:#eef1f5; border:none; border-radius:9px; padding:9px 12px; cursor:pointer; }}
  #flash {{ display:none; position:fixed; left:50%; bottom:20px; transform:translateX(-50%); background:var(--no);
    color:#fff; padding:11px 16px; border-radius:10px; font-size:14px; box-shadow:0 4px 14px rgba(0,0,0,.25); z-index:50; }}
  footer {{ color:var(--soft); font-size:12px; text-align:center; padding:28px 0; }}
</style>
</head>
<body>
  <div class="topbar">
    <h1>BidBrain review, tomorrow's sales</h1>
    <div class="sub">{nice_date}. {len(keepers)} cars passed your rules, not yet valued. Hide any you would not buy and your reason is saved to BidBrain.</div>
  </div>
  <div class="wrap"><div class="grid">{cards}</div></div>
  <div id="flash"></div>
  <footer>BidBrain review step. No cars valued yet. You place every bid yourself.</footer>
  <script>
    function flash(m){{ const f=document.getElementById('flash'); f.textContent=m; f.style.display='block';
      clearTimeout(f._t); f._t=setTimeout(()=>f.style.display='none', 6000); }}
    {HIDE_JS}
  </script>
</body>
</html>
"""


def render_hidden(today=None) -> str:
    """A small live page listing cars Steven has hidden, each with an unhide
    button. It fetches the current list from the server, so it is always up to
    date. Must be opened at the served address, not as a file."""
    today = today or datetime.date.today()
    return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, hidden cars</title>
<style>
  :root { --ink:#13202b; --soft:#64757f; --line:#e4e9ef; --bg:#eef1f5; --no:#b03a2e; --motorway:#0a5fb4; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; }
  .topbar { background:var(--ink); color:#fff; padding:14px 22px; }
  .topbar h1 { margin:0; font-size:19px; }
  .topbar a { color:#9fb1c0; font-size:13px; text-decoration:none; }
  .wrap { max-width:760px; margin:0 auto; padding:20px; }
  .row { background:#fff; border:1px solid var(--line); border-radius:12px; padding:13px 15px; margin-bottom:10px;
    display:flex; align-items:center; gap:14px; }
  .row .info { flex:1; }
  .row .name { font-weight:700; }
  .row .reason { color:var(--soft); font-size:13px; margin-top:2px; }
  .row .reg { font-weight:700; background:#f4d000; padding:2px 8px; border-radius:5px; font-size:12.5px; }
  .unhide { background:var(--motorway); color:#fff; border:none; border-radius:9px; padding:9px 13px; font-weight:700; cursor:pointer; }
  .empty { color:var(--soft); }
  #flash { position:fixed; left:50%; bottom:22px; transform:translateX(-50%); background:var(--no); color:#fff; padding:11px 16px; border-radius:10px; display:none; }
</style>
</head>
<body>
  <div class="topbar"><h1>Hidden cars</h1><a href="cockpit.html">Back to the cockpit</a></div>
  <div class="wrap"><div id="list"><p class="empty">Loading...</p></div></div>
  <div id="flash"></div>
  <script>
    function flash(m){ const f=document.getElementById('flash'); f.textContent=m; f.style.display='block'; setTimeout(()=>f.style.display='none',5000); }
    async function load(){
      try {
        const r = await fetch('/api/hidden'); const d = await r.json();
        const list = document.getElementById('list');
        if(!d.hidden || !d.hidden.length){ list.innerHTML='<p class="empty">No cars are hidden.</p>'; return; }
        list.innerHTML='';
        d.hidden.forEach(h => {
          const row=document.createElement('div'); row.className='row';
          row.innerHTML = '<span class="reg">'+(h.reg||'')+'</span>'+
            '<div class="info"><div class="name">'+(h.name||(h.make+' '+h.model)||'')+'</div>'+
            '<div class="reason">'+(h.reason||'no reason given')+'</div></div>'+
            '<button class="unhide">Unhide</button>';
          row.querySelector('.unhide').onclick = async () => {
            try { await fetch('/api/unhide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reg:h.reg})});
              row.remove(); if(!list.querySelector('.row')) list.innerHTML='<p class="empty">No cars are hidden.</p>'; }
            catch(e){ flash('Could not save. Open this page at its web address.'); }
          };
          list.appendChild(row);
        });
      } catch(e){ document.getElementById('list').innerHTML='<p class="empty">Could not load. Open this page at its web address, not from a file.</p>'; }
    }
    load();
  </script>
</body>
</html>
"""


def render_page(shortlist, held, rejected, today=None, show_excluded=True,
                show_rejected=None, bidset=None, sale_date=None) -> str:
    if show_rejected is None:
        show_rejected = show_excluded
    today = today or datetime.date.today()
    bidset = bidset or set()
    sale_date = sale_date or today.isoformat()
    nice_date = today.strftime("%d %B %Y")

    shortlist_html = "".join(_shortlist_card(a, bidset) for a in shortlist) or \
        '<p class="empty">No cars made the shortlist in this set.</p>'

    held_section = ""
    rejected_section = ""
    if show_excluded and held:
        held_html = "".join(_plain_card(a, "held") for a in held)
        held_section = f"""
      <section>
        <h1 class="section held">Held back, {len(held)}</h1>
        <p class="lead">These pass the rules but a valuation could not be read with confidence, so they are not priced on a guess.</p>
        <div class="grid">{held_html}</div>
      </section>"""

    if show_rejected and rejected:
        rejected_html = "".join(_plain_card(a, "rejected") for a in rejected)
        rejected_section = f"""
      <section>
        <h1 class="section rejected">Rejected, {len(rejected)}</h1>
        <p class="lead">These broke a rule. Each one shows why.</p>
        <div class="grid">{rejected_html}</div>
      </section>"""

    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain cockpit</title>
<style>
  :root {{
    --ink:#13202b; --soft:#64757f; --line:#e4e9ef; --bg:#eef1f5; --card:#fff;
    --go:#0a7d44; --no:#b03a2e; --hold:#b7791f;
    --g1:#0c8a4a; --g2:#6aa516; --g3:#e08a00; --g4:#d9534f; --g5:#9b2d22;
    --motorway:#0a5fb4; --carwow:#7b3ff2;
  }}
  * {{ box-sizing:border-box; }}
  body {{ margin:0; background:var(--bg); color:var(--ink);
    font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; line-height:1.4;
    font-variant-numeric:tabular-nums; -webkit-font-smoothing:antialiased; }}
  .topbar {{ background:var(--ink); color:#fff; padding:16px 22px; }}
  .topbar h1 {{ margin:0; font-size:19px; letter-spacing:.3px; }}
  .topbar .sub {{ color:#9fb1c0; font-size:13px; margin-top:2px; }}
  .wrap {{ max-width:1200px; margin:0 auto; padding:20px; }}
  .section {{ font-size:14px; text-transform:uppercase; letter-spacing:1px; color:var(--soft);
    border-bottom:2px solid var(--line); padding-bottom:8px; margin:26px 0 6px; }}
  .section.held {{ color:var(--hold); }} .section.rejected {{ color:var(--no); }}
  .lead {{ color:var(--soft); font-size:13px; margin:4px 0 16px; }}
  .grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:18px; }}
  .card {{ background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden;
    box-shadow:0 1px 3px rgba(20,30,40,.08); display:flex; flex-direction:column;
    transition:box-shadow .15s ease, transform .15s ease; }}
  .card:hover {{ box-shadow:0 8px 22px rgba(20,30,40,.13); transform:translateY(-2px); }}

  .photo {{ position:relative; aspect-ratio:16/10; background:#dde4ea; }}
  /* Only the car photo fills the box. The logo img inside the pill is sized by
     .plat-logo, so scope this to the direct child or it crops the logo. */
  .photo > img {{ width:100%; height:100%; object-fit:cover; display:block; }}
  .nophoto {{ display:flex; align-items:center; justify-content:center; }}
  .noimg {{ color:#92a3b1; font-size:14px; }}
  .plat {{ position:absolute; top:10px; left:10px; height:26px; border-radius:7px;
    display:flex; align-items:center; padding:0 8px; box-shadow:0 1px 5px rgba(0,0,0,.35); }}
  .plat-logo {{ height:15px; display:block; }}
  .plat-word {{ font-size:13px; font-weight:800; color:#fff; letter-spacing:.2px; }}
  .plat-word.cw {{ color:#16c8d6; font-size:15px; }}
  .plat-motorway {{ background:#f2f500; padding:0 7px; }}
  .plat-carwow {{ background:#fff; }}
  .plat-other {{ background:#55606b; }}
  /* The platform pill is smaller on phones so it does not dominate the photo. */
  @media (max-width:600px) {{
    .plat {{ top:8px; left:8px; height:20px; border-radius:6px; padding:0 6px; }}
    .plat-logo {{ height:12px; }}
    .plat-word {{ font-size:10px; }}
    .plat-word.cw {{ font-size:12px; }}
    .plat-motorway {{ padding:0 6px; }}
  }}
  .grade {{ position:absolute; bottom:10px; right:10px; width:44px; height:44px; border-radius:50%;
    display:flex; align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:21px;
    border:3px solid #fff; box-shadow:0 2px 6px rgba(0,0,0,.35); background:#55606b; }}
  .grade-1 {{ background:var(--g1); }}
  .grade-2 {{ background:#f6cb00; color:#13202b; }}
  .grade-3 {{ background:var(--g3); }} .grade-4 {{ background:var(--g4); }} .grade-5 {{ background:var(--g5); }}

  .body {{ padding:14px 16px 16px; display:flex; flex-direction:column; gap:12px; flex:1 1 auto; }}
  header {{ display:flex; justify-content:space-between; align-items:baseline; gap:10px; }}
  /* Reserve two lines for the name so the cards and their bid buttons line up
     even when a make and model needs a second line. */
  header h2 {{ font-size:16px; margin:0; line-height:1.25; min-height:2.5em; }}
  .reg {{ font-weight:700; background:#f4d000; color:#13202b; padding:2px 9px; border-radius:5px;
    font-size:12.5px; letter-spacing:.5px; white-space:nowrap; box-shadow:inset 0 0 0 1px rgba(0,0,0,.08); }}

  .cl {{ display:block; font-size:10.5px; text-transform:uppercase; letter-spacing:.7px; color:var(--soft); font-weight:600; }}
  .hero {{ background:#f6f8fa; border:1px solid var(--line); border-left:4px solid var(--line);
    border-radius:13px; padding:14px 15px; margin-top:10px; }}
  .hero.go {{ border-left-color:var(--go); }}
  .hero.no {{ border-left-color:var(--no); }}
  .gov {{ border-bottom:1px solid var(--line); padding-bottom:11px; margin-bottom:11px; }}
  .gov-lbl {{ display:flex; justify-content:space-between; align-items:baseline; }}
  .gov .big {{ display:block; font-size:36px; font-weight:800; line-height:1; letter-spacing:-.6px; margin-top:4px; }}
  .gov .src {{ font-size:10px; text-transform:uppercase; letter-spacing:.5px; color:var(--soft); font-weight:600; }}
  .hero-row {{ display:flex; gap:14px; align-items:flex-start; }}
  .hero-row > div {{ flex:1; }}
  .res-amt {{ text-align:right; }}
  .bid-amt .mid {{ font-size:27px; font-weight:800; color:var(--go); line-height:1.1; display:block; }}
  .res-amt .mid {{ font-size:20px; font-weight:700; line-height:1.1; display:block; color:var(--ink); }}
  .head {{ display:inline-block; margin-top:3px; font-size:11px; font-weight:700; padding:1px 7px; border-radius:999px; }}
  .head.over {{ background:#e4f4ea; color:var(--go); }}
  .head.under {{ background:#f7e9e6; color:var(--no); }}

  .chips {{ display:flex; flex-wrap:wrap; gap:6px; }}
  .chip {{ background:#f2f5f8; border:1px solid var(--line); border-radius:8px; padding:4px 9px; font-size:13px; font-weight:600; }}
  .chip .cl {{ display:inline; font-size:10px; margin-right:5px; font-weight:600; }}

  .statcols {{ display:flex; justify-content:space-between; gap:14px; }}
  .col {{ display:flex; flex-direction:column; gap:7px; }}
  .col.right {{ align-items:flex-end; text-align:right; }}
  .stat {{ font-size:15px; }}
  .stat .cl {{ display:inline; font-size:10.5px; margin-right:6px; letter-spacing:.5px; }}
  .stat .v {{ font-weight:700; }}
  .capstat {{ background:#e9f1fc; border:1px solid #b6d2f0; border-radius:8px; padding:4px 10px; color:#0a5fb4; font-size:15px; }}
  .capstat .cl {{ display:inline; color:#0a5fb4; opacity:.85; margin-right:6px; font-size:10px; letter-spacing:.5px; }}
  .capstat .v {{ font-weight:800; }}

  .flags {{ margin:0; padding-left:18px; font-size:12.5px; color:var(--hold); }}
  .flags li {{ margin:2px 0; }}
  .valn summary {{ cursor:pointer; font-size:12px; color:var(--soft); }}
  .valn .chips {{ margin-top:8px; }}
  .paid {{ border-radius:10px; padding:9px 11px; font-size:13px; }}
  .paid.ok {{ background:#e4f4ea; }} .paid.warn {{ background:#fbf0e3; }}
  .paid p {{ margin:5px 0 0; }}
  .bid {{ display:block; text-align:center; background:var(--ink); color:#fff; text-decoration:none;
    padding:11px; border-radius:10px; font-weight:700; font-size:14px; margin-top:auto;
    transition:background .15s ease; }}
  .bid:hover {{ background:#000; }}
  .bid.disabled {{ background:#c6cfd7; }}

  .card.slim .body {{ gap:8px; }}
  .reasons {{ margin:0; padding-left:18px; font-size:13px; color:var(--no); }}
  .card.held .reasons {{ color:var(--hold); }}
  .empty {{ color:var(--soft); }}
  footer {{ color:var(--soft); font-size:12px; text-align:center; padding:28px 0; }}

  /* Control bar: search, platform, sort, show only my bids. */
  .controls {{ position:sticky; top:0; z-index:4; background:#fff; border-bottom:1px solid var(--line);
    padding:11px 22px; display:flex; flex-wrap:wrap; gap:10px; align-items:center;
    box-shadow:0 2px 8px rgba(20,30,40,.05); }}
  .controls input[type=search], .controls select {{ font-size:14px; padding:8px 10px; border:1px solid var(--line);
    border-radius:9px; background:#fff; }}
  .controls input[type=search] {{ min-width:220px; flex:1; }}
  .controls label {{ font-size:14px; font-weight:600; display:flex; align-items:center; gap:7px; cursor:pointer; }}
  .controls .runbtn {{ margin-left:auto; font-size:13px; font-weight:700; color:#fff; background:var(--g1);
    border:none; border-radius:9px; padding:9px 16px; cursor:pointer; white-space:nowrap; }}
  .controls .runbtn:hover {{ filter:brightness(.95); }}
  .controls .runbtn:disabled {{ background:var(--soft); cursor:default; filter:none; }}
  /* Run progress bar, a full width row inside the sticky control bar. */
  .controls .runbar {{ flex:0 0 100%; width:100%; margin-top:2px; }}
  .runbar-row {{ display:flex; justify-content:space-between; align-items:center; gap:10px;
    font-size:12.5px; margin-bottom:5px; }}
  .runmsg {{ font-weight:700; color:var(--ink); }}
  .runpct {{ color:var(--soft); white-space:nowrap; }}
  .runtrack {{ height:8px; background:#e4e9ef; border-radius:6px; overflow:hidden; }}
  .runfill {{ height:100%; width:0%; background:var(--g1); border-radius:6px; transition:width .4s ease; }}
  .runfill.indet {{ width:35%; animation:indet 1.1s ease-in-out infinite; }}
  .runfill.stale {{ background:var(--g3); }}
  .runfill.error {{ background:var(--no); width:100% !important; animation:none; }}
  @keyframes indet {{ 0% {{ margin-left:-35%; }} 100% {{ margin-left:100%; }} }}

  /* Seen before badge next to the plate. */
  .reghead {{ display:flex; align-items:center; gap:7px; white-space:nowrap; }}
  .repeat {{ background:#fde9c8; color:#9a5b00; font-size:10.5px; font-weight:800; text-transform:uppercase;
    letter-spacing:.4px; padding:2px 7px; border-radius:5px; }}

  /* Bid star on the photo, top right, sitting with the grade circle and platform badge. */
  .starbtn {{ position:absolute; top:10px; right:10px; width:40px; height:40px; border-radius:50%;
    border:2px solid #fff; background:rgba(19,32,43,.5); color:rgba(255,255,255,.92); font-size:20px; line-height:1;
    display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,.35);
    transition:transform .12s ease, background .12s ease, color .12s ease; }}
  .starbtn:hover {{ transform:scale(1.09); }}
  .starbtn.on {{ background:#ffcc33; border-color:#fff; color:#7a5a00; }}

  /* Hide: a quiet link under the bid button that opens a tidy reason panel. */
  .hide-row {{ text-align:center; margin-top:2px; }}
  .hidelink {{ background:none; border:none; color:var(--soft); font-size:12.5px; cursor:pointer; padding:4px; }}
  .hidelink:hover {{ color:var(--no); text-decoration:underline; }}
  .hidebox {{ display:flex; flex-direction:column; gap:8px; margin-top:4px; background:#fbf3f1;
    border:1px solid #ecd9d5; border-radius:10px; padding:10px; }}
  .hidebox[hidden] {{ display:none; }}
  .hidebox input {{ border:1px solid var(--line); border-radius:9px; padding:9px; font-size:14px; }}
  .hidebtns {{ display:flex; gap:8px; }}
  .hidebox .confirm {{ flex:1; background:var(--no); color:#fff; border:none; border-radius:9px; padding:9px 12px; font-weight:700; cursor:pointer; }}
  .hidebox .cancel {{ flex:1; background:#eef1f5; border:none; border-radius:9px; padding:9px 12px; cursor:pointer; }}
  .card.gone {{ display:none; }}

  #flash {{ position:fixed; left:50%; bottom:22px; transform:translateX(-50%); background:var(--no); color:#fff;
    padding:11px 16px; border-radius:10px; font-size:14px; box-shadow:0 4px 14px rgba(0,0,0,.25); display:none; z-index:20; }}
</style>
</head>
<body>
  <div class="topbar">
    <h1>BidBrain cockpit</h1>
    <div class="sub">{nice_date}. Star the cars you want to bid on, hide any you would not buy. Recommendations only, you place every bid yourself.</div>
  </div>
  <div class="controls">
    <input id="search" type="search" placeholder="Search reg, make or model" oninput="applyView()">
    <select id="platform" onchange="applyView()">
      <option value="">All platforms</option>
      <option value="motorway">Motorway</option>
      <option value="carwow">Carwow</option>
    </select>
    <select id="sort" onchange="applyView()">
      <option value="reserve">Cheapest reserve</option>
      <option value="headroom">Biggest headroom</option>
      <option value="platform">Platform</option>
    </select>
    <label><input id="onlybids" type="checkbox" onchange="applyView()"> View shortlist</label>
    <button id="runbtn" class="runbtn" onclick="runNow()">Run now</button>
    <div id="runbar" class="runbar" hidden>
      <div class="runbar-row"><span id="runmsg" class="runmsg"></span><span id="runpct" class="runpct"></span></div>
      <div class="runtrack"><div id="runfill" class="runfill"></div></div>
    </div>
  </div>
  <div class="wrap">
    <section>
      <div class="grid" id="grid">{shortlist_html}</div>
    </section>
    {held_section}
    {rejected_section}
  </div>
  <div id="flash"></div>
  <footer>BidBrain Phase 2. Read live from Motorway and Carwow. No platform APIs, no bids placed.</footer>
  <script>
    const SALE_DATE = "{sale_date}";
    function flash(msg){{ const f=document.getElementById('flash'); f.textContent=msg; f.style.display='block';
      clearTimeout(f._t); f._t=setTimeout(()=>f.style.display='none', 6000); }}
    async function post(url, body){{
      const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/json'}}, body:JSON.stringify(body)}});
      if(!r.ok) throw new Error('save failed');
      return r.json();
    }}
    function cardData(el){{ const c=el.closest('.card'); return {{
      reg:c.dataset.reg, make:c.dataset.make, model:c.dataset.model, name:c.dataset.name,
      max_bid: c.dataset.maxbid?+c.dataset.maxbid:null, reserve: c.dataset.reserve?+c.dataset.reserve:null }}; }}
    async function toggleBid(btn){{
      const on = btn.getAttribute('aria-pressed') !== 'true';
      try {{
        await post('/api/bid', {{...cardData(btn), sale_date:SALE_DATE, on}});
        btn.setAttribute('aria-pressed', on); btn.classList.toggle('on', on);
        btn.title = on ? 'On your shortlist' : 'Add to my shortlist';
        applyView();
      }} catch(e){{ flash('Could not save. Open the page at its web address, not from a file.'); }}
    }}
    function openHide(btn){{ btn.closest('.card').querySelector('.hidebox').hidden = false; }}
    function cancelHide(btn){{ btn.closest('.hidebox').hidden = true; }}
    async function confirmHide(btn){{
      const reason = btn.closest('.hidebox').querySelector('.hidereason').value.trim();
      try {{
        await post('/api/hide', {{...cardData(btn), reason}});
        btn.closest('.card').classList.add('gone'); applyView();
      }} catch(e){{ flash('Could not save. Open the page at its web address, not from a file.'); }}
    }}
    function applyView(){{
      const q = (document.getElementById('search').value||'').toLowerCase().trim();
      const plat = document.getElementById('platform').value;
      const onlybids = document.getElementById('onlybids').checked;
      const sort = document.getElementById('sort').value;
      const grid = document.getElementById('grid');
      const cards = [...grid.querySelectorAll('.card')];
      let shown = 0;
      cards.forEach(c => {{
        const hideGone = c.classList.contains('gone');
        const okq = !q || (c.dataset.search||'').includes(q);
        const okp = !plat || c.dataset.platform===plat;
        const okb = !onlybids || c.querySelector('.starbtn.on');
        const vis = !hideGone && okq && okp && okb;
        c.style.display = vis ? '' : 'none';
        if(vis) shown++;
      }});
      const key = {{reserve:'reserve', headroom:'headroom'}}[sort];
      let vis = cards.filter(c => c.style.display !== 'none');
      if(sort==='platform') vis.sort((a,b)=>(a.dataset.platform).localeCompare(b.dataset.platform));
      else vis.sort((a,b)=>{{
        const av=+a.dataset[key]||0, bv=+b.dataset[key]||0;
        return sort==='headroom' ? bv-av : av-bv;  // headroom biggest first, reserve cheapest first
      }});
      vis.forEach(c => grid.appendChild(c));
    }}
    applyView();

    // Run now: starts a full daily run in the background, like the 5.10pm job,
    // then polls and shows a progress bar until it finishes, then reloads.
    const ACTIVE_PHASES = ['reading','valuing','photos','writing'];
    function setRunning(on){{
      const b = document.getElementById('runbtn');
      if(!b) return;
      b.disabled = on; b.textContent = on ? 'Running...' : 'Run now';
    }}
    function renderProgress(s){{
      const bar = document.getElementById('runbar');
      if(!bar) return false;
      const p = s.progress;
      const active = s.running || (p && ACTIVE_PHASES.includes(p.phase));
      if(p && p.phase === 'error'){{
        bar.hidden = false;
        document.getElementById('runmsg').textContent = p.message || 'Run failed';
        document.getElementById('runpct').textContent = '';
        const f = document.getElementById('runfill'); f.className = 'runfill error';
        return false;  // stop polling on error, leave the message up
      }}
      if(!p || !active){{ bar.hidden = true; return active; }}
      bar.hidden = false;
      const msg = document.getElementById('runmsg');
      const pct = document.getElementById('runpct');
      const fill = document.getElementById('runfill');
      msg.textContent = p.message || (p.phase + '...');
      fill.className = 'runfill';
      if(p.total > 0){{
        fill.style.width = Math.round(100 * p.done / p.total) + '%';
        pct.textContent = p.done + ' / ' + p.total;
      }} else {{
        fill.style.width = ''; fill.classList.add('indet'); pct.textContent = '';
      }}
      // Stuck check: compare against the server clock so a phone clock cannot fool it.
      const age = (s.now || 0) - (p.ts || 0);
      if(age > 90){{
        fill.classList.add('stale');
        pct.textContent = (pct.textContent ? pct.textContent + '  ' : '') + 'no update for ' + Math.round(age) + 's';
      }}
      return active;
    }}
    let _pollTimer = null;
    async function pollRun(){{
      try {{
        const s = await (await fetch('/api/run-status')).json();
        const active = renderProgress(s);
        setRunning(active);
        const p = s.progress;
        if(p && p.phase === 'error') return;  // stop, error shown
        if(active){{ _pollTimer = setTimeout(pollRun, 3000); }}
        else if(p && p.phase === 'done'){{ flash('Run finished. Refreshing the list...'); setTimeout(()=>location.reload(), 1200); }}
      }} catch(e){{ _pollTimer = setTimeout(pollRun, 5000); }}
    }}
    async function runNow(){{
      if(!confirm('Run BidBrain now? This reads Motorway and Carwow and revalues, it takes a few minutes.')) return;
      try {{
        const r = await post('/api/run', {{}});
        if(r.started) flash('Run started. Watch the progress bar, the page refreshes when it is ready.');
        else flash('A run is already in progress. The page will refresh when it finishes.');
        setRunning(true); pollRun();
      }} catch(e){{ flash('Could not start the run. Open the page at its web address, not from a file.'); }}
    }}
    // If a run is already going (or just finished) when the page loads, reflect it.
    fetch('/api/run-status').then(r=>r.json()).then(s=>{{ if(renderProgress(s)){{ setRunning(true); pollRun(); }} }}).catch(()=>{{}});
  </script>
</body>
</html>
"""
