#!/usr/bin/env python3
"""Winnow Photos — the native sort view generator.

Takes a real folder, runs the photo pipeline (winnow_photo.sort_folder: shared-engine
near-duplicate clustering + on-device quality scoring), and emits a self-contained
HTML sorter that matches the live web prototype (lab.belstone.com/winnowphotos/app):
a cluster filmstrip + triage grid with score badges + a 2-up "Selected vs Winnow's
pick" compare + keyboard sort (K keep / X reject / arrows pick / Enter next / Z undo)
in the ivory/gold + Noir skin. Unlike the prototype's mock data, every cluster, score,
and thumbnail here is REAL and on-device.

The Swift WKWebView shell loads the emitted file:// page exactly like the doc grid.

CLI:  python winnow_view.py --dir FOLDER [--out OUT.html] [--eyes]
"""
import os, sys, json, html, datetime

_HERE = os.path.dirname(os.path.abspath(__file__))
_SRC = os.path.dirname(_HERE)
for _p in (_HERE, _SRC):
    if _p not in sys.path:
        sys.path.insert(0, _p)

import winnow_photo as wp
try:
    import triage_gen as tg
    _furl = tg.furl
    _BASE = tg.BASE_DIR
except Exception:                       # standalone fallback
    import urllib.parse
    def _furl(p):
        return "file://" + urllib.parse.quote(os.path.abspath(p))
    _BASE = "/tmp"


def _thumb_url(it):
    """A file:// URL the WKWebView can render: the engine's cached thumbnail if we
    have one, else the original file."""
    t = it.get("thumb")
    if t:
        local = tg._local(t) if 'tg' in globals() else None
        if local and os.path.exists(local):
            return _furl(local)
        if t.startswith("file:"):
            return t
    return _furl(it["path"])


def _resolve_brand_name():
    """Same resolution order as winnow_collect_view._default_dest: the bundled
    brand.json is authoritative when present (multi-brand builds), else "Winnow
    Photos" for standalone/legacy CLI use."""
    try:
        import brand as _b
        return _b.name()
    except Exception:
        return "Winnow Photos"


def build_sort(folder, out_path=None, task=None, with_eyes=False, progress=None,
               brand_name=None):
    """Run the pipeline over `folder` and write the sorter HTML. Returns the
    {out, kb, note} dict triage_app.run expects."""
    if brand_name is None:
        brand_name = _resolve_brand_name()
    man = wp.sort_folder(folder, with_eyes=with_eyes, progress=progress)
    photos = man["photos"]
    by_name = {p["name"]: p for p in photos}

    # Build the sort queue: one entry per multi-member burst, keeper first-class.
    clusters = []
    for c in man["clusters"]:
        members = [by_name[n] for n in c["members"] if n in by_name]
        # keeper first in display order, then by quality desc
        members.sort(key=lambda m: (not m.get("best"), -(m.get("overall") or 0)))
        frames = []
        for m in members:
            frames.append({
                "name": m["name"],
                "url": _thumb_url(m),
                "sharp": m.get("sharp"), "exp": m.get("exp"),
                "eyes": m.get("eyes"), "overall": m.get("overall"),
                "best": bool(m.get("best")), "blurry": bool(m.get("blurry")),
                "dims": m.get("dims", ""),
            })
        sel = next((i for i, f in enumerate(frames) if f["best"]), 0)
        clusters.append({"id": c["id"], "kind": c["kind"], "label": _KIND_LABEL.get(c["kind"], "Near-duplicates"),
                         "frames": frames, "sel": sel})

    singles = man["stats"]["photos"] - sum(len(c["frames"]) for c in clusters)
    payload = {
        "brand": brand_name,
        "stats": man["stats"],
        "singles": max(0, singles),
        "eyes_backend": man.get("eyes_backend"),
        "clusters": clusters,
        "folder": os.path.basename(folder.rstrip("/")) or folder,
        "folder_path": os.path.abspath(folder),
    }

    if not out_path:
        now = datetime.datetime.now()
        stamp = now.strftime("%Y-%m-%d at %H.%M") + now.strftime("%p").lower()
        os.makedirs(_BASE, exist_ok=True)
        out_path = os.path.join(_BASE, "%s %s (sort).html" % (stamp, task or payload["folder"]))

    htmltext = _HTML.replace("/*__DATA__*/null", json.dumps(payload))
    with open(out_path, "w") as f:
        f.write(htmltext)
    kb = os.path.getsize(out_path) / 1024.0
    note = "%d clusters · %d duplicate frames · %d blurry" % (
        man["stats"]["clusters"], man["stats"]["duplicate_frames"], man["stats"]["blurry"])
    return {"out": out_path, "kb": kb, "note": note}


_KIND_LABEL = {"dupe": "Near-duplicates", "blur": "Soft / motion blur",
               "blink": "Eyes closed in some", "pair": "RAW + JPEG (same shot)"}


# ---------------------------------------------------------------- the page
_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Sort</title>
<style>
  :root{
    --ivory:#FBF7EE; --ivory-2:#F4EEDF; --paper:#FFFDF8;
    --gold:#C8A872; --gold-deep:#B8860B; --ink:#2B2722; --ink-2:#6B6457;
    --line:#E6DEC9; --ring:#D8CBA8;
    --good:#5C8A5A; --warn:#C9A227; --bad:#C06A4D;
    --shadow:0 3px 8px rgba(39,42,45,.08), 0 1px 24px rgba(39,42,45,.06);
    --shadow-hi:0 7px 18px rgba(39,42,45,.14), 0 2px 8px rgba(39,42,45,.10);
    --serif:"Cambria","Georgia","Times New Roman",serif;
    --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; --r:12px;
  }
  [data-skin="noir"]{
    --ivory:#16140F; --ivory-2:#1E1B15; --paper:#211D16; --gold:#C8A872; --gold-deep:#D9B873;
    --ink:#F2EBDA; --ink-2:#B6AC95; --line:#322C20; --ring:#3C3424;
    --shadow:0 3px 14px rgba(0,0,0,.45); --shadow-hi:0 8px 26px rgba(0,0,0,.55);
  }
  *{box-sizing:border-box} html,body{margin:0;height:100%}
  body{background:var(--ivory);color:var(--ink);font-family:var(--sans);font-size:14px;
    line-height:1.45;-webkit-font-smoothing:antialiased;overflow:hidden}
  .bar{height:54px;display:flex;align-items:center;gap:14px;padding:0 18px;background:var(--paper);
    border-bottom:1px solid var(--line);position:relative;z-index:5}
  .mark{width:26px;height:26px;flex:none}
  .mark circle{fill:none;stroke:var(--gold);stroke-width:1.4}
  .wordmark{font-family:var(--serif);font-size:19px;letter-spacing:.2px}
  .wordmark b{font-weight:600}
  .by{font-size:11px;color:var(--ink-2);letter-spacing:.3px;margin-top:1px}
  .grow{flex:1}
  .chip{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--ink-2);
    border:1px solid var(--line);border-radius:999px;padding:5px 11px;background:var(--ivory)}
  .chip svg{width:12px;height:12px;opacity:.8}.lock{color:var(--gold-deep)}
  .toggle{cursor:pointer;border:1px solid var(--line);background:var(--ivory);color:var(--ink-2);
    border-radius:999px;padding:5px 11px;font-size:11.5px}
  .toggle:hover{color:var(--ink);border-color:var(--ring)}
  .seg{display:inline-flex;border:1px solid var(--line);border-radius:999px;overflow:hidden;background:var(--ivory)}
  .seg button{border:0;background:transparent;color:var(--ink-2);font:inherit;font-size:12px;padding:6px 14px;cursor:pointer}
  .seg button.on{background:var(--ink);color:var(--paper);font-weight:600}
  .stage{display:grid;grid-template-columns:248px 1fr 332px;height:calc(100% - 54px)}
  .rail{background:var(--paper);border-right:1px solid var(--line);padding:16px;overflow:auto}
  .center{display:flex;flex-direction:column;min-width:0}
  .detail{background:var(--paper);border-left:1px solid var(--line);padding:16px;overflow:auto}
  .h{font-family:var(--serif);font-size:13px;letter-spacing:.4px;text-transform:uppercase;color:var(--ink-2);margin:0 0 9px}
  .card{background:var(--ivory);border:1px solid var(--line);border-radius:var(--r);padding:13px 14px;margin-bottom:14px}
  .stat{display:flex;justify-content:space-between;align-items:baseline;padding:5px 0}
  .stat + .stat{border-top:1px dashed var(--line)}
  .stat .n{font-family:var(--serif);font-size:18px;font-weight:600}
  .stat small{color:var(--ink-2)}
  .filter{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;cursor:pointer;color:var(--ink-2);font-size:13px}
  .filter:hover{background:var(--ivory-2)} .filter.on{background:var(--ivory-2);color:var(--ink);font-weight:500}
  .filter .dot{width:8px;height:8px;border-radius:50%;background:var(--ring)} .filter.on .dot{background:var(--gold)}
  .filter .c{margin-left:auto;font-variant-numeric:tabular-nums;color:var(--ink-2);font-size:12px}
  .hud{font-size:12px}.hud .row{display:flex;align-items:center;gap:8px;padding:4px 0;color:var(--ink-2)}
  kbd{font-family:var(--sans);font-size:11px;min-width:20px;text-align:center;border:1px solid var(--ring);
    border-bottom-width:2px;border-radius:6px;padding:2px 6px;background:var(--paper);color:var(--ink)}
  .strip{display:flex;gap:8px;padding:12px 16px;overflow-x:auto;border-bottom:1px solid var(--line);background:var(--ivory)}
  .cl{flex:none;width:54px;height:40px;border-radius:8px;border:2px solid transparent;background-size:cover;
    background-position:center;cursor:pointer;position:relative;opacity:.62;transition:.15s}
  .cl:hover{opacity:.9}.cl.active{opacity:1;border-color:var(--gold);box-shadow:var(--shadow)}
  .cl.done::after{content:"\2713";position:absolute;right:2px;top:1px;font-size:10px;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.6)}
  .cl .qn{position:absolute;left:3px;bottom:1px;font-size:9px;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.7)}
  .gridwrap{flex:1;overflow:auto;padding:18px}
  .cltitle{display:flex;align-items:center;gap:10px;margin:0 0 14px}
  .cltitle h2{font-family:var(--serif);font-weight:500;font-size:19px;margin:0}
  .tag{font-size:11px;color:var(--ink-2);border:1px solid var(--line);border-radius:999px;padding:2px 9px}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:16px}
  .frame{position:relative;border-radius:var(--r);overflow:hidden;border:2px solid transparent;background:var(--ivory-2);
    aspect-ratio:4/3;cursor:pointer;box-shadow:var(--shadow);transition:.14s}
  .frame img{width:100%;height:100%;object-fit:cover;display:block}
  .frame:hover{transform:translateY(-2px);box-shadow:var(--shadow-hi)}
  .frame.sel{border-color:var(--gold)}.frame.best .beststar{display:flex}
  .frame.kept{border-color:var(--good)}
  .frame.rej img{filter:grayscale(1) brightness(.8);opacity:.5}
  .frame.rej::before{content:"";position:absolute;inset:0;background:linear-gradient(135deg,transparent 47%,
    var(--bad) 47%,var(--bad) 53%,transparent 53%);opacity:.5}
  .beststar{display:none;position:absolute;left:8px;top:8px;align-items:center;gap:4px;background:rgba(200,168,114,.95);
    color:#231f18;font-size:10.5px;font-weight:600;padding:3px 7px;border-radius:999px;letter-spacing:.2px}
  .badge{position:absolute;right:8px;bottom:8px;display:flex;gap:5px}
  .pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;color:#fff;backdrop-filter:blur(4px);font-variant-numeric:tabular-nums}
  .pill.s-good{background:rgba(92,138,90,.92)}.pill.s-warn{background:rgba(201,162,39,.92)}
  .pill.s-bad{background:rgba(192,106,77,.94)}.pill.blink{background:rgba(70,70,80,.85)}
  .verdict{position:absolute;left:8px;bottom:8px;font-size:10.5px;font-weight:600;padding:2px 8px;border-radius:999px;color:#fff}
  .verdict.kept{background:rgba(92,138,90,.95)}.verdict.rej{background:rgba(192,106,77,.95)}
  .status{display:flex;align-items:center;gap:14px;padding:11px 18px;border-top:1px solid var(--line);background:var(--paper);font-size:12.5px;color:var(--ink-2)}
  .progress{flex:1;height:7px;border-radius:999px;background:var(--ivory-2);overflow:hidden}
  .progress i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--gold),var(--gold-deep));transition:width .4s ease}
  .preview{border-radius:var(--r);overflow:hidden;border:1px solid var(--line);aspect-ratio:4/3;background:var(--ivory-2);position:relative;margin-bottom:6px}
  .preview img{width:100%;height:100%;object-fit:cover;display:block}
  .vs{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px}
  .vs figure{margin:0}.vs .preview{aspect-ratio:1/1;margin:0}
  .vs figcaption{font-size:10.5px;color:var(--ink-2);text-align:center;margin-top:5px;letter-spacing:.2px}
  .scores{margin:0 0 16px}
  .score{display:flex;align-items:center;gap:10px;padding:8px 0}.score + .score{border-top:1px dashed var(--line)}
  .score .lab{width:84px;color:var(--ink-2);font-size:12.5px}
  .meter{flex:1;height:7px;border-radius:999px;background:var(--ivory-2);overflow:hidden}
  .meter i{display:block;height:100%;border-radius:999px}
  .score .val{width:40px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600;font-size:13px}
  .acts{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
  .btn{border:1px solid var(--line);background:var(--ivory);color:var(--ink);border-radius:10px;padding:11px;font-size:13px;
    font-weight:600;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px;transition:.12s}
  .btn:hover{box-shadow:var(--shadow)}.btn .k{font-size:10px;color:var(--ink-2);font-weight:500}
  .btn.keep{border-color:var(--good)}.btn.keep:hover{background:rgba(92,138,90,.10)}.btn.rej:hover{background:rgba(192,106,77,.10)}
  .nextline{font-size:12px;color:var(--ink-2);text-align:center}.nextline b{color:var(--ink)}
  .review{position:absolute;inset:54px 0 0;background:var(--ivory);z-index:8;overflow:auto;padding:36px 48px;display:none}
  .review.show{display:block}
  .review h1{font-family:var(--serif);font-weight:500;font-size:30px;margin:0 0 6px}
  .review p.sub{color:var(--ink-2);margin:0 0 26px;max-width:560px}
  .revstats{display:flex;gap:30px;margin-bottom:28px;flex-wrap:wrap}
  .revstats .b{font-family:var(--serif);font-size:34px;font-weight:600;line-height:1}
  .revstats .b small{display:block;font-family:var(--sans);font-size:12px;font-weight:400;color:var(--ink-2);margin-top:5px}
  .revgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px;margin-bottom:30px}
  .revgrid img{width:100%;aspect-ratio:1/1;object-fit:cover;border-radius:9px}
  .revgrid img.r{filter:grayscale(1) brightness(.82);opacity:.45}
  .primary{background:linear-gradient(180deg,var(--gold),var(--gold-deep));color:#231f18;border:none;border-radius:11px;
    padding:13px 22px;font-size:14px;font-weight:600;cursor:pointer;box-shadow:var(--shadow)}
  .ghost{background:transparent;border:1px solid var(--line);color:var(--ink-2);border-radius:11px;padding:13px 18px;font-size:14px;cursor:pointer;margin-left:10px}
  .empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--ink-2);font-family:var(--serif);font-size:18px;text-align:center;padding:40px}
</style>
</head>
<body data-skin="ivory">
  <div class="bar">
    <svg class="mark" viewBox="0 0 40 40"><circle cx="20" cy="20" r="6"/><circle cx="20" cy="20" r="12"/><circle cx="20" cy="20" r="18"/></svg>
    <div><div class="wordmark" id="wordmark"></div><div class="by">by Epiphani Studios</div></div>
    <span class="chip"><svg class="lock" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2h-1V6a5 5 0 0 0-5-5zm-3 8V6a3 3 0 1 1 6 0v3H9z"/></svg>100% on-device</span>
    <div class="grow"></div>
    <span class="chip" id="libname">Library</span>
    <div class="seg" role="tablist">
      <button id="seg-sort" class="on">Sort</button><button id="seg-collect">Collect</button>
    </div>
    <button class="toggle" id="skin">Noir</button>
  </div>
  <div class="stage">
    <aside class="rail">
      <div class="card">
        <p class="h">Library</p>
        <div class="stat"><span>Photos</span><span class="n" id="s-photos">0</span></div>
        <div class="stat"><span><small>Duplicate frames</small></span><span class="n" id="s-dupes">0</span></div>
        <div class="stat"><span><small>Blurry</small></span><span class="n" id="s-blur">0</span></div>
        <div class="stat"><span><small>Unique (kept)</small></span><span class="n" id="s-single">0</span></div>
      </div>
      <div class="card" style="padding-bottom:6px">
        <p class="h">Filter clusters</p>
        <div class="filter on" data-f="all"><span class="dot"></span>All clusters<span class="c" id="f-all">0</span></div>
        <div class="filter" data-f="dupe"><span class="dot"></span>Duplicates<span class="c" id="f-dupe">0</span></div>
        <div class="filter" data-f="blur"><span class="dot"></span>Blurry<span class="c" id="f-blur">0</span></div>
        <div class="filter" data-f="blink"><span class="dot"></span>Eyes closed<span class="c" id="f-blink">0</span></div>
      </div>
      <div class="card hud">
        <p class="h">Keyboard</p>
        <div class="row"><kbd>K</kbd> keep selected, reject rest</div>
        <div class="row"><kbd>X</kbd> reject whole cluster</div>
        <div class="row"><kbd>&larr;</kbd><kbd>&rarr;</kbd> pick frame</div>
        <div class="row"><kbd>&crarr;</kbd> next cluster</div>
        <div class="row"><kbd>Z</kbd> undo</div>
      </div>
    </aside>
    <section class="center">
      <div class="strip" id="strip"></div>
      <div class="gridwrap">
        <div class="cltitle"><h2 id="cl-title">Cluster</h2><span class="tag" id="cl-kind"></span><span class="tag" id="cl-count"></span></div>
        <div class="grid" id="grid"></div>
        <div class="empty" id="empty" style="display:none">No near-duplicate clusters found: your library is already lean.</div>
      </div>
      <div class="status">
        <span><b id="st-done">0</b> of <span id="st-total">0</span> clusters sorted</span>
        <span class="progress"><i id="st-bar"></i></span><span id="st-eta"></span>
      </div>
    </section>
    <aside class="detail">
      <p class="h">Cluster detail</p>
      <div class="vs">
        <figure><div class="preview"><img id="pv-sel" alt=""></div><figcaption id="cap-sel">Selected</figcaption></figure>
        <figure><div class="preview"><img id="pv-best" alt=""></div><figcaption id="cap-best"></figcaption></figure>
      </div>
      <div class="scores card">
        <div class="score"><span class="lab">Sharpness</span><span class="meter"><i id="m-sharp"></i></span><span class="val" id="v-sharp">0</span></div>
        <div class="score"><span class="lab">Exposure</span><span class="meter"><i id="m-exp"></i></span><span class="val" id="v-exp">0</span></div>
        <div class="score"><span class="lab">Eyes open</span><span class="meter"><i id="m-eyes"></i></span><span class="val" id="v-eyes">--</span></div>
      </div>
      <div class="acts">
        <button class="btn keep" id="b-keep">Keep<span class="k">K</span></button>
        <button class="btn rej" id="b-rej">Reject all<span class="k">X</span></button>
      </div>
      <p class="nextline" id="nextline"></p>
    </aside>
  </div>
  <div class="review" id="review">
    <h1 id="rv-title">You narrowed it down.</h1>
    <p class="sub" id="rv-sub">Here is what got kept and what got set aside. Nothing left your device, and nothing is deleted until you say so.</p>
    <div class="revstats">
      <div class="b" id="rv-kept">0<small>Keepers</small></div>
      <div class="b" id="rv-rej">0<small>Set aside</small></div>
      <div class="b" id="rv-space">0<small>Approx. reclaimed</small></div>
      <div class="b" id="rv-time">0s<small>Time spent</small></div>
    </div>
    <p class="h">Keepers</p><div class="revgrid" id="rv-keepgrid"></div>
    <p class="h">Set aside</p><div class="revgrid" id="rv-rejgrid"></div>
    <div style="margin-top:18px">
      <button class="primary" id="rv-trash">Move set-aside out of this folder</button>
      <button class="ghost" id="rv-restart">Run again</button>
      <p class="nextline" id="rv-applynote" style="margin-top:12px;text-align:left">Set-aside photos move to a dated <b>Set Aside</b> folder here, and nothing is deleted, so you can always move them back.</p>
    </div>
  </div>
<script>
(function(){
  var DATA = /*__DATA__*/null;
  var $=function(s){return document.querySelector(s);};
  var CL = (DATA.clusters||[]).map(function(c){ c.done=false; return c; });
  var startedAt=Date.now(), cur=0, filter="all", undoStack=[];
  var grid=$("#grid"), strip=$("#strip");

  var BRAND_NAME = DATA.brand || "";
  var BRAND_FIRST = BRAND_NAME.split(" ")[0];
  (function(){
    var sp = BRAND_NAME.indexOf(" ");
    document.title = BRAND_NAME + ": Sort";
    $("#wordmark").innerHTML = sp === -1 ? BRAND_NAME
      : BRAND_NAME.slice(0, sp) + " <b>" + BRAND_NAME.slice(sp + 1) + "</b>";
    $("#cap-best").textContent = BRAND_FIRST + "'s pick";
  })();
  $("#libname").textContent = DATA.folder + " · " + (DATA.stats.photos||0) + " photos";
  $("#s-photos").textContent = DATA.stats.photos||0;
  $("#s-dupes").textContent = DATA.stats.duplicate_frames||0;
  $("#s-blur").textContent = DATA.stats.blurry||0;
  $("#s-single").textContent = DATA.singles||0;
  if(DATA.eyes_backend==null){ // no eyes detector -> hide the eyes meter row honestly
    var er=$("#m-eyes"); if(er){ er.closest(".score").style.display="none"; }
  }

  function sclass(v){return v>=72?"s-good":v>=50?"s-warn":"s-bad";}
  function visibleClusters(){return filter==="all"?CL:CL.filter(function(c){return c.kind===filter;});}

  function renderStrip(){
    strip.innerHTML="";
    visibleClusters().forEach(function(c){
      var d=document.createElement("div");
      d.className="cl"+(c.id===CL[cur]&&CL[cur].id===c.id?"":"")+(c.id===CL[cur].id?" active":"")+(c.done?" done":"");
      var best=c.frames[c.sel]||c.frames[0];
      d.style.backgroundImage="url('"+best.url+"')";
      d.title="Cluster "+c.id+" · "+c.label;
      var qn=document.createElement("span"); qn.className="qn"; qn.textContent=c.frames.length; d.appendChild(qn);
      d.onclick=function(){cur=CL.indexOf(c);renderAll();};
      strip.appendChild(d);
    });
    var act=strip.querySelector(".cl.active"); if(act) act.scrollIntoView({inline:"center",block:"nearest",behavior:"smooth"});
  }
  function renderGrid(){
    var c=CL[cur]; if(!c){ $("#empty").style.display="flex"; grid.innerHTML=""; return; }
    $("#empty").style.display="none";
    $("#cl-title").textContent="Cluster "+c.id;
    $("#cl-kind").textContent=c.label;
    $("#cl-count").textContent=c.frames.length+" frames";
    grid.innerHTML="";
    c.frames.forEach(function(fr,i){
      var el=document.createElement("div");
      el.className="frame"+(i===c.sel?" sel":"")+(fr.best?" best":"")+(fr.state==="kept"?" kept":"")+(fr.state==="rej"?" rej":"");
      var img=document.createElement("img"); img.src=fr.url; img.alt=fr.name; img.loading="lazy";
      img.onerror=function(){el.style.background="linear-gradient(135deg,#cdbf9c,#9c8e6a)";img.remove();};
      el.appendChild(img);
      var star=document.createElement("div"); star.className="beststar"; star.innerHTML="★ Best of "+c.frames.length; el.appendChild(star);
      if(fr.state){var v=document.createElement("div");v.className="verdict "+(fr.state==="kept"?"kept":"rej");v.textContent=fr.state==="kept"?"Kept":"Set aside";el.appendChild(v);}
      else{
        var b=document.createElement("div"); b.className="badge";
        var p=document.createElement("span"); p.className="pill "+sclass(fr.sharp||0); p.textContent=fr.sharp; b.appendChild(p);
        if(fr.eyes!=null && fr.eyes<40){var bl=document.createElement("span");bl.className="pill blink";bl.textContent="eyes";b.appendChild(bl);}
        el.appendChild(b);
      }
      el.onclick=function(){c.sel=i;renderGrid();renderDetail();};
      grid.appendChild(el);
    });
  }
  function setMeter(id,val){var c=val>=72?"var(--good)":val>=50?"var(--warn)":"var(--bad)";$(id).style.width=(val||0)+"%";$(id).style.background=c;}
  function renderDetail(){
    var c=CL[cur]; if(!c) return;
    var sel=c.frames[c.sel]||c.frames[0];
    var best=c.frames.find(function(f){return f.best;})||c.frames[0];
    $("#pv-sel").src=sel.url; $("#pv-best").src=best.url;
    $("#cap-sel").textContent=sel.best?"Selected · "+BRAND_FIRST+"'s pick":"Selected";
    $("#v-sharp").textContent=sel.sharp; setMeter("#m-sharp",sel.sharp);
    $("#v-exp").textContent=sel.exp; setMeter("#m-exp",sel.exp);
    if(sel.eyes!=null){$("#v-eyes").textContent=sel.eyes; setMeter("#m-eyes",sel.eyes);} else {$("#v-eyes").textContent="--";}
    var rest=c.frames.length-1;
    $("#nextline").innerHTML=sel.best
      ? "Press <b>K</b> to keep this frame and set aside the other "+rest+"."
      : "Not "+BRAND_FIRST+"'s pick. Press <b>K</b> to keep it anyway, or &larr; &rarr; to choose.";
  }
  function renderStatus(){
    var done=CL.filter(function(c){return c.done;}).length;
    $("#st-done").textContent=done; $("#st-total").textContent=CL.length;
    $("#st-bar").style.width=(CL.length?done/CL.length*100:0)+"%";
    var left=Math.max(0,CL.length-done);
    $("#st-eta").textContent=CL.length===0?"nothing to sort":(left===0?"done":("about "+(Math.ceil(left*9/60*10)/10)+" min left"));
    $("#f-all").textContent=CL.length;
    $("#f-dupe").textContent=CL.filter(function(c){return c.kind==="dupe";}).length;
    $("#f-blur").textContent=CL.filter(function(c){return c.kind==="blur";}).length;
    $("#f-blink").textContent=CL.filter(function(c){return c.kind==="blink";}).length;
  }
  function renderAll(){renderStrip();renderGrid();renderDetail();renderStatus();}

  function snapshot(c){return {idx:CL.indexOf(c),done:c.done,sel:c.sel,states:c.frames.map(function(f){return f.state;})};}
  function keep(){var c=CL[cur]; if(!c) return; if(c.done) return nextCluster();
    undoStack.push(snapshot(c)); c.frames.forEach(function(fr,i){fr.state=(i===c.sel)?"kept":"rej";});
    c.done=true; renderAll(); setTimeout(nextCluster,180);}
  function rejectAll(){var c=CL[cur]; if(!c) return; undoStack.push(snapshot(c));
    c.frames.forEach(function(fr){fr.state="rej";}); c.done=true; renderAll(); setTimeout(nextCluster,180);}
  function undo(){var s=undoStack.pop(); if(!s) return; var c=CL[s.idx]; c.done=s.done; c.sel=s.sel;
    c.frames.forEach(function(f,i){f.state=s.states[i];}); cur=s.idx; renderAll();}
  function nextCluster(){var vis=visibleClusters(); var next=vis.find(function(c){return !c.done;});
    if(next){cur=CL.indexOf(next);renderAll();}
    else if(CL.length && CL.every(function(c){return c.done;})) showReview();
    else {var any=CL.find(function(c){return !c.done;}); if(any){cur=CL.indexOf(any);renderAll();}}}
  function moveSel(d){var c=CL[cur]; if(!c) return; var n=c.frames.length; c.sel=(c.sel+d+n)%n; renderGrid(); renderDetail();}

  function showReview(){
    var kept=[],rej=[];
    CL.forEach(function(c){c.frames.forEach(function(f){(f.state==="kept"?kept:rej).push(f);});});
    $("#rv-kept").innerHTML=(kept.length+(DATA.singles||0))+'<small>Keepers</small>';
    $("#rv-rej").innerHTML=rej.length+'<small>Set aside</small>';
    $("#rv-space").innerHTML=(rej.length*3.4).toFixed(0)+' MB<small>Approx. reclaimed</small>';
    $("#rv-time").innerHTML=Math.round((Date.now()-startedAt)/1000)+'s<small>Time spent</small>';
    var kg=$("#rv-keepgrid"),rg=$("#rv-rejgrid");kg.innerHTML="";rg.innerHTML="";
    kept.slice(0,24).forEach(function(f){var im=new Image();im.src=f.url;kg.appendChild(im);});
    rej.slice(0,24).forEach(function(f){var im=new Image();im.src=f.url;im.className="r";rg.appendChild(im);});
    $("#review").classList.add("show");
  }

  $("#b-keep").onclick=keep; $("#b-rej").onclick=rejectAll;
  $("#rv-restart").onclick=function(){CL.forEach(function(c){c.done=false;c.frames.forEach(function(f){f.state=null;});});cur=0;undoStack=[];$("#review").classList.remove("show");renderAll();};
  // Apply the sort = MOVE set-aside photos to a dated Set Aside folder (safe, reversible).
  // Hands the rejected basenames to the native shell, which shells winnow_photo.py apply.
  function signal(u){window.__winnowLastSignal=u;location.href=u;}
  function rejectedNames(){var out=[];CL.forEach(function(c){c.frames.forEach(function(f){if(f.state==="rej")out.push(f.name);});});return out;}
  $("#rv-trash").onclick=function(){
    var names=rejectedNames();
    if(!names.length){$("#rv-applynote").textContent="Nothing was set aside.";return;}
    if(!DATA.folder_path || !(window.webkit||window.PagehawkNative)){ // browser preview (no native shell)
      $("#rv-trash").textContent="Move set-aside out of this folder ("+names.length+")";
      $("#rv-applynote").textContent="Preview only: in the app this moves "+names.length+" photos to a Set Aside folder.";return;}
    $("#rv-trash").textContent="Moving "+names.length+"…";$("#rv-trash").disabled=true;
    var b64=btoa(unescape(encodeURIComponent(JSON.stringify(names))));
    signal("hvniz://winnowapply?dir="+encodeURIComponent(DATA.folder_path)+"&rejb64="+encodeURIComponent(b64));
  };
  // Native shell calls this back with the move result.
  window.winnowApplied=function(res){
    if(!res||res.error){$("#rv-trash").disabled=false;$("#rv-trash").textContent="Move set-aside out of this folder";
      $("#rv-applynote").textContent="Could not move files"+(res&&res.error?": "+res.error:"")+".";return;}
    $("#rv-trash").textContent="Moved "+res.moved+" ✓";
    var where=res.dest&&/album/i.test(res.dest)?res.dest:"a <b>Set Aside</b> folder";
    $("#rv-applynote").innerHTML="Moved <b>"+res.moved+"</b> set-aside "+(res.moved===1?"photo":"photos")+" to "+where+". Nothing deleted, move them back anytime.";
  };
  document.querySelectorAll(".filter").forEach(function(el){el.onclick=function(){
    document.querySelectorAll(".filter").forEach(function(x){x.classList.remove("on");});
    el.classList.add("on");filter=el.dataset.f;var v=visibleClusters();if(v.length){cur=CL.indexOf(v[0]);}renderAll();};});
  $("#skin").onclick=function(){var b=document.body;var n=b.dataset.skin==="ivory"?"noir":"ivory";
    b.dataset.skin=n;$("#skin").textContent=n==="ivory"?"Noir":"Ivory";};
  // Sort ⇄ Collect: switch the same library to the additive Collections surface.
  // Fire via the hvniz:// navigation IPC like every other signal here. The old
  // `if(!(window.webkit||window.PagehawkNative))return` guard short-circuited in the real app
  // (the shell registers no WKScriptMessageHandler and sets no PagehawkNative), so Collect did nothing.
  $("#seg-collect").onclick=function(){ signal("hvniz://winnowmode?to=collect"); };
  $("#seg-sort").onclick=function(){};
  document.addEventListener("keydown",function(e){
    if($("#review").classList.contains("show")) return;
    var k=e.key.toLowerCase();
    if(k==="k"){e.preventDefault();keep();}
    else if(k==="x"){e.preventDefault();rejectAll();}
    else if(e.key==="ArrowRight"){e.preventDefault();moveSel(1);}
    else if(e.key==="ArrowLeft"){e.preventDefault();moveSel(-1);}
    else if(e.key==="Enter"){e.preventDefault();nextCluster();}
    else if(k==="z"){e.preventDefault();undo();}
  });

  if(!CL.length){ $("#empty").style.display="flex"; }
  renderAll();
})();
</script>
</body>
</html>
"""


if __name__ == "__main__":
    import argparse
    ap = argparse.ArgumentParser(description="Winnow Photos — sort view generator")
    ap.add_argument("--dir", required=True)
    ap.add_argument("--out")
    ap.add_argument("--eyes", action="store_true")
    a = ap.parse_args()
    if not os.path.isdir(a.dir):
        print("not a folder:", a.dir); sys.exit(1)
    res = build_sort(a.dir, out_path=a.out, with_eyes=a.eyes)
    print(json.dumps(res, indent=2))
