#!/usr/bin/env python3
"""PageHawk · standalone "point at a folder" engine.

This is the brain behind the drag-and-drop app and the Finder Quick Action.
Hand it one or more folders (or files) and it will:

  1. Recursively scan what's actually on disk (non-destructive; reads in place).
  2. Auto-detect the right mode (flip / single / doc) from the file mix.
  3. Enrich each file from any sidecar JSON it finds (preview_index.json etc.).
  4. Build the manifest and render the lean interactive HTML via the SAME
     build_tool() the Claude-driven CLI uses (identical output / cache / naming).
  5. Open the HTML and fire a macOS notification.

Jason never has to ask Claude to wire this up: he drops a folder and reviews.

Usage:
    triage_app.py [--mode auto|flip|single|doc] [--task NAME] [--no-open]
                  [--no-sidecar] PATH [PATH ...]

stdlib-only, python3, macOS. Lives next to triage_gen.py (one dir up).
"""
import os, sys, json, argparse, subprocess, glob, html, shutil

# triage_gen.py is one directory up; reuse its renderer + build_tool so the app
# and the Claude CLI produce byte-identical tools, cache, and Downloads behavior.
_HERE = os.path.dirname(os.path.abspath(__file__))
_SKILL = os.path.dirname(_HERE)
# Personal install: triage_gen.py is in the parent (skill root). Bundled-in-a-.app
# (the DMG build): it sits next to this file. Put both on the path so either works.
for _p in (_HERE, _SKILL):
    if _p not in sys.path:
        sys.path.insert(0, _p)
import triage_gen  # noqa: E402

# ---------------------------------------------------------------------------
# What counts as a viewable file, and how we bucket the mix for mode detection.
# ---------------------------------------------------------------------------
DOC_EXT = {".pdf", ".pptx", ".ppt", ".key", ".keynote", ".doc", ".docx",
           ".pages", ".xls", ".xlsx", ".numbers"}
IMG_EXT = {".jpg", ".jpeg", ".png", ".gif", ".heic", ".heif", ".tif", ".tiff",
           ".webp", ".bmp", ".raw", ".dng", ".cr2", ".nef", ".arw"}
VID_EXT = {".mp4", ".m4v", ".mov", ".avi", ".mkv", ".webm", ".mpg", ".mpeg",
           ".wmv", ".flv", ".3gp", ".m2ts", ".mts"}
VIEWABLE = DOC_EXT | IMG_EXT | VID_EXT

# Files we always skip when scanning a folder (sidecars, our own output, junk).
SKIP_NAMES = {".ds_store", "icon\r", "preview_index.json", "thumbs.db"}
SKIP_EXT = {".html", ".json", ".txt", ".md", ".py", ".pyc", ".log"}

# Sidecar filenames (a curated metadata index Claude may have written alongside
# the files). If present, we enrich matching files with its richer fields.
SIDECAR_NAMES = ("preview_index.json", "triage_index.json", "triage_manifest.json",
                 ".triage.json")


def _human_size(n):
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return (f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}")
        n /= 1024.0


def _fmt_feature(key, val):
    """Turn a sidecar scalar into a short, human-useful tag value."""
    money_keys = {"principal", "value", "eev", "amount", "price", "balance", "total"}
    if isinstance(val, bool):
        return "yes" if val else None  # only surface truthy flags
    if isinstance(val, (int, float)) and key.lower() in money_keys:
        return f"${val:,.0f}"
    if isinstance(val, float):
        return f"{val:,.2f}"
    if isinstance(val, int):
        return f"{val:,}"
    s = str(val).strip()
    if not s:
        return None
    return s if len(s) <= 80 else s[:77] + "..."


def _load_sidecars(roots):
    """Find sidecar JSON index files under the dropped roots; return path->entry."""
    by_path = {}
    seen = set()
    for root in roots:
        search_dirs = [root] if os.path.isdir(root) else [os.path.dirname(root)]
        for d in search_dirs:
            for name in SIDECAR_NAMES:
                cand = os.path.join(d, name)
                if cand in seen or not os.path.isfile(cand):
                    continue
                seen.add(cand)
                try:
                    data = json.load(open(cand))
                except Exception:
                    continue
                entries = data if isinstance(data, list) else data.get("items", [])
                for e in entries:
                    if isinstance(e, dict) and e.get("path"):
                        by_path[os.path.realpath(e["path"])] = e
    return by_path


def _scan_files(roots, max_depth=1, ignore_archive=True):
    """Recursively collect viewable files under each root, in stable order.

    Returns list of (abs_path, group_label). group = the immediate subfolder
    under the dropped root, or the root's own name for files sitting directly
    in it (so a folder-of-folders reads as one group per subfolder).

    max_depth: how many folder levels below the dropped root to descend. 1 (default)
      = the root's own files + ONE level of subfolders, so a big tree of nested
      subfolders never gets recursively trawled. None / negative = no limit.
    ignore_archive: skip the root's immediate subfolders whose name contains
      "archive" UNLESS they are the only subfolders present.
    """
    out = []
    multi = len([r for r in roots if os.path.isdir(os.path.abspath(r))]) > 1
    for root in roots:
        root = os.path.abspath(root)
        if os.path.isfile(root):
            if os.path.splitext(root)[1].lower() in VIEWABLE:
                out.append((root, os.path.basename(os.path.dirname(root))))
            continue
        if not os.path.isdir(root):
            continue
        root_name = os.path.basename(root.rstrip("/"))
        for dirpath, dirnames, filenames in os.walk(root):
            depth = (0 if os.path.abspath(dirpath) == root
                     else os.path.relpath(dirpath, root).count(os.sep) + 1)
            # prune hidden + cache dirs in place
            dirnames[:] = sorted(d for d in dirnames
                                 if not d.startswith(".") and d != ".cache")
            # at the root level, drop "archive"-named subfolders unless they're the
            # only ones there (so an Archive/ folder doesn't pull in stale items)
            if depth == 0 and ignore_archive and dirnames:
                keep = [d for d in dirnames if "archive" not in d.lower()]
                if keep:
                    dirnames[:] = keep
            # depth limit: collect files at this level but don't descend past max_depth
            if max_depth is not None and max_depth >= 0 and depth >= max_depth:
                dirnames[:] = []
            for fn in sorted(filenames):
                # Skip hidden files AND Office lock/owner stubs ("~$Book.xlsx",
                # "~$Memo.docx") that exist while a file is OPEN in Excel/Word.
                # They are not real documents and QuickLook HANGS on them, which
                # blocked the whole grid render (the data-room "won't load" bug,
                # 2026-06-29). LibreOffice ".~lock.*#" files start with "." already.
                if fn.startswith(".") or fn.startswith("~$"):
                    continue
                ext = os.path.splitext(fn)[1].lower()
                if ext in SKIP_EXT or fn.lower() in SKIP_NAMES or ext not in VIEWABLE:
                    continue
                fpath = os.path.join(dirpath, fn)
                rel = os.path.relpath(fpath, root)
                parts = rel.split(os.sep)
                if len(parts) > 1:
                    group = parts[0]
                else:
                    group = root_name
                if multi:
                    group = f"{root_name} / {group}" if len(parts) > 1 else root_name
                out.append((fpath, group))
    return out


def _looks_like_versions(paths):
    """Heuristic: a small flat set of files that are VERSIONS of one document
    (share a long common prefix, differ by a short tail / version token)."""
    if not (2 <= len(paths) <= 9):
        return False
    bases = [os.path.splitext(os.path.basename(p))[0] for p in paths]
    common = os.path.commonprefix(bases)
    # versions look like "<long shared name> v1 / v2 / (1) / final"
    return len(common) >= 8 and all(len(b) - len(common) <= 12 for b in bases)


def detect_mode(files, sidecar):
    """Choose single / doc / flip from the on-disk file mix. The fast thumbnail GRID
    (single) is the default for ANY pile — photos, videos, OR documents (one preview
    per file). Rendering every page of every document (flip) is 70x+ slower (an Excel-
    heavy folder took 49s vs 0.7s), so it is now OPT-IN via 'Open Folder As ->
    Browse a Set'. A clearly-versioned set still auto-routes to the doc picker."""
    if not files:
        return "single"
    groups = {g for _, g in files}
    if len(groups) == 1 and _looks_like_versions([p for p, _ in files]):
        return "doc"
    return "single"


def build_items(mode, files, sidecar):
    """Map (path, group) tuples + sidecar metadata into a mode-specific manifest."""
    items = []
    if mode == "doc":
        versions = []
        for p, _ in files:
            versions.append({"path": p})
        common = os.path.commonprefix(
            [os.path.splitext(os.path.basename(p))[0] for p, _ in files]).strip(" -_")
        items.append({"group": common or "Versions", "versions": versions})
        return items

    for p, group in files:
        meta = sidecar.get(os.path.realpath(p), {})
        feats = {}
        # sidecar enrichment (curated fields win as nice tags)
        for k, v in meta.items():
            if k in ("path", "guess", "folder"):
                continue
            fv = _fmt_feature(k, v)
            if fv is not None:
                feats[k] = fv
        # always-useful baseline tags
        try:
            feats.setdefault("size", _human_size(os.path.getsize(p)))
        except OSError:
            pass
        feats.setdefault("folder", group)
        item = {"path": p, "name": os.path.basename(p), "group": group,
                "features": feats}
        if meta.get("guess"):
            item["guess"] = meta["guess"]
        if mode == "single":
            item["folder"] = group   # surface the subfolder for grid folder-tags / group-by-folder
            item.pop("group", None)
            item.pop("name", None)
        items.append(item)
    return items


def _file_url(path):
    try:
        return triage_gen.furl(path)
    except Exception:
        import urllib.parse
        return "file://" + urllib.parse.quote(path)


def _loading_html(n, task):
    """A branded 'rendering…' page that reloads itself until it gets swapped for
    a redirect to the finished tool (file:// pages can't poll, so we self-reload)."""
    plural = "s" if n != 1 else ""
    _bn = triage_gen._brand_name()
    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>{_bn} · preparing…</title>
<style>
 html,body{{height:100%;margin:0}}
 body{{display:flex;align-items:center;justify-content:center;background:#F4EFE4;
   font-family:Cambria,'Big Caslon','Hoefler Text',Georgia,serif;color:#3A3F45}}
 .shell{{text-align:center;max-width:540px;padding:40px}}
 .apl{{width:88px;height:88px;position:relative;margin:0 auto 26px}}
 .apl span{{position:absolute;left:50%;top:50%;border-radius:50%;transform:translate(-50%,-50%)}}
 .apl .o{{width:88px;height:88px;border:5px solid rgba(120,96,48,.22);animation:apf 2.6s ease-in-out infinite}}
 .apl .m{{width:58px;height:58px;border:5px solid rgba(120,96,48,.4);animation:apf 2.6s ease-in-out infinite .2s}}
 .apl .i{{width:30px;height:30px;border:6px solid #C8A872;animation:apf 2.6s ease-in-out infinite .4s}}
 .apl .cr{{width:12px;height:12px;background:#C8A872;animation:apc 2.6s ease-in-out infinite}}
 @keyframes apf{{0%,100%{{opacity:.42;transform:translate(-50%,-50%) scale(1.12)}}50%{{opacity:1;transform:translate(-50%,-50%) scale(.9)}}}}
 @keyframes apc{{0%,100%{{box-shadow:0 0 0 0 rgba(200,168,114,0);opacity:.75}}50%{{box-shadow:0 0 14px 2px rgba(200,168,114,.55);opacity:1}}}}
 .brand{{font-size:31px;letter-spacing:.3px;color:#2A2D30;margin-bottom:6px}}
 .brand b{{color:#856000;font-weight:600}}
 .sub{{font-size:15px;color:#6E664F;margin-bottom:26px}}
 .count{{font-size:16px;color:#3A3F45}} .count b{{color:#856000}}
 .foot{{font-size:12.5px;color:#6E664F;margin-top:22px;line-height:1.6}}
 @media (prefers-color-scheme:dark){{body{{background:#1b1b1d;color:#C9C4BB}}.brand{{color:#F0EBE1}}.brand b{{color:#D8BC8C}}.sub{{color:#9A9690}}.count{{color:#C9C4BB}}.count b{{color:#D8BC8C}}.foot{{color:#8A857C}}.apl .o{{border-color:rgba(246,241,231,.26)}}.apl .m{{border-color:rgba(246,241,231,.5)}}}}
</style></head><body>
<div class="shell">
 <div class="apl"><span class="o"></span><span class="m"></span><span class="i"></span><span class="cr"></span></div>
 <div class="brand">{_bn}</div>
 <div class="sub" id="sub">Taking a gander…</div>
 <div class="count">Rendering <b>{n}</b> file{plural} from <b>{html.escape(str(task))}</b>…</div>
 <div class="foot">This opens your review automatically when it's ready.<br>Larger folders take a little longer the first time.</div>
</div>
<script>
 // Re-navigate to ourselves with a changing query so WKWebView (and browsers)
 // re-read the file fresh; when the worker swaps this file to a redirect, the
 // next pass picks it up and navigates to the finished tool.
 var M=["Taking a gander…","Gathering your files…","Looking everything over…","Lining up your review…","Bringing the best forward…"];document.getElementById('sub').textContent=M[Math.floor(Date.now()/1500)%M.length];
 setTimeout(function(){{var u=location.href.split('?')[0];location.replace(u+'?_='+Date.now());}},1400);
</script>
</body></html>"""


def _redirect_html(url):
    j = json.dumps(url)
    return (f'<!DOCTYPE html><html><head><meta charset="utf-8">'
            f'<meta http-equiv="refresh" content="0; url={html.escape(url, quote=True)}">'
            f'<title>Opening…</title></head><body>'
            f'<script>location.replace({j});</script>'
            f'<p style="font-family:Cambria,Georgia,serif;color:#3A3F45;padding:40px">Opening your review… '
            f'<a href="{html.escape(url, quote=True)}">click here if it does not open</a>.</p>'
            f'</body></html>')


def _error_html(msg):
    return (f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{triage_gen._brand_name()}</title>'
            f'<style>html,body{{height:100%;margin:0}}body{{font-family:Cambria,Georgia,serif;'
            f'background:#F4EFE4;color:#3A3F45;display:flex;align-items:center;'
            f'justify-content:center}}.c{{max-width:560px;padding:40px;text-align:center}}'
            f'.b{{font-size:28px;color:#2A2D30}}.b b{{color:#856000}}.e{{color:#9A3B2E;margin:18px 0 8px}}'
            f'.m{{font-size:13px;color:#6E664F}}@media (prefers-color-scheme:dark){{body{{background:#1b1b1d;color:#C9C4BB}}.b{{color:#F0EBE1}}.b b{{color:#D8BC8C}}.e{{color:#E0857A}}.m{{color:#9A9690}}}}</style></head><body><div class="c">'
            f'<div class="b">Epiphan<b>i</b></div>'
            f'<p class="e">Could not build the review.</p>'
            f'<p class="m">{html.escape(str(msg))}</p></div></body></html>')


# ---------------------------------------------------------------------------
# Preferences: ~/Library/Application Support/PageHawk/config.json
# Created with defaults on first run; edit it to change behavior.
# ---------------------------------------------------------------------------
_CONFIG_DEFAULTS = {
    "max_depth": 2,                 # folder levels to descend (Jason 2026-06-14: default 2)
    "ignore_archive_folders": True, # skip "archive"-named subfolders unless they're the only ones
    "output_copy_dir": "",          # also copy the finished tool here ("" = don't; archive always kept)
    "viewer": "app",                # "app" = PageHawk (native) | "browser" | "chrome-app"
}


def _config_path():
    return os.path.expanduser("~/Library/Application Support/PageHawk/config.json")


def _load_config():
    cfg = dict(_CONFIG_DEFAULTS)
    p = _config_path()
    try:
        if os.path.isfile(p):
            user = json.load(open(p))
            if isinstance(user, dict):
                for k in _CONFIG_DEFAULTS:
                    if k in user:
                        cfg[k] = user[k]
        else:
            os.makedirs(os.path.dirname(p), exist_ok=True)
            with open(p, "w") as f:
                json.dump(_CONFIG_DEFAULTS, f, indent=2)
    except Exception:
        pass
    return cfg


def _open_in_viewer(path, viewer):
    """Open a tool/loading page in the configured viewer, falling back gracefully.
    Uses `open -a` so LaunchServices finds the viewer wherever it's installed
    (~/Applications or /Applications); falls back to the default browser."""
    if viewer == "app":
        # open by bundle id so it's unambiguous (the unified PageHawk app)
        r = subprocess.run(["open", "-b", "com.hvn.visualize.app", path],
                           stderr=subprocess.DEVNULL)
        if r.returncode == 0:
            return
    if viewer == "chrome-app" and os.path.isdir("/Applications/Google Chrome.app"):
        subprocess.run(["open", "-na", "Google Chrome", "--args", f"--app={_file_url(path)}"])
        return
    subprocess.run(["open", path])  # default browser


# ---------------------------------------------------------------------------
# Pre-review CHOOSER: when a folder is ambiguous (multiple subfolders and/or file
# kinds), show a selection prompt instead of guessing. Categorize cheaply (no AI).
# ---------------------------------------------------------------------------
def _category(path):
    ext = os.path.splitext(path)[1].lower()
    name = os.path.basename(path).lower()
    if ext in IMG_EXT:
        return "Screenshots" if ("screenshot" in name or "screen shot" in name) else "Photos"
    if ext in VID_EXT:
        return "Videos"
    if ext == ".pdf":
        return "PDFs"
    if ext in {".pptx", ".ppt", ".key", ".keynote"}:
        return "Decks"
    if ext in {".doc", ".docx", ".pages"}:
        return "Documents"
    if ext in {".xls", ".xlsx", ".numbers"}:
        return "Spreadsheets"
    return "Other"


def _is_archive(group):
    return "archive" in group.lower()


_CAT_ORDER = ["Photos", "Videos", "Screenshots", "PDFs", "Decks", "Documents", "Spreadsheets", "Other"]
_CAT_ICON = {"Photos": "🌄", "Videos": "🎞", "Screenshots": "📱", "PDFs": "📄", "Decks": "🖥",
             "Documents": "📝", "Spreadsheets": "▦", "Other": "•"}


def _folder_prefs_path():
    return os.path.expanduser("~/Library/Application Support/PageHawk/folder_prefs.json")


def _load_folder_pref(root):
    try:
        d = json.load(open(_folder_prefs_path()))
        return d.get(os.path.realpath(root))
    except Exception:
        return None


def _save_folder_pref(root, folders, types, known=None):
    p = _folder_prefs_path()
    try:
        os.makedirs(os.path.dirname(p), exist_ok=True)
        d = json.load(open(p)) if os.path.isfile(p) else {}
    except Exception:
        d = {}
    # `known` = every subfolder that existed when this selection was made. On a later open,
    # any folder NOT in `known` is treated as newly added and included by default, so adding
    # a subfolder is picked up automatically instead of being silently filtered out.
    d[os.path.realpath(root)] = {"folders": folders, "types": types, "known": known}
    try:
        json.dump(d, open(p, "w"), indent=2)
    except Exception:
        pass


def clear_folder_pref(root):
    p = _folder_prefs_path()
    try:
        d = json.load(open(p))
        d.pop(os.path.realpath(root), None)
        json.dump(d, open(p, "w"), indent=2)
    except Exception:
        pass


def _selection_html(root, allfiles):
    """The chooser: pick which subfolders + file kinds to review. Posts the choice
    back to the app via a hvniz://review URL that the WKWebView intercepts."""
    from collections import Counter
    groups = {}
    cats = Counter()
    fc = {}                       # folder(group) x category cross-counts, for an accurate live total
    for p, g in allfiles:
        groups[g] = groups.get(g, 0) + 1
        c = _category(p)
        cats[c] += 1
        fc.setdefault(g, Counter())[c] += 1
    root_name = os.path.basename(root.rstrip("/")) or root
    src = html.escape(root_name)
    multi_root = any(" / " in g for g in groups)   # multi-root labels -> keep the flat list

    def folder_row(name, n, child=True, label=None, icon="📁", default_on=True):
        arch = _is_archive(name)
        checked = "checked" if (default_on and not arch) else ""
        dim = ' style="opacity:.5"' if arch else ""
        cls = "row child" if child else "row"
        disp = label if label is not None else html.escape(name)   # label may carry safe markup
        return (f'<label class="{cls}"{dim}><input type="checkbox" class="gf" value="{html.escape(name)}" {checked}>'
                f'<span class="ic">{icon}</span><span class="nm">{disp}</span>'
                f'<span class="ct">{n:,}</span></label>')

    # Hierarchical folder picker: the dropped folder is the PARENT (a master select-all); its loose
    # files and each immediate subfolder are indented children, so the tree is obvious (Jason 2026-06-18).
    if multi_root:
        g_rows = sorted(groups.items(), key=lambda kv: (_is_archive(kv[0]), kv[0].lower()))
        folders_inner = "".join(folder_row(n, c, child=False) for n, c in g_rows)
    else:
        subs = sorted(((n, c) for n, c in groups.items() if n != root_name),
                      key=lambda kv: (_is_archive(kv[0]), kv[0].lower()))
        loose_n = groups.get(root_name, 0)
        # Jason 2026-06-18: the workspace's own top-level files are the default ("only the main
        # workspace is checked"). The separate, confusing "Loose files" row is gone: this parent
        # row IS the top-level-files toggle (checked = assume yes). Subfolders are listed but OFF
        # by default, opt-in; the choice is remembered per workspace. "Review everything" still
        # includes every subfolder.
        parent = (f'<label class="row parent"><input type="checkbox" class="gf" value="{html.escape(root_name)}" checked>'
                  f'<span class="ic">📂</span><span class="nm">{src}'
                  f'<span class="hint">top-level files</span></span>'
                  f'<span class="ct">{loose_n:,}</span></label>')
        rows = [folder_row(n, c, default_on=True) for n, c in subs]
        folders_inner = parent + '<div class="children">' + "".join(rows) + '</div>'

    # File types sorted by count DESCENDING (the folder's dominant type first), and ONLY that single
    # largest type is checked by default: point at a mostly-PDF folder and you get PDFs, a mostly-photo
    # folder and you get photos. Adding #2 / #3 is one click (Jason 2026-06-18, supersedes the fixed
    # 4-type default). Ties broken by the canonical _CAT_ORDER so the order is stable.
    present_cats = sorted((c for c in cats if cats.get(c)),
                          key=lambda c: (-cats[c], _CAT_ORDER.index(c) if c in _CAT_ORDER else 99))
    top_type = present_cats[0] if present_cats else None
    def c_row(cat):
        n = cats[cat]
        on = True   # in "Choose…" every type starts on; deselect to narrow
        return (f'<label class="chip{" on" if on else ""}"><input type="checkbox" class="tf" value="{cat}" {"checked" if on else ""}>'
                f'<span class="cic">{_CAT_ICON.get(cat, "•")}</span><span class="cnm">{cat}</span>'
                f'<span class="cct">{n:,}</span></label>')

    groups_html = '<div class="tree">' + folders_inner + '</div>'
    show_groups = len(groups) > 1
    cats_html = "".join(c_row(c) for c in present_cats)
    show_cats = len(present_cats) > 1
    total = sum(cats.values())
    loose_n = groups.get(root_name, 0)   # top-level (loose) files = the "Top level" scope count

    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>{triage_gen._brand_name()} · open</title>
<style>
 :root{{--bg:#F4EFE4;--card:#fff;--line:#E2D7C1;--fg:#2A2D30;--mut:#6E664F;--accent:#C8A872;--accent-dark:#856000;--ink:#23262A}}
 html,body{{height:100%;margin:0}}
 body{{background:var(--bg);font-family:Cambria,'Big Caslon','Hoefler Text',Georgia,serif;color:var(--fg);display:flex;align-items:center;justify-content:center}}
 .card{{background:var(--card);border:1px solid var(--line);border-radius:16px;max-width:620px;width:92%;padding:26px 32px;box-shadow:0 24px 70px rgba(0,0,0,.12)}}
 .wm{{font-size:25px;font-weight:600;line-height:1}} .wm b{{color:var(--accent-dark)}}
 .src{{font-size:12.5px;color:var(--mut);margin:3px 0 16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
 .lead{{font-size:14px;color:var(--mut);margin:0 0 13px}} .lead b{{color:var(--fg)}}
 .seg{{display:grid;grid-template-columns:1fr 1fr 1fr;gap:9px}}
 .opt{{border:1.5px solid var(--line);border-radius:12px;padding:13px 12px;cursor:pointer;background:var(--card);transition:.15s;text-align:left;font-family:inherit;color:inherit}}
 .opt:hover{{border-color:#d8cbb0}} .opt.sel{{border-color:var(--accent);background:#FBF6EA}}
 .opt.disabled{{opacity:.4;cursor:default}}
 .opt .t{{font-weight:bold;font-size:14px}} .opt .d{{color:var(--mut);font-size:11.5px;margin-top:3px}} .opt .n{{color:var(--accent-dark);font-weight:bold;font-variant-numeric:tabular-nums}}
 .detail{{max-height:0;overflow:hidden;opacity:0;transition:max-height .3s ease,opacity .25s}} .detail.open{{max-height:1200px;opacity:1;margin-top:14px}}
 .lbl{{font-size:11.5px;font-weight:bold;color:var(--mut);letter-spacing:.4px;margin:14px 0 8px;text-transform:uppercase}}
 .row{{display:flex;align-items:center;gap:11px;padding:8px 11px;border-radius:9px;font-size:14.5px;cursor:pointer}}
 .row:hover{{background:#FAF6EC}} .row .nm{{flex:1}} .row .ct{{font-size:12px;color:var(--mut)}} .row .ic{{font-size:15px}}
 .tree .row.parent{{font-weight:bold}} .tree .children{{margin-left:13px;border-left:1.5px solid var(--line);padding-left:4px;margin-top:1px}}
 .tree .row.child .nm{{font-weight:normal}} .nm .hint{{font-size:11px;color:var(--mut);font-weight:normal;margin-left:6px}}
 .chips{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:9px}}
 .chip{{display:flex;align-items:center;gap:9px;padding:10px 12px;border:1.5px solid var(--line);border-radius:10px;font-size:14.5px;cursor:pointer}}
 .chip.on{{border-color:var(--accent);background:#FBF6EA}} .chip.absent{{opacity:.38}} .chip .cnm{{flex:1}} .chip .cct{{font-size:12px;color:var(--mut)}} .chip .cic{{font-size:16px}}
 .ft{{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:20px}}
 .rem{{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--mut);cursor:pointer}}
 .btns{{display:flex;gap:9px;align-items:center}}
 button.b{{font-family:inherit;font-size:13.5px;padding:9px 15px;border-radius:10px;cursor:pointer;border:1.5px solid var(--line);background:var(--card);color:#4A4034}}
 button.go{{border-color:var(--accent);background:var(--accent);color:var(--ink);font-weight:bold}}
 input[type=checkbox]{{width:16px;height:16px;accent-color:var(--accent-dark)}}
 @media (prefers-color-scheme:dark){{:root{{--bg:#1b1b1d;--card:#242019;--line:rgba(255,255,255,.14);--fg:#F0EBE1;--mut:#9A9690;--accent:#C8A872;--accent-dark:#D8BC8C;--ink:#1b1b1d}}.card{{box-shadow:0 24px 70px rgba(0,0,0,.55)}}.opt.sel,.chip.on{{background:rgba(200,168,114,.14)}}.row:hover{{background:rgba(255,255,255,.05)}}button.b{{background:#2A2620;color:#E8E2D6}}}}
</style></head><body>
<div class="card">
 <div class="wm">Page<b>Hawk</b></div>
 <div class="src">Open · {src}</div>
 <p class="lead" id="lead">Reviewing <b>{total:,} files</b></p>
 <div class="seg" id="seg">
   <button class="opt{'' if loose_n else ' disabled'}" data-scope="root"><div class="t">Top level</div><div class="d"><span class="n">{loose_n:,}</span> files, no subfolders</div></button>
   <button class="opt sel" data-scope="all"><div class="t">Everything</div><div class="d">all subfolders · <span class="n">{total:,}</span></div></button>
   <button class="opt" data-scope="custom"><div class="t">Choose…</div><div class="d">pick folders &amp; types</div></button>
 </div>
 <div class="detail" id="detail">
  {'<div class="lbl">Folders to include</div>' + groups_html if show_groups else ''}
  {'<div class="lbl">File types</div><div class="chips">' + cats_html + '</div>' if show_cats else ''}
 </div>
 <div class="ft">
  <label class="rem"><input type="checkbox" id="rem" checked> Remember for “{src}”</label>
  <div class="btns"><button class="b" onclick="reset_()">Reset</button><button class="b go" onclick="go()">Review <span id="cnt">{total:,}</span> →</button></div>
 </div>
</div>
<script>
 var SRC={json.dumps(root)}, ROOT={json.dumps(root_name)}, FC={json.dumps({g: dict(fc[g]) for g in fc})};
 var scope='all';
 function gfs(){{return [].slice.call(document.querySelectorAll('.gf'));}}
 function tfs(){{return [].slice.call(document.querySelectorAll('.tf'));}}
 function picked(a){{return a.filter(function(b){{return b.checked}}).map(function(b){{return b.value}});}}
 function typeTotals(folders){{var t={{}}; folders.forEach(function(f){{var r=FC[f]||{{}};for(var c in r)t[c]=(t[c]||0)+r[c];}}); return t;}}
 function activeFolders(){{ if(scope==='root') return [ROOT]; if(scope==='all') return Object.keys(FC); return picked(gfs()); }}
 function count(){{ var t=typeTotals(activeFolders()); var selT=(scope==='custom'&&tfs().length)?picked(tfs()):null; var n=0; for(var c in t){{ if(!selT||selT.indexOf(c)>=0) n+=t[c]; }} return n; }}
 function fmt(n){{return n.toLocaleString();}}
 function render(){{
   [].slice.call(document.querySelectorAll('.opt')).forEach(function(o){{o.classList.toggle('sel',o.dataset.scope===scope);}});
   document.getElementById('detail').classList.toggle('open',scope==='custom');
   var n=count();
   var cc=document.getElementById('cnt'); if(cc) cc.textContent=fmt(n);
   document.querySelector('.go').style.opacity=n?1:.45;
   document.getElementById('lead').innerHTML = n? ('Reviewing <b>'+fmt(n)+' files</b>') : 'Nothing selected';
   if(scope==='custom'){{ var tc=typeTotals(activeFolders()); tfs().forEach(function(b){{var chip=b.closest('.chip');var c=tc[b.value]||0;var el=chip.querySelector('.cct');if(el)el.textContent=fmt(c);chip.classList.toggle('absent',c===0);chip.classList.toggle('on',b.checked);}}); }}
 }}
 [].slice.call(document.querySelectorAll('.opt')).forEach(function(o){{o.addEventListener('click',function(){{ if(o.classList.contains('disabled'))return; scope=o.dataset.scope; render(); }});}});
 gfs().forEach(function(b){{b.addEventListener('change',render);}});
 tfs().forEach(function(b){{b.addEventListener('change',render);}});
 function reset_(){{ scope='all'; gfs().forEach(function(b){{b.checked=true;}}); tfs().forEach(function(b){{b.checked=true;}}); var r=document.getElementById('rem'); if(r)r.checked=true; render(); }}
 function go(){{
   if(count()===0) return;
   var folders = scope==='all' ? null : (scope==='root' ? [ROOT] : picked(gfs()));
   var types   = (scope==='all'||scope==='root'||!tfs().length) ? null : picked(tfs());
   var rem = (document.getElementById('rem')||{{}}).checked ? 1 : 0;
   location.href='hvniz://review?src='+encodeURIComponent(SRC)+'&folders='+encodeURIComponent(JSON.stringify(folders))+'&types='+encodeURIComponent(JSON.stringify(types))+'&remember='+rem;
 }}
 render();
</script></body></html>"""


def run(paths, mode="auto", task=None, do_open=True, use_sidecar=True, loading=False,
        config=None, loading_file=None, choose=False,
        include_folders=None, include_types=None, remember=False, fresh=False):
    cfg = config if config is not None else _load_config()
    roots = [os.path.abspath(os.path.expanduser(p)) for p in paths]
    roots = [r for r in roots if os.path.exists(r)]
    if not roots:
        raise SystemExit("PageHawk: no existing folder/file given.")

    # --fresh = "recache everything": wipe the render caches so every thumbnail and page
    # is regenerated from the files on disk right now. The cache is already content-keyed
    # (an edited file re-renders on its own), so this is the belt-and-suspenders refresh
    # for the rare case you want a guaranteed full rebuild (the in-review ↻ Refresh button).
    if fresh:
        for d in (".cache", "pagecache"):
            shutil.rmtree(os.path.join(triage_gen.BASE_DIR, d), ignore_errors=True)
    sidecar = _load_sidecars(roots) if use_sidecar else {}
    max_depth = cfg.get("max_depth", 2)

    # Saved per-folder selection? Apply it and skip the chooser.
    known_folders = None
    if choose and len(roots) == 1 and include_folders is None and include_types is None:
        saved = _load_folder_pref(roots[0])
        if saved:
            include_folders = saved.get("folders")
            include_types = saved.get("types")
            # folders present when the selection was saved; any folder added since counts as new.
            # Old prefs predate this field — fall back to the saved selection itself, so a genuinely
            # new subfolder still shows (it just can't have been deselected back then).
            known_folders = saved.get("known")
            if known_folders is None:
                known_folders = saved.get("folders") or []
            choose = False

    # The chooser needs the FULL structure (all folders + kinds, incl. Archive).
    allfiles = _scan_files(roots, max_depth=max_depth, ignore_archive=False)
    if not allfiles:
        raise SystemExit("PageHawk: no viewable files (PDF/deck/image) found.")

    # Ambiguous FOLDER + chooser requested + a window to draw into -> show the chooser.
    #
    # "Ambiguous" requires a folder in the first place. Jason, 2026-08-04, after dragging in six
    # loose files: "This is not necessary. It's only six files. There are no folders, so I
    # shouldn't be getting this folder page." He was right, and the chooser was even admitting it
    # — its own Top level option read "0 files, no subfolders" while still asking him to choose.
    #
    # The trigger was `len(kinds) > 1`: his six were PDF + Word + plain text, three categories, so
    # the kinds test fired even though the folders test could not. But the chooser answers two
    # questions — WHICH SUBFOLDERS and WHICH KINDS — and hand-dropped files have already answered
    # both. Dragging exactly these six documents IS the selection; asking which of them to include
    # is asking someone to repeat themselves.
    #
    # So the scope question is gated on a real folder being involved. Drop a folder and it still
    # appears (top level vs everything is genuinely ambiguous there); drop files and you go
    # straight to the review.
    dropped_a_folder = any(os.path.isdir(r) for r in roots)
    if choose and loading_file and dropped_a_folder:
        groups = {g for _, g in allfiles}
        kinds = {_category(p) for p, _ in allfiles}
        if len(groups) > 1 or len(kinds) > 1:
            try:
                open(loading_file, "w").write(_selection_html(roots[0], allfiles))
            except Exception:
                pass
            return None  # wait for the hvniz://review postback

    # Build the working set: explicit selection, else default archive/depth filtering.
    # An EMPTY include list means "no constraint" (= all), NOT "none" — otherwise a saved
    # per-folder pref with e.g. types=[] (which happens when a folder has a single file kind,
    # so the chooser hides the types section) would filter EVERYTHING out and fail with
    # "nothing selected to review." (Bug fix 2026-06-17.)
    if include_folders is not None or include_types is not None:
        files = list(allfiles)
        if include_folders:
            fset = set(include_folders)
            if known_folders is not None:
                # Applying a SAVED selection: keep the chosen folders AND any folder added since
                # (a folder that didn't exist when the selection was saved was never deselected).
                kset = set(known_folders)
                files = [(p, g) for p, g in files if g in fset or g not in kset]
            else:
                files = [(p, g) for p, g in files if g in fset]
        if include_types:
            tset = set(include_types)
            files = [(p, g) for p, g in files if _category(p) in tset]
        if remember and len(roots) == 1:
            _save_folder_pref(roots[0], include_folders, include_types,
                              sorted({g for _, g in allfiles}))
    elif cfg.get("ignore_archive_folders", True):
        files = [(p, g) for p, g in allfiles if not _is_archive(g)]
    else:
        files = list(allfiles)
    if not files:
        raise SystemExit("PageHawk: nothing selected to review.")

    _explicit_mode = mode not in (None, "auto")
    if mode in (None, "auto"):
        mode = detect_mode(files, sidecar)
    if not task:
        base = roots[0]
        file_roots = [r for r in roots if os.path.isfile(r)]
        if mode != "compare" and len(roots) > 1 and len(file_roots) == len(roots):
            # A FILE SET, not a folder. Naming it after roots[0]'s PARENT titled a
            # three-document review "Downloads", which reads as the whole folder and is
            # precisely what the set is not. Say what it actually holds, and keep the
            # shared parent in front when there is one so the review is still placeable.
            # (task is also the output filename, so no path separators here.)
            parents = {os.path.dirname(r) for r in file_roots}
            task = "%d documents" % len(file_roots)
            if len(parents) == 1:
                p = os.path.basename(next(iter(parents)).rstrip("/"))
                if p:
                    task = "%s · %s" % (p, task)
        else:
            task = os.path.basename((base if os.path.isdir(base)
                                     else os.path.dirname(base)).rstrip("/")) or "Triage"
    # Brand gating: the Winnow Photos build opens straight into the photo sorter when
    # pointed at a folder of images (its dedupe + quality_score deltas are mounted).
    # Data-driven via the bundled brand.json, so the doc-tool builds are unaffected.
    # An EXPLICIT --mode always wins over this auto-detection (a caller who asked for
    # e.g. "single" should never be silently rerouted into "sort").
    try:
        import brand as _brand
        _img_root = (len(roots) == 1 and os.path.isdir(roots[0])
                     and sum(1 for p, _ in files if os.path.splitext(p)[1].lower() in IMG_EXT)
                         >= max(1, len(files) // 2))
        if not _explicit_mode and mode not in ("doc", "compare", "rank", "collect") and _img_root and \
                (_brand.default_view() == "sort" or _brand.is_winnow()):
            mode = "sort"
    except Exception:
        pass
    # Single-document mode: ONE multi-page file dropped → show every PAGE as a grid
    # thumbnail (a full document viewer, not just a multi-document one). Pages render via
    # render_pages (pdftoppm for PDF; soffice for Office formats) and then flow through the
    # normal image grid, so the jump-search, lightbox, and keyboard nav all work per page.
    page_items = None
    if len(files) == 1 and mode != "doc":
        _src = files[0][0]
        if os.path.splitext(_src)[1].lower() in DOC_EXT:
            try:
                _thumbdir = os.path.join(triage_gen.BASE_DIR, "pagecache")
                _urls, _ = triage_gen.render_pages(_src, _thumbdir, max_pages=500)
                _paths = [triage_gen._local(u) for u in _urls]
                _paths = [p for p in _paths if p and os.path.exists(p)]
                if len(_paths) > 1:
                    _docname = os.path.splitext(os.path.basename(_src))[0]
                    page_items = [{"path": pp, "name": f"Page {i + 1}", "folder": _docname,
                                   "category": "Pages", "features": {"page": str(i + 1)}}
                                  for i, pp in enumerate(_paths)]
                    mode = "single"
                    task = _docname
            except Exception:
                page_items = None   # any failure → fall back to the normal single-file view
    items = page_items if page_items is not None else build_items(mode, files, sidecar)
    viewer = cfg.get("viewer", "app")
    # Open a branded loading page NOW so there's an immediate visual cue while the
    # (possibly slow) render runs; we overwrite it with a redirect when done.
    loading_path = None
    if loading_file:
        # The unified app already opened a window on THIS file (so it isn't reaped
        # before a window appears). We just swap it to a redirect when done.
        loading_path = loading_file
    elif do_open and loading:
        try:
            os.makedirs(triage_gen.BASE_DIR, exist_ok=True)
            for stale in glob.glob(os.path.join(triage_gen.BASE_DIR, ".loading-*.html")):
                try:
                    os.remove(stale)
                except OSError:
                    pass
            loading_path = os.path.join(triage_gen.BASE_DIR, f".loading-{os.getpid()}.html")
            with open(loading_path, "w") as fh:
                fh.write(_loading_html(len(files), task))
            _open_in_viewer(loading_path, viewer)
        except Exception:
            loading_path = None
    print(f"PageHawk: {len(files)} files | mode={mode} | task={task!r}")
    print("Rendering pages/thumbnails (first run is slowest; cache reused after)...")
    # progress callback: keep the loading window's count + bar moving while rendering
    prog = None
    if loading_path:
        def prog(done, total, _lp=loading_path, _t=task):
            try:
                open(_lp, "w").write(triage_gen._progress_html(done, total, _t))
            except Exception:
                pass
    try:
        if mode == "sort":
            # Winnow Photos: shared-engine near-duplicate clustering + on-device quality
            # scoring -> the 3-zone sorter. The DELTA work lives in winnow_photo/winnow_view.
            import os as _os, sys as _sys
            _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
            import winnow_view as _wv
            _folder = _os.path.abspath(_os.path.expanduser(roots[0]))
            result = _wv.build_sort(_folder, task=task, progress=prog,
                                     brand_name=_wv._resolve_brand_name())
        elif mode == "collect":
            # Winnow Photos — Collections (ADDITIVE): gallery-scale, year-organized batch
            # favoriting into named albums. The twin of sort; lives in winnow_collect_view.
            import os as _os, sys as _sys
            _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
            import winnow_collect_view as _cv
            _folder = _os.path.abspath(_os.path.expanduser(roots[0]))
            result = _cv.build_collect(_folder, task=task, progress=prog,
                                        brand_name=_cv._resolve_brand_name())
        elif mode == "rank":
            import os as _os, sys as _sys, datetime as _dt
            _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
            import pagehawk_engine as _pe
            _folder = _os.path.abspath(_os.path.expanduser(paths[0]))
            _now = _dt.datetime.now()
            _out = _os.path.join(triage_gen.BASE_DIR,
                                 _now.strftime("%Y-%m-%d at %H.%M") + _now.strftime("%p").lower()
                                 + " " + str(task) + " (ranked).html")
            _pe.build(_folder, "", _out)
            result = {"out": _out, "kb": _os.path.getsize(_out) / 1024.0, "note": "ranked by focus"}
        elif mode == "compare":
            import os as _os, sys as _sys, datetime as _dt
            _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
            import pagehawk_diff as _pd
            _fs = [f[0] for f in files][:2]
            if len(_fs) < 2:
                raise ValueError("Compare needs two documents; got %d" % len(_fs))
            _now = _dt.datetime.now()
            _out = _os.path.join(triage_gen.BASE_DIR,
                                 _now.strftime("%Y-%m-%d at %H.%M") + _now.strftime("%p").lower()
                                 + " Compare " + str(task) + ".html")
            result = _pd.build_compare(_fs, _out, task)
        else:
            result = triage_gen.build_tool(mode, task, items, roots=roots,
                                           copy_dir=cfg.get("output_copy_dir", ""), progress=prog)
    except Exception as e:
        if loading_path:
            try:
                open(loading_path, "w").write(_error_html(e))
            except Exception:
                pass
        raise
    print(f"  -> {result['out']}  ({result['kb']:.0f} KB, {result['note']})")
    if do_open:
        if loading_path:
            # swap the loading window to a redirect; its self-reload picks it up
            try:
                open(loading_path, "w").write(_redirect_html(_file_url(result["out"])))
            except Exception:
                _open_in_viewer(result["out"], viewer)
        else:
            _open_in_viewer(result["out"], viewer)
        _notify(f"{triage_gen._brand_name()} ready",
                f"{len(files)} files · {mode} · {task}")
    return result


def _notify(title, body):
    try:
        subprocess.run(
            ["osascript", "-e",
             f'display notification {json.dumps(body)} with title {json.dumps(title)}'],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
    except Exception:
        pass


def _run_compare(paths, task, loading_file):
    """PageHawk two-document redline. Bypasses the grid scan (text-only), so PDFs,
    Word (.doc/.docx/.rtf via textutil), and plain text all compare. On-device."""
    import datetime as _dt
    files = [os.path.abspath(os.path.expanduser(p)) for p in (paths or [])]
    files = [p for p in files if os.path.isfile(p)][:2]
    if len(files) < 2:
        msg = f"{triage_gen._brand_name()} Compare needs two documents."
        print(msg)
        if loading_file:
            try:
                open(loading_file, "w").write(_error_html(ValueError(msg)))
            except Exception:
                pass
        return None
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    import pagehawk_diff
    if not task:
        task = "Compare " + os.path.splitext(os.path.basename(files[0]))[0]
    os.makedirs(triage_gen.BASE_DIR, exist_ok=True)
    now = _dt.datetime.now()
    out = os.path.join(triage_gen.BASE_DIR,
                       now.strftime("%Y-%m-%d at %H.%M") + now.strftime("%p").lower() + " " + task + ".html")
    res = pagehawk_diff.build_compare(files, out, task)
    if loading_file:
        try:
            open(loading_file, "w").write(_redirect_html(_file_url(res["out"])))
        except Exception:
            _open_in_viewer(res["out"], "app")
    else:
        _open_in_viewer(res["out"], "app")
    _notify(f"{triage_gen._brand_name()} compare ready", f"{os.path.basename(files[0])} vs {os.path.basename(files[1])}")
    return res


def main():
    ap = argparse.ArgumentParser(
        description="Point PageHawk at a folder (or folders) and review.")
    ap.add_argument("paths", nargs="+", help="Folder(s) or file(s) to triage.")
    ap.add_argument("--mode", default="auto",
                    choices=["auto", "flip", "single", "doc", "rank", "compare", "sort", "collect"],
                    help="auto detects from the mix; rank = focus-query ranking; compare = two-doc redline; "
                         "sort = reductive photo sort; collect = additive Collections (file into albums).")
    ap.add_argument("--task", default=None, help="Override the tool title/name.")
    ap.add_argument("--no-open", action="store_true", help="Build but don't open.")
    ap.add_argument("--no-sidecar", action="store_true",
                    help="Ignore preview_index.json and scan raw disk only.")
    ap.add_argument("--loading", action="store_true",
                    help="Open a branded loading page immediately, then redirect to the tool.")
    ap.add_argument("--loading-file", default=None,
                    help="Swap THIS already-open file to a redirect when done (the unified app "
                         "opens the loading window itself so it isn't reaped on launch).")
    ap.add_argument("--choose", action="store_true",
                    help="If the folder is ambiguous (multiple subfolders/kinds), show the chooser.")
    ap.add_argument("--include-folders", default=None,
                    help='JSON array of folder/group names to include (null = all).')
    ap.add_argument("--include-types", default=None,
                    help='JSON array of categories to include (null = all).')
    ap.add_argument("--remember", action="store_true", help="Save this selection for the folder.")
    ap.add_argument("--reset-pref", action="store_true", help="Clear the saved selection for the folder, then run.")
    ap.add_argument("--fresh", action="store_true", help="Wipe the render cache first (full recache from disk).")
    a = ap.parse_args()

    def _jlist(s):
        if not s:
            return None
        try:
            v = json.loads(s)
            return v if isinstance(v, list) else None
        except Exception:
            return None

    if a.reset_pref and a.paths:
        clear_folder_pref(os.path.abspath(os.path.expanduser(a.paths[0])))
    # "sort" is the canonical mode key everywhere (2026-08-02). The retired spelling is
    # normalized by the Swift shell BEFORE the engine is shelled (the hvniz:// verb router
    # and handleWinnowMode both map it), so no alias is needed here and none is offered:
    # the engine ships inside the app bundle, so an old shell never drives a new engine.
    # Verified before flipping the key: nothing persists it — folder_prefs.json stores
    # folder names + types only, and brand.json is regenerated from the TOML every build.
    if a.mode == "compare":
        _run_compare(a.paths, a.task, a.loading_file)
        return
    run(a.paths, mode=a.mode, task=a.task, do_open=not a.no_open,
        use_sidecar=not a.no_sidecar, loading=a.loading, loading_file=a.loading_file,
        choose=a.choose, include_folders=_jlist(a.include_folders),
        include_types=_jlist(a.include_types), remember=a.remember, fresh=a.fresh)


if __name__ == "__main__":
    main()
