#!/usr/bin/env python3
"""Winnow Photos — the photo-specialist deltas over the shared Gander/Pagehawk engine.

This is the FIRST niche-brand fork's native code (see ../../BRANDS.md). The shared
engine already does near-duplicate *clustering* (dHash + union-find in triage_gen);
this module adds the three photo-specific capabilities that engine does not have:

  1. quality_score  — per-photo sharpness / exposure / eyes-open scores so a burst's
                      keeper is chosen by QUALITY, not by file size. Sharpness and
                      exposure run 100% on-device with zero ML (Laplacian variance +
                      luma histogram over a small sips-rendered thumbnail). Eyes-open
                      is best-effort: a local vision model on the EXISTING Ollama seam
                      when one is installed, else macOS Vision via pyobjc when present,
                      else honestly reported as unknown (never fabricated).
  3. raw_decode     — Camera Raw (DNG/CR2/NEF/ARW/RAF/ORF/RW2) decoded via macOS
                      ImageIO (the `sips` path the engine already uses) so RAW photos
                      get thumbnails, hashes, and scores like any other image; a
                      LibRaw fallback is probed for the formats ImageIO can't read.
                      (Pro Max tier flag in the brand manifest.)

Design constraints (match the shared engine):
  * stdlib + `sips` is the PRIMARY path so this runs inside the bundled .app, which
    ships no Pillow/numpy. numpy is used ONLY as an optional accelerator when present.
  * Pure functions, no mutable instance state (the engine runs greenlets/threads).
  * Degrade gracefully and tell the truth: a missing model -> eyes=None, not a guess.

CLI:
  python winnow_photo.py score   --doc FILE                 # one photo's scores
  python winnow_photo.py sort    --dir FOLDER [--json OUT]  # cluster + score a folder
  python winnow_photo.py rawcheck --doc FILE.dng            # can we decode this RAW?
  python winnow_photo.py status                             # eyes-detector availability
"""
import os, sys, json, struct, subprocess, math

_HERE = os.path.dirname(os.path.abspath(__file__))
_SRC = os.path.dirname(_HERE)                      # .../src  (triage_gen lives here)
for _p in (_HERE, _SRC):
    if _p not in sys.path:
        sys.path.insert(0, _p)

# Optional numpy accelerator (present in dev, absent in the bundled .app — both fine).
try:
    import numpy as _np
except Exception:
    _np = None

# Camera Raw extensions ImageIO/sips decodes natively on modern macOS.
RAW_EXT = {".dng", ".cr2", ".cr3", ".nef", ".nrw", ".arw", ".sr2", ".srf",
           ".raf", ".orf", ".rw2", ".pef", ".raw", ".3fr", ".erf", ".dcr"}
IMG_EXT = {".jpg", ".jpeg", ".png", ".gif", ".heic", ".heif", ".tif", ".tiff",
           ".webp", ".bmp"} | RAW_EXT


def is_raw(path):
    return os.path.splitext(path)[1].lower() in RAW_EXT


def is_image(path):
    return os.path.splitext(path)[1].lower() in IMG_EXT


# ----------------------------------------------------------------- pixel access
def _gray_bmp(path, thumbdir, long_edge=256):
    """Render any image (incl. HEIC/RAW) to a small uncompressed BMP via sips
    (ImageIO under the hood) and return rows of luma [0..255]. None on failure.
    Small so the pure-Python math below stays fast even with no numpy."""
    os.makedirs(thumbdir, exist_ok=True)
    base = os.path.basename(path)
    bmp = os.path.join(thumbdir, base + ".q%d.bmp" % long_edge)
    if not os.path.exists(bmp):
        try:
            subprocess.run(
                ["sips", "-Z", str(long_edge), "-s", "format", "bmp", path, "--out", bmp],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=True)
        except Exception:
            return None
    try:
        return _read_bmp_gray(bmp)
    except Exception:
        return None


def _read_bmp_gray(path):
    """Rows of luma from an uncompressed 24/32-bit BMP (sips output). Mirrors the
    reader in triage_gen so behavior is identical; kept local so this module runs
    standalone in the bundled app even if triage_gen import order changes."""
    with open(path, "rb") as f:
        d = f.read()
    if d[:2] != b"BM":
        raise ValueError("not a BMP")
    pixoff = struct.unpack("<I", d[10:14])[0]
    width = struct.unpack("<i", d[18:22])[0]
    height = struct.unpack("<i", d[22:26])[0]
    bpp = struct.unpack("<H", d[28:30])[0]
    bypp = max(1, bpp // 8)
    if bypp < 3:
        raise ValueError("unexpected bpp %d" % bpp)
    rowsize = ((bpp * width + 31) // 32) * 4
    rows = []
    for y in range(abs(height)):
        off = pixoff + y * rowsize
        line = d[off:off + rowsize]
        gr = bytearray(width)
        for x in range(width):
            b, g, r = line[x * bypp], line[x * bypp + 1], line[x * bypp + 2]
            gr[x] = (r * 299 + g * 587 + b * 114) // 1000
        rows.append(gr)
    if height > 0:                       # BMP scanlines are bottom-up
        rows.reverse()
    return rows


# ----------------------------------------------------------------- sharpness
def _sharpness(rows):
    """Variance of the Laplacian (the standard blur metric): a sharp image has lots
    of high-frequency edge energy, a blurred one has little. Returns the raw
    variance (unbounded); higher = sharper. numpy-accelerated when available."""
    h = len(rows)
    w = len(rows[0]) if h else 0
    if h < 3 or w < 3:
        return 0.0
    if _np is not None:
        a = _np.asarray(rows, dtype=_np.float64)
        lap = (a[:-2, 1:-1] + a[2:, 1:-1] + a[1:-1, :-2] + a[1:-1, 2:]
               - 4.0 * a[1:-1, 1:-1])
        return float(lap.var())
    # pure-python 4-neighbour Laplacian
    s = 0.0; ss = 0.0; n = 0
    for y in range(1, h - 1):
        r0, r1, r2 = rows[y - 1], rows[y], rows[y + 1]
        for x in range(1, w - 1):
            v = r0[x] + r2[x] + r1[x - 1] + r1[x + 1] - 4 * r1[x]
            s += v; ss += v * v; n += 1
    if n == 0:
        return 0.0
    mean = s / n
    return ss / n - mean * mean


def _sharp_to_score(var):
    """Map raw Laplacian variance to a friendly 0..100. The knee (~variance 300 on a
    256px thumbnail) is calibrated so a crisp photo lands ~85-98 and obvious blur
    <50; it is a display scale, not a physical unit. Monotonic, so RANKING within a
    burst (what the keeper pick depends on) is exact regardless of the curve."""
    if var <= 0:
        return 0
    # log compression: variance spans orders of magnitude across sharp/blurred
    score = 100.0 * (math.log10(var + 1) / math.log10(3000.0))
    return int(max(0, min(100, round(score))))


# ----------------------------------------------------------------- exposure
def _exposure(rows):
    """0..100 exposure quality from the luma histogram. Penalizes blown highlights
    and crushed shadows (clipping) and over/under-exposed mean. 100 = well-exposed."""
    hist = [0] * 256
    total = 0
    for r in rows:
        for v in r:
            hist[v] += 1; total += 1
    if not total:
        return 0
    mean = sum(i * c for i, c in enumerate(hist)) / total
    clip_lo = sum(hist[:4]) / total       # near-black
    clip_hi = sum(hist[252:]) / total     # near-white
    # ideal mean ~118; fall off toward the ends
    mean_pen = abs(mean - 118) / 118.0            # 0 best .. ~1 worst
    clip_pen = min(1.0, (clip_lo + clip_hi) * 2.5)  # heavy clipping hurts
    score = 100.0 * (1 - 0.6 * mean_pen - 0.4 * clip_pen)
    return int(max(0, min(100, round(score))))


# ----------------------------------------------------------------- eyes-open (best effort)
_EYES_BACKEND = None  # cached: 'vision' | 'ollama:<model>' | None


def _vision_models():
    """Installed Ollama vision models, or [] (also [] if Ollama is down)."""
    try:
        import pagehawk_ai as ai
        names = ai.available()
    except Exception:
        return []
    vis = ("llava", "llama3.2-vision", "llama3.2vision", "qwen2-vl", "qwen2.5vl",
           "qwen2.5-vl", "moondream", "bakllava", "minicpm-v", "gemma3")
    return [n for n in names if any(n.lower().startswith(v) or v in n.lower() for v in vis)]


def eyes_backend():
    """Decide once how (if at all) we can judge eyes-open: macOS Vision face landmarks
    (pyobjc) > local Ollama vision model > none. Cached for the process."""
    global _EYES_BACKEND
    if _EYES_BACKEND is not None:
        return _EYES_BACKEND
    # 1) macOS Vision framework (real on-device face/eye landmarks) if pyobjc present
    try:
        import Vision, Quartz  # noqa: F401
        _EYES_BACKEND = "vision"
        return _EYES_BACKEND
    except Exception:
        pass
    # 2) local Ollama vision model on the existing seam
    vm = _vision_models()
    _EYES_BACKEND = ("ollama:" + vm[0]) if vm else None
    return _EYES_BACKEND


def _eyes_ollama(path, model):
    """Ask a local vision model if subjects' eyes are open. Returns 0..100 or None.
    Fully on-device (Ollama). Strict JSON for a stable parse."""
    try:
        import base64, urllib.request
        # downscale to keep the model fast / the request small
        small = path + ".eyes.jpg"
        subprocess.run(["sips", "-Z", "512", "-s", "format", "jpeg", path, "--out", small],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
        src = small if os.path.exists(small) else path
        b64 = base64.b64encode(open(src, "rb").read()).decode()
        host = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
        payload = {"model": model, "stream": False, "format": "json",
                   "options": {"temperature": 0.0, "num_predict": 80},
                   "prompt": ('Look at the people in this photo. Reply STRICT JSON only: '
                              '{"faces":N,"eyes_open":true|false,"confidence":0..1}. '
                              'eyes_open is false if ANY clearly-visible subject has '
                              'closed eyes. If there are no faces, faces=0.'),
                   "images": [b64]}
        req = urllib.request.Request(host + "/api/generate",
                                     data=json.dumps(payload).encode(),
                                     headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=120) as r:
            d = json.loads(r.read().decode("utf-8", "ignore"))
        obj = json.loads(d.get("response", "{}") or "{}")
        if not obj.get("faces"):
            return None                         # no face -> eyes score not meaningful
        return 90 if obj.get("eyes_open") else 12
    except Exception:
        return None
    finally:
        try:
            if 'small' in dir() and os.path.exists(small):
                os.remove(small)
        except Exception:
            pass


def _eyes_vision(path):
    """macOS Vision face-landmark eye-openness, fully on-device, no model download.
    Returns 0..100 or None (no face / framework unavailable)."""
    try:
        import Quartz, Vision
        url = Quartz.CFURLCreateWithFileSystemPath(None, path, Quartz.kCFURLPOSIXPathStyle, False)
        src = Quartz.CGImageSourceCreateWithURL(url, None)
        if not src:
            return None
        cg = Quartz.CGImageSourceCreateImageAtIndex(src, 0, None)
        if not cg:
            return None
        req = Vision.VNDetectFaceLandmarksRequest.alloc().init()
        handler = Vision.VNImageRequestHandler.alloc().initWithCGImage_options_(cg, None)
        ok = handler.performRequests_error_([req], None)
        if not ok:
            return None
        results = req.results() or []
        if not results:
            return None                          # no faces -> not meaningful
        worst = 100
        for face in results:
            lm = face.landmarks()
            if lm is None:
                continue
            # eye region present + reasonably spread vertically => open. Vision has no
            # direct openness scalar pre-26; use the eye landmark bounding spread as a
            # cheap proxy, clamped.
            for getter in ("leftEye", "rightEye"):
                region = getattr(lm, getter, lambda: None)()
                if region is None:
                    continue
                pts = region.normalizedPoints()
                ys = [p.y for p in pts] if pts else []
                if ys:
                    spread = (max(ys) - min(ys))
                    worst = min(worst, int(max(0, min(100, spread * 100 * 6))))
        return worst if worst != 100 else 88
    except Exception:
        return None


def eyes_open_score(path):
    """Best-effort eyes-open 0..100, or None when no detector / no face. Honest:
    returns None rather than a fabricated number when we genuinely cannot tell."""
    b = eyes_backend()
    if b == "vision":
        return _eyes_vision(path)
    if b and b.startswith("ollama:"):
        return _eyes_ollama(path, b.split(":", 1)[1])
    return None


# ----------------------------------------------------------------- per-photo score
def score_photo(path, thumbdir="/tmp/winnow_q", with_eyes=False):
    """Return {sharp, exp, eyes, overall, blurry, raw} for one photo. `eyes` is None
    when unavailable. `overall` is the keeper-ranking composite (eyes folded in only
    when known). Sharpness + exposure are always real, on-device, ML-free."""
    out = {"sharp": None, "exp": None, "eyes": None, "overall": None,
           "blurry": False, "raw": is_raw(path)}
    rows = _gray_bmp(path, thumbdir)
    if rows is None:
        return out
    var = _sharpness(rows)
    out["sharp"] = _sharp_to_score(var)
    out["exp"] = _exposure(rows)
    out["blurry"] = out["sharp"] < 50
    if with_eyes:
        out["eyes"] = eyes_open_score(path)
    # composite: sharpness dominates the keeper decision, exposure modulates, an
    # eyes-closed photo is heavily penalized only when we actually detected it.
    base = out["sharp"] * 0.65 + out["exp"] * 0.35
    if out["eyes"] is not None:
        base = base * (0.55 + 0.45 * out["eyes"] / 100.0)
    out["overall"] = int(round(base))
    return out


# ----------------------------------------------------------------- folder sort
def sort_folder(folder, thumbdir=None, with_eyes=False, progress=None):
    """Cluster a folder into bursts (shared engine) AND score every photo, then
    re-pick each burst's keeper by QUALITY. Returns a manifest the sort UI consumes:
      {photos:[{path,name,thumb,dims,cluster,best,sharp,exp,eyes,overall,blurry,
                suggest,raw}], clusters:[{id,kind,members,keeper}], stats:{...}}"""
    import triage_gen as tg
    thumbdir = thumbdir or os.path.join(tg.BASE_DIR, "winnowcache")
    files = sorted(os.path.join(folder, f) for f in os.listdir(folder)
                   if is_image(os.path.join(folder, f)) and not f.startswith("."))
    items = [{"path": p, "name": os.path.basename(p)} for p in files]
    if not items:
        return {"photos": [], "clusters": [], "stats": {"photos": 0}}
    # shared engine: thumbnails + dims + dHash + burst clustering (delta 1)
    tg.prep_grid(items, thumbdir, size=512, progress=progress)
    # delta 2: quality score every photo (content-addressed cache so a re-sort is instant)
    cache = _load_score_cache(thumbdir)
    dirty = False
    for i, it in enumerate(items):
        key = _score_key(it["path"])
        hit = cache.get(key) if (key and not with_eyes) else None   # eyes runs fresh (opt-in, may change)
        if hit:
            it.update(hit)
        else:
            local = tg._local(it.get("thumb")) if it.get("thumb") else it["path"]
            q = score_photo(local if local and os.path.exists(local) else it["path"],
                            thumbdir, with_eyes=with_eyes)
            it.update(q)
            if key and not with_eyes:
                cache[key] = q; dirty = True
        if progress:
            progress(i + 1, len(items), "scoring")
    if dirty:
        _save_score_cache(thumbdir, cache)
    # also group same-capture RAW+JPEG / HEIC+JPEG pairs (a gap most sorters miss)
    _pair_same_capture(items)
    # re-pick each cluster's keeper by quality (was area/filesize in the engine)
    from collections import defaultdict
    groups = defaultdict(list)
    for it in items:
        if it.get("cluster"):
            groups[it["cluster"]].append(it)
    clusters = []
    for cid, members in sorted(groups.items()):
        keeper = max(members, key=lambda m: (m.get("overall") or -1,
                                             _area(m), m.get("size", 0)))
        for m in members:
            m["best"] = (m is keeper)
            m["suggest"] = "" if m is keeper else "trash"
        kinds = _cluster_kind(members)
        clusters.append({"id": cid, "kind": kinds, "size": len(members),
                         "members": [m["name"] for m in members],
                         "keeper": keeper["name"]})
    stats = {
        "photos": len(items),
        "clusters": len(clusters),
        "duplicate_frames": sum(c["size"] - 1 for c in clusters),
        "blurry": sum(1 for it in items if it.get("blurry")),
        "eyes_closed": sum(1 for it in items if (it.get("eyes") is not None and it["eyes"] < 40)),
    }
    return {"photos": items, "clusters": clusters, "stats": stats,
            "eyes_backend": eyes_backend()}


def _pair_same_capture(items, start_cid=0):
    """Group same-capture RAW+JPEG / HEIC+JPEG pairs — a real gap most sorters miss: a file
    saved as both (e.g. IMG_1234.CR2 + IMG_1234.JPG) is ONE shot counted twice. Files sharing
    a stem where at least one side is RAW or HEIC become a 'pair' cluster so the user keeps one.
    Keeper defaults to the higher-fidelity original (RAW/HEIC). Skips files already in a burst
    cluster. Returns the number of pairs found."""
    from collections import defaultdict
    by_stem = defaultdict(list)
    for it in items:
        by_stem[os.path.splitext(it["name"])[0].lower()].append(it)
    cid = max([it.get("cluster") or 0 for it in items] + [start_cid])
    pairs = 0
    HEIC = {".heic", ".heif"}
    for stem, members in by_stem.items():
        if len(members) < 2 or any(m.get("cluster") for m in members):
            continue
        exts = {os.path.splitext(m["name"])[1].lower() for m in members}
        if not (exts & RAW_EXT) and not (exts & HEIC):
            continue   # only same-stem groups where one side is a RAW/HEIC original
        cid += 1; pairs += 1
        def pref(m):
            e = os.path.splitext(m["name"])[1].lower()
            return (e in RAW_EXT or e in HEIC, m.get("overall") or 0, m.get("size") or 0)
        keeper = max(members, key=pref)
        for m in members:
            m["cluster"] = cid
            m["best"] = (m is keeper)
            m["pair"] = True
            m["suggest"] = "" if m is keeper else "trash"
    return pairs


def _area(it):
    try:
        w, h = (it.get("dims") or "0x0").split("x")
        return int(w) * int(h)
    except Exception:
        return 0


def _cluster_kind(members):
    """Label a burst for the filter rail: pair (RAW/HEIC+JPEG same capture), blink (eyes
    closed), blur (wide sharpness spread), else dupe."""
    if any(m.get("pair") for m in members):
        return "pair"
    if any(m.get("eyes") is not None and m["eyes"] < 40 for m in members):
        return "blink"
    sharps = [m.get("sharp") or 0 for m in members]
    if sharps and (max(sharps) - min(sharps)) >= 25:
        return "blur"
    return "dupe"


# ----------------------------------------------------------------- raw decode probe
def rawcheck(path):
    """Can macOS decode this RAW? Try ImageIO (sips) first; report a LibRaw fallback
    if present for what ImageIO can't read. Returns a dict for the CLI / Pro Max gate."""
    ext = os.path.splitext(path)[1].lower()
    res = {"path": path, "ext": ext, "is_raw": ext in RAW_EXT,
           "imageio": False, "libraw": False, "dims": None}
    try:
        r = subprocess.run(["sips", "-g", "pixelWidth", "-g", "pixelHeight", path],
                           capture_output=True, timeout=30)
        txt = r.stdout.decode("utf-8", "ignore")
        w = h = None
        for line in txt.splitlines():
            line = line.strip()
            if line.startswith("pixelWidth:"):
                w = line.split(":", 1)[1].strip()
            elif line.startswith("pixelHeight:"):
                h = line.split(":", 1)[1].strip()
        if w and h:
            res["imageio"] = True
            res["dims"] = "%sx%s" % (w, h)
    except Exception:
        pass
    # LibRaw fallback probe (dcraw_emu / libraw is the standard CLI) — only relevant
    # for the handful of bodies ImageIO lags on; install is `brew install libraw`.
    import shutil
    res["libraw"] = bool(shutil.which("dcraw_emu") or shutil.which("dcraw"))
    res["decodable"] = res["imageio"] or res["libraw"]
    return res


# ----------------------------------------------------------------- score cache (warm re-sort = instant)
def _score_key(path):
    """Content-address a photo by abspath+mtime+size: an edit invalidates the entry,
    and the full path avoids same-basename collisions across folders sharing the cache."""
    try:
        st = os.stat(path)
        return "%s|%d|%d" % (os.path.abspath(path), int(st.st_mtime), st.st_size)
    except OSError:
        return None


def _score_cache_path(thumbdir):
    return os.path.join(thumbdir, ".winnow_scores.json")


def _load_score_cache(thumbdir):
    try:
        return json.load(open(_score_cache_path(thumbdir)))
    except Exception:
        return {}


def _save_score_cache(thumbdir, cache):
    try:
        os.makedirs(thumbdir, exist_ok=True)
        tmp = _score_cache_path(thumbdir) + ".tmp"
        json.dump(cache, open(tmp, "w"))
        os.replace(tmp, _score_cache_path(thumbdir))   # atomic, never a half-written cache
    except Exception:
        pass


# ----------------------------------------------------------------- apply the sort (safe move)
import datetime as _dt


def apply_setaside(folder, reject_names, dest=None):
    """MOVE the rejected photos out of `folder` into a dated 'Winnow Set Aside' subfolder.
    This is the sort's one destructive-looking action made SAFE: it MOVES, never deletes,
    so every decision is reversible (drag the files back, or trash the folder yourself).
    `reject_names` = basenames the user set aside. Returns a JSON-able result.

    Name collisions inside the set-aside folder are de-duped (' (2)' suffix). Files that
    vanished since the sort are skipped, not errored. Keepers are never touched."""
    folder = os.path.abspath(os.path.expanduser(folder))
    if not os.path.isdir(folder):
        return {"error": "folder not found: %s" % folder, "moved": 0}
    if dest is None:
        stamp = _dt.datetime.now().strftime("%Y-%m-%d")
        dest = os.path.join(folder, "_Winnow Set Aside %s" % stamp)
    moved, skipped, errors = [], [], []
    made_dest = False
    for name in reject_names:
        name = os.path.basename(name)                  # never escape the folder
        src = os.path.join(folder, name)
        if not os.path.isfile(src):
            skipped.append(name); continue
        try:
            if not made_dest:
                os.makedirs(dest, exist_ok=True); made_dest = True
            target = os.path.join(dest, name)
            if os.path.exists(target):                 # de-dupe collisions, don't clobber
                stem, ext = os.path.splitext(name); n = 2
                while os.path.exists(os.path.join(dest, "%s (%d)%s" % (stem, n, ext))):
                    n += 1
                target = os.path.join(dest, "%s (%d)%s" % (stem, n, ext))
            import shutil
            shutil.move(src, target)
            moved.append(name)
        except Exception as e:
            errors.append({"name": name, "error": str(e)})
    return {"moved": len(moved), "skipped": len(skipped), "errors": errors,
            "dest": dest if moved else None, "moved_names": moved,
            "note": "moved to '%s' — nothing deleted, fully reversible" % os.path.basename(dest)}


def _decode_rejects(reject_b64=None, reject_csv=None):
    if reject_b64:
        import base64
        return json.loads(base64.b64decode(reject_b64).decode("utf-8"))
    if reject_csv:
        return [n for n in reject_csv.split(",") if n]
    return []


# ----------------------------------------------------------------- collect (ADDITIVE: file into a named album)
def file_into_album(folder, names, album, dest_root):
    """COPY the chosen photos from `folder` INTO `<dest_root>/<album>/`. This is the
    additive twin of apply_setaside: where the sort MOVES rejects out, Collections
    COPIES keepers into a curated, shareable album. Originals are NEVER moved or
    touched — the copy is the whole point (the album is a separate 'public' subset).

    `names` = basenames in `folder` to file. `album` is sanitized to a single safe
    folder name. If a same-basename file already sits in the album it is treated as
    already-present (idempotent, so re-filing a photo never duplicates it). Files that
    vanished since the gallery loaded are skipped, not errored. Returns a JSON-able result."""
    folder = os.path.abspath(os.path.expanduser(folder))
    dest_root = os.path.abspath(os.path.expanduser(dest_root))
    album = _safe_album(album)
    if not os.path.isdir(folder):
        return {"error": "folder not found: %s" % folder, "filed": 0}
    if not album:
        return {"error": "no album name", "filed": 0}
    dest = os.path.join(dest_root, album)
    filed, already, skipped, errors = [], [], [], []
    import shutil
    made = False
    for name in names:
        name = os.path.basename(name)                  # never escape the source folder
        src = os.path.join(folder, name)
        if not os.path.isfile(src):
            skipped.append(name); continue
        target = os.path.join(dest, name)
        try:
            if os.path.exists(target):                 # already in the album → idempotent
                already.append(name); continue
            if not made:
                os.makedirs(dest, exist_ok=True); made = True
            shutil.copy2(src, target)
            filed.append(name)
        except Exception as e:
            errors.append({"name": name, "error": str(e)})
    return {"filed": len(filed), "already": len(already), "skipped": len(skipped),
            "errors": errors, "album": album, "dest": dest,
            "filed_names": filed,
            "note": "copied %d into the '%s' album — originals untouched" % (len(filed), album)}


def remove_from_album(names, album, dest_root):
    """Un-file: delete the COPIES of `names` from `<dest_root>/<album>/`. Only ever
    touches the curated album copy, never an original. Lets the gallery toggle a photo
    back out of an album. Returns a JSON-able result."""
    dest_root = os.path.abspath(os.path.expanduser(dest_root))
    album = _safe_album(album)
    dest = os.path.join(dest_root, album)
    removed, missing, errors = [], [], []
    for name in names:
        name = os.path.basename(name)
        target = os.path.join(dest, name)
        if not os.path.isfile(target):
            missing.append(name); continue
        try:
            os.remove(target); removed.append(name)
        except Exception as e:
            errors.append({"name": name, "error": str(e)})
    return {"removed": len(removed), "missing": len(missing), "errors": errors,
            "album": album, "dest": dest}


def album_members(dest_root, albums=None):
    """{album: [basenames]} for the albums under `dest_root`. Drives the gallery's
    'already filed' highlight and the per-album counts, mirroring the picker."""
    dest_root = os.path.abspath(os.path.expanduser(dest_root))
    out = {}
    try:
        if albums is None:
            albums = [d for d in sorted(os.listdir(dest_root))
                      if os.path.isdir(os.path.join(dest_root, d)) and not d.startswith(".")]
    except OSError:
        albums = albums or []
    for a in albums:
        a = _safe_album(a)
        d = os.path.join(dest_root, a)
        try:
            out[a] = sorted(f for f in os.listdir(d)
                            if is_image(os.path.join(d, f)) and not f.startswith("."))
        except OSError:
            out[a] = []
    return out


def _safe_album(name):
    """A single safe folder name for an album: strip path separators and leading dots,
    collapse whitespace. Never returns a path that could escape dest_root."""
    name = (name or "").strip().replace("/", " ").replace("\\", " ").replace(":", " ")
    name = " ".join(name.split())
    while name.startswith("."):
        name = name[1:].strip()
    return name[:120]


def collect_manifest(folder, thumbdir=None, scored=False, progress=None):
    """Gallery-scale manifest for Collections mode: every photo in `folder` with a
    thumbnail, capture date, and YEAR bucket — NO clustering, NO reductive sort. This
    is the additive favorites surface: browse the whole gallery by year and batch-file
    keepers into albums. Quality scoring is optional (`scored`) since the headline here
    is fast year/group browsing, not keeper-picking.

    Returns {photos:[{name,path,thumb,dims,mtime,date,year}], years:[{year,count}],
             stats:{photos,dated,years}}."""
    import triage_gen as tg
    thumbdir = thumbdir or os.path.join(tg.BASE_DIR, "winnowcache")
    files = sorted(os.path.join(folder, f) for f in os.listdir(folder)
                   if is_image(os.path.join(folder, f)) and not f.startswith("."))
    items = [{"path": p, "name": os.path.basename(p)} for p in files]
    if not items:
        return {"photos": [], "years": [], "stats": {"photos": 0, "dated": 0, "years": 0}}
    # shared engine: thumbnails + dims + EXIF capture date (cheap; no per-photo scoring)
    tg.prep_grid(items, thumbdir, size=512, progress=progress)
    if scored:
        cache = _load_score_cache(thumbdir)
        for i, it in enumerate(items):
            key = _score_key(it["path"])
            hit = cache.get(key) if key else None
            if hit:
                it.update(hit)
            else:
                local = tg._local(it.get("thumb")) if it.get("thumb") else it["path"]
                q = score_photo(local if local and os.path.exists(local) else it["path"], thumbdir)
                it.update(q)
                if key:
                    cache[key] = q
            if progress:
                progress(i + 1, len(items), "scoring")
        _save_score_cache(thumbdir, cache)
    dated = 0
    from collections import Counter
    yc = Counter()
    for it in items:
        y = _photo_year(it)
        it["year"] = y
        d = (it.get("exif") or {}).get("date")
        if d:
            it["date"] = d; dated += 1
        yc[y] += 1
    # year sections newest-first; "Undated" always last
    years = sorted((y for y in yc if y != "Undated"), reverse=True)
    if "Undated" in yc:
        years.append("Undated")
    return {"photos": items, "years": [{"year": y, "count": yc[y]} for y in years],
            "stats": {"photos": len(items), "dated": dated, "years": len(years)}}


def _photo_year(it):
    """The year a photo belongs to: EXIF capture date first (most accurate), else file
    mtime, else 'Undated'. Returns a 4-char year string or 'Undated'."""
    d = (it.get("exif") or {}).get("date")            # 'YYYY-MM-DD HH:MM'
    if d and len(d) >= 4 and d[:4].isdigit():
        return d[:4]
    mt = it.get("mtime")
    if mt:
        try:
            return _dt.datetime.fromtimestamp(mt).strftime("%Y")
        except Exception:
            pass
    return "Undated"


# ----------------------------------------------------------------- CLI
def _status():
    b = eyes_backend()
    return {"eyes_backend": b,
            "eyes_detail": ("macOS Vision (pyobjc)" if b == "vision"
                            else b if b else "none — sharpness+exposure only (on-device, ML-free)"),
            "vision_models": _vision_models(),
            "numpy": bool(_np)}


if __name__ == "__main__":
    import argparse
    ap = argparse.ArgumentParser(description="Winnow Photos — photo-sort deltas")
    ap.add_argument("cmd", choices=["score", "sort", "rawcheck", "status", "apply",
                                    "collect", "collectapply", "collectremove", "albums"])
    ap.add_argument("--doc")
    ap.add_argument("--dir")
    ap.add_argument("--json")
    ap.add_argument("--reject-b64", help="base64(JSON list) of rejected basenames (apply)")
    ap.add_argument("--reject", help="comma-separated rejected basenames (apply)")
    ap.add_argument("--names-b64", help="base64(JSON list) of basenames (collectapply/collectremove)")
    ap.add_argument("--album", help="album name (collectapply/collectremove)")
    ap.add_argument("--dest", help="dest root the album folders live under (collect actions)")
    ap.add_argument("--scored", action="store_true", help="also quality-score each photo (collect)")
    ap.add_argument("--eyes", action="store_true", help="run the eyes-open detector (slower)")
    a = ap.parse_args()
    if a.cmd == "apply":
        if not a.dir or not os.path.isdir(a.dir):
            print(json.dumps({"error": "folder not found: %s" % a.dir})); sys.exit(1)
        rejects = _decode_rejects(getattr(a, "reject_b64", None), a.reject)
        print(json.dumps(apply_setaside(a.dir, rejects))); sys.exit(0)
    if a.cmd in ("collectapply", "collectremove"):
        names = _decode_rejects(getattr(a, "names_b64", None), None)
        if not a.dest or not a.album:
            print(json.dumps({"error": "need --dest and --album"})); sys.exit(1)
        if a.cmd == "collectapply":
            if not a.dir or not os.path.isdir(a.dir):
                print(json.dumps({"error": "folder not found: %s" % a.dir})); sys.exit(1)
            print(json.dumps(file_into_album(a.dir, names, a.album, a.dest)))
        else:
            print(json.dumps(remove_from_album(names, a.album, a.dest)))
        sys.exit(0)
    if a.cmd == "albums":
        if not a.dest:
            print(json.dumps({"error": "need --dest"})); sys.exit(1)
        print(json.dumps(album_members(a.dest), indent=2)); sys.exit(0)
    if a.cmd == "collect":
        if not a.dir or not os.path.isdir(a.dir):
            print(json.dumps({"error": "folder not found: %s" % a.dir})); sys.exit(1)
        man = collect_manifest(a.dir, scored=a.scored)
        if a.json:
            json.dump(man, open(a.json, "w"), indent=2)
            print("wrote", a.json, "(%d photos, %d years)" % (man["stats"]["photos"], man["stats"]["years"]))
        else:
            print(json.dumps({"stats": man["stats"], "years": man["years"]}, indent=2))
        sys.exit(0)
    if a.cmd == "status":
        print(json.dumps(_status(), indent=2)); sys.exit(0)
    if a.cmd == "score":
        if not a.doc or not os.path.exists(a.doc):
            print(json.dumps({"error": "photo not found: %s" % a.doc})); sys.exit(1)
        print(json.dumps(score_photo(a.doc, with_eyes=a.eyes), indent=2)); sys.exit(0)
    if a.cmd == "rawcheck":
        if not a.doc or not os.path.exists(a.doc):
            print(json.dumps({"error": "file not found: %s" % a.doc})); sys.exit(1)
        print(json.dumps(rawcheck(a.doc), indent=2)); sys.exit(0)
    if a.cmd == "sort":
        if not a.dir or not os.path.isdir(a.dir):
            print(json.dumps({"error": "folder not found: %s" % a.dir})); sys.exit(1)
        man = sort_folder(a.dir, with_eyes=a.eyes)
        if a.json:
            json.dump(man, open(a.json, "w"), indent=2)
            print("wrote", a.json, "(%d photos, %d clusters)" % (man["stats"]["photos"], len(man["clusters"])))
        else:
            # compact summary
            print(json.dumps({"stats": man["stats"], "eyes_backend": man.get("eyes_backend"),
                              "clusters": man["clusters"]}, indent=2))
