#!/usr/bin/env python3
"""
Photos screenshot capture (read-only source)
============================================
Finds NEW screenshots in the local macOS Photos library and captures them into
the vault's ``Agent/inbox/`` staging queue — the image files copied to
``Agent/inbox/Files/Screenshots <stamp>/`` plus one ``type: inbox-item`` note
holding a stub manifest — ready for the read-and-triage pass that routes
valuable content into the vault and drops the noise (see Agent/docs/
Screenshots.md). The `screenshots` skill drives this; a person rarely runs it
by hand.

Read-only against the library by construction: the live Photos.sqlite is never
opened — it is snapshot-copied (db + -wal + -shm) to a temp dir and the *copy*
is queried, so photolibraryd's locks are never contended and the library is
never touched. Image files are copied out, never moved. The only things this
script writes are the inbox item + batch folder (the capture) and its own
watermark state at ``<vault>/Agent/scripts/screenshots/state.json`` (created
fresh on first run) — and it refuses to run against a directory that is not a
real vault (no Agent/config.json), so a mis-resolved ``--vault`` can never
scatter captures into an arbitrary tree.

Runs on the machine that holds the owner's Photos library — i.e. their Mac,
NOT an always-on automation host (like the iMessage reader, unlike the
digest/sweep engine). Requires the app running the assistant (Claude, or the
terminal it runs in) to have macOS Full Disk Access; without it every read of
the library is blocked by TCC.

iCloud "Optimize Mac Storage" often keeps full originals OFF disk. Per shot
the script prefers the original (originals/<d>/<uuid>.png) and falls back to
the largest local derivative (resources/derivatives/<d>/<uuid>_…jpeg — full
device resolution, text readable). Shots with neither are reported + retried
on later runs (they resolve once Photos re-caches them).

Screenshots are identified by ZASSET.ZKINDSUBTYPE = 10 (trashed and hidden
excluded). Incremental runs window on ZADDEDDATE (when the shot *entered* the
library), so a screenshot taken offline days ago is still caught when it
finally syncs. Files are named by *taken* time in the shot's own timezone
(assets carry ZTIMEZONEOFFSET, so days stay right across travel).

Optional config — read from Agent/config.json under the `screenshots` key, all
optional (built-in defaults apply when absent):
    screenshots.default_since   first-run window when no watermark exists (default "7d")
    screenshots.default_limit   max shots per batch                       (default 75)
    screenshots.library         path to the .photoslibrary                (default ~/Pictures/Photos Library.photoslibrary)

Usage:
    python -m alexandria_vault.engine.screenshots_capture --vault PATH             # new since last run → inbox
    python -m alexandria_vault.engine.screenshots_capture --vault PATH --dry-run   # list what would be captured, write nothing
    python -m alexandria_vault.engine.screenshots_capture --vault PATH --since 3d  # explicit window (3d / 2w / today / yesterday / YYYY-MM-DD / all)
    python -m alexandria_vault.engine.screenshots_capture --vault PATH --limit 10  # cap the batch (0 = no cap); excess reported as remaining
    python -m alexandria_vault.engine.screenshots_capture --vault PATH --json      # machine-readable report (prefer for synthesis)

Every run prints a one-line coverage summary to stderr (window scanned · found
N new · captured M · not-local · remaining), so `--json` stdout stays clean.
Backfill = re-run the same `--since` window: the seen-set skips what is already
captured, `--limit` counts *captured* shots, and the watermark never moves
backwards — so repeated runs walk oldest-first through history one triageable
batch at a time.

The script never commits — the capture commit and its `git show --stat`
durability check are the caller's job (the `screenshots` skill). Stdlib only
(`sips`, the built-in macOS image tool, converts the rare HEIC). No secrets,
no network. Python 3.9+.
"""
import argparse
import datetime as dt
import json
import os
import shutil
import socket
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path

from alexandria_vault.engine import consume_vault_arg, resolve_vault
from alexandria_vault.vault_core import is_vault
from alexandria_vault.vault_core import load_config as _vc_load_config

# `--vault` must land in VAULT_DIR before the module-level vault/config load below.
consume_vault_arg()

SCRIPT = Path(__file__).resolve()
VAULT = resolve_vault()
COCOA_EPOCH = 978307200                 # seconds between 1970-01-01 and 2001-01-01 UTC
SUBTYPE_SCREENSHOT = 10                 # ZASSET.ZKINDSUBTYPE for screenshots
SEEN_CAP = 4000                         # UUIDs remembered against window overlap (a whole library's worth)
DERIV_FLOOR = 40_000                    # bytes — a "derivative" smaller than this is a thumbnail, not a readable render
MISSING_CAP = 50                        # not-local shots retried on later runs
STATE_REL = Path("Agent") / "scripts" / "screenshots" / "state.json"
INBOX_REL = Path("Agent") / "inbox"     # the staging queue (binaries under Files/ — capital F)


# ── config (Core-reads-Config seam; personalization never hardcoded here) ─────
def _load_config():
    """Read optional `screenshots` tuning from the vault's Config. A missing file or
    key is fine — built-in defaults apply. No identity is read here; this only tunes
    defaults (first-run window, batch cap, library path)."""
    cfg = _vc_load_config(VAULT)
    return cfg.get("screenshots", {}) if isinstance(cfg, dict) else {}


SS = _load_config()
DEFAULT_FIRST_WINDOW = str(SS.get("default_since", "7d"))   # no watermark yet → don't drain years of history
try:
    DEFAULT_LIMIT = int(SS.get("default_limit", 75))
except (TypeError, ValueError):
    DEFAULT_LIMIT = 75
LIBRARY = (Path(SS["library"]).expanduser() if SS.get("library")
           else Path.home() / "Pictures" / "Photos Library.photoslibrary")


def fmt_bytes(n):
    return "%.1fM" % (n / 1048576) if n >= 1048576 else "%.0fK" % (n / 1024)


# ── time helpers ──────────────────────────────────────────────────────────────
def cocoa_to_unix(c):
    return c + COCOA_EPOCH


def taken_local(date_created, tz_offset):
    """Cocoa taken-time + per-asset GMT offset → aware local datetime (the shot's own tz)."""
    ts = cocoa_to_unix(date_created)
    if tz_offset is None:
        return dt.datetime.fromtimestamp(ts).astimezone()
    tz = dt.timezone(dt.timedelta(seconds=tz_offset))
    return dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).astimezone(tz)


def parse_since(s):
    """--since grammar → cocoa cutoff on ZADDEDDATE (when the shot entered the library).
    Anything unparseable is a hard error — never a silent fallback, which would
    quietly narrow the window."""
    s = s.strip().lower()
    now = dt.datetime.now().astimezone()
    if s == "all":
        return 0.0
    if s == "today":
        cut = now.replace(hour=0, minute=0, second=0, microsecond=0)
    elif s == "yesterday":
        cut = now.replace(hour=0, minute=0, second=0, microsecond=0) - dt.timedelta(days=1)
    elif s.endswith("d") and s[:-1].isdigit():
        cut = now - dt.timedelta(days=int(s[:-1]))
    elif s.endswith("w") and s[:-1].isdigit():
        cut = now - dt.timedelta(weeks=int(s[:-1]))
    else:
        try:
            cut = dt.datetime.fromisoformat(s).astimezone()
        except ValueError:
            sys.exit("--since: can't parse %r (want 3d / 2w / today / yesterday / YYYY-MM-DD / all)" % s)
    return cut.timestamp() - COCOA_EPOCH


# ── database (read-only: only ever a snapshot copy is opened) ────────────────
FDA_MESSAGE = (
    "Photos library: access not permitted (macOS Full Disk Access).\n"
    "Grant Full Disk Access to the app running your assistant — Claude, or the terminal it runs in:\n"
    "  System Settings -> Privacy & Security -> Full Disk Access -> enable it -> re-open the app.\n"
    "Until then every read of the Photos library is blocked by macOS TCC."
)


def snapshot_and_open(library):
    """Copy Photos.sqlite (+wal/shm) to a temp dir and open the copy read-only.
    The live database is never opened, so the library is never touched."""
    db = library / "database" / "Photos.sqlite"
    if not db.exists():
        sys.exit("No Photos database at %s — is this the machine with the owner's Photos "
                 "library? (It lives on their Mac, not an automation host.)" % db)
    tmp = Path(tempfile.mkdtemp(prefix="photos-capture-"))
    try:
        for suffix in ("", "-wal", "-shm"):
            src = Path(str(db) + suffix)
            if src.exists():
                shutil.copy2(src, tmp / src.name)
    except (PermissionError, OSError) as e:
        shutil.rmtree(tmp, ignore_errors=True)
        sys.exit("Can't read %s (%s).\n%s" % (db, e, FDA_MESSAGE))
    copy = tmp / db.name
    try:
        con = sqlite3.connect("file:%s?mode=ro" % copy, uri=True)
        con.execute("SELECT 1 FROM ZASSET LIMIT 1")
    except sqlite3.OperationalError:
        con = sqlite3.connect(str(copy))    # ro open can fail on WAL recovery; the copy is ours
    return con, tmp


def query_screenshots(con, cutoff, extra_uuids):
    """All non-trashed, non-hidden screenshots added after cutoff (plus retry UUIDs), oldest first."""
    sql = """
        SELECT a.ZUUID, a.ZDIRECTORY, a.ZFILENAME, a.ZDATECREATED,
               COALESCE(a.ZADDEDDATE, a.ZDATECREATED) AS added,
               aa.ZORIGINALFILENAME, aa.ZTIMEZONEOFFSET
        FROM ZASSET a
        LEFT JOIN ZADDITIONALASSETATTRIBUTES aa ON aa.ZASSET = a.Z_PK
        WHERE a.ZKINDSUBTYPE = ? AND a.ZTRASHEDSTATE = 0 AND a.ZHIDDEN = 0
          AND (COALESCE(a.ZADDEDDATE, a.ZDATECREATED) > ? OR a.ZUUID IN (%s))
        ORDER BY added ASC
    """ % (",".join("?" * len(extra_uuids)) or "''")
    try:
        return con.execute(sql, [SUBTYPE_SCREENSHOT, cutoff, *extra_uuids]).fetchall()
    except sqlite3.OperationalError as e:
        sys.exit("Photos.sqlite schema mismatch (%s) — macOS may have migrated the library; "
                 "re-verify the ZASSET query in %s." % (e, SCRIPT.name))


def resolve_source(library, uuid, directory, filename):
    """Best local file for an asset: original, else largest derivative. → (path|None, kind)."""
    if directory and filename:
        orig = library / "originals" / directory / filename
        if orig.exists():
            return orig, "original"
    deriv_dir = library / "resources" / "derivatives" / (directory or "")
    if deriv_dir.is_dir():
        cands = [p for p in deriv_dir.glob(uuid + "_*")
                 if p.suffix.lower() in (".jpeg", ".jpg", ".png", ".heic")]
        if cands:
            best = max(cands, key=lambda p: p.stat().st_size)
            if best.stat().st_size >= DERIV_FLOOR:      # below this it's a thumbnail — retry later instead
                return best, "derivative"
    return None, "missing"


# ── state (incremental watermark; created fresh on first run) ─────────────────
def state_path():
    return VAULT / STATE_REL


def load_state():
    path = state_path()
    if not path.exists():
        return {"watermark_added": None, "seen": {}, "missing": []}
    try:
        return json.loads(path.read_text())
    except Exception:
        return {"watermark_added": None, "seen": {}, "missing": []}


def save_state(state):
    state["last_run"] = dt.datetime.now().isoformat(timespec="seconds")
    state["host"] = socket.gethostname()
    if len(state["seen"]) > SEEN_CAP:           # keep the newest entries only
        state["seen"] = dict(sorted(state["seen"].items(), key=lambda kv: kv[1])[-SEEN_CAP:])
    state["missing"] = state["missing"][-MISSING_CAP:]
    path = state_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(state, indent=1) + "\n")
    os.replace(tmp, path)


# ── capture ───────────────────────────────────────────────────────────────────
def shot_filename(t_local, original_name, uuid, ext):
    stem = Path(original_name).stem if original_name else uuid[:8]
    return "Screenshot %s (%s)%s" % (t_local.strftime("%Y-%m-%d %H%M"), stem, ext)


def export_file(src, dest, taken_ts):
    """Copy src → dest (HEIC → JPEG via sips so agents can read it); stamp mtime = taken."""
    if src.suffix.lower() == ".heic":
        dest = dest.with_suffix(".jpg")
        r = subprocess.run(["/usr/bin/sips", "-s", "format", "jpeg", str(src), "--out", str(dest)],
                           capture_output=True)
        if r.returncode != 0:                   # keep the heic rather than lose the shot
            dest = dest.with_suffix(".heic")
            shutil.copy2(src, dest)
    else:
        shutil.copy2(src, dest)
    os.utime(dest, (taken_ts, taken_ts))
    return dest


def write_inbox_item(item_path, batch_rel, shots, window_desc, now):
    """One `type: inbox-item` note in the product's queue shape (see the Agent/inbox
    folder note): frontmatter the drain can glob, a `## Manifest` stub, and the
    captured images embedded under `## Raw content`."""
    lines = [
        "---",
        "type: inbox-item",
        "status: pending",
        "captured: %s" % now.strftime("%Y-%m-%d %H:%M"),
        "label: screenshots %s" % now.strftime("%Y-%m-%d"),
        "source: screenshots",
        "author: alexandria",
        "count: %d" % len(shots),
        "batch: %s" % batch_rel,
        "---",
        "",
        "# Screenshots capture — %s" % now.strftime("%Y-%m-%d"),
        "",
        "%d new screenshot(s) exported from the macOS Photos library (%s). "
        "Captured by `alexandria_vault.engine.screenshots_capture`; triage per "
        "[[Screenshots]]." % (len(shots), window_desc),
        "",
        "## Manifest",
        "",
        "*Best-effort at capture — the real routing happens at triage: read each image, split the "
        "valuable content out per [[AGENTS#Ingesting and filing content|the routing rules]], drop "
        "the noise. Watch for sensitive captures (banking screens, IDs, credentials) — never "
        "transcribe raw values. Delete this item + the batch folder once filed.*",
        "",
        "| # | Screenshot | Taken | Source | Route |",
        "|---|---|---|---|---|",
    ]
    for i, s in enumerate(shots, 1):
        lines.append("| %d | [[%s]] | %s | %s | _pending triage_ |" % (i, s["file"], s["taken"], s["source"]))
    lines += ["", "## Raw content", ""]
    for s in shots:
        origin = ("device original `%s`" % s["original_name"]) if s["original_name"] else s["uuid"][:8]
        lines += ["![[%s]]" % s["file"],
                  "*taken %s · %s · %s · %s*" % (s["taken"], origin, s["source"], s["size"]), ""]
    item_path.write_text("\n".join(lines))


def unique_item_path(inbox, now):
    """`YYYY-MM-DD-HHmm screenshots capture.md`, never clobbering an earlier batch."""
    path = inbox / ("%s screenshots capture.md" % now.strftime("%Y-%m-%d-%H%M"))
    if path.exists():                           # second run in the same minute
        path = inbox / ("%s screenshots capture.md" % now.strftime("%Y-%m-%d-%H%M%S"))
    n = 2
    while path.exists():
        path = inbox / ("%s-%d screenshots capture.md" % (now.strftime("%Y-%m-%d-%H%M%S"), n))
        n += 1
    return path


# ── coverage reporting (stderr, so --json stdout stays parseable) ────────────
def warn(msg):
    print("screenshots_capture: %s" % msg, file=sys.stderr)


def report_coverage(window_desc, found, captured, missing, remaining, dry_run):
    """State what the run actually covered — window scanned, found N new, captured M —
    so a capped or file-less batch can't pass for 'nothing new'."""
    warn("window %s → found %d new · %s %d · %d not-local (retry queued) · %d remaining beyond --limit"
         % (window_desc, found, "would capture" if dry_run else "captured",
            captured, missing, remaining))


def main():
    ap = argparse.ArgumentParser(
        description="Capture new Photos screenshots into Agent/inbox/ (read-only against the library).")
    ap.add_argument("--since", default=None,
                    help="explicit window on added-date: 3d / 2w / today / yesterday / "
                         "YYYY-MM-DD / all (default: since last run; first run: last %s)"
                         % DEFAULT_FIRST_WINDOW)
    ap.add_argument("--limit", type=int, default=DEFAULT_LIMIT,
                    help="max shots per batch (default %d; 0 = no cap); excess is "
                         "reported as remaining and picked up next run" % DEFAULT_LIMIT)
    ap.add_argument("--dry-run", action="store_true", help="list what would be captured; write nothing")
    ap.add_argument("--json", action="store_true", help="machine-readable report")
    ap.add_argument("--library", type=Path, default=None,
                    help="override the .photoslibrary path (default: config screenshots.library, "
                         "else ~/Pictures/Photos Library.photoslibrary)")
    args = ap.parse_args()
    library = args.library or LIBRARY

    # This capture WRITES into the vault (inbox + state), so the lenient cwd
    # fallback the read-only engine modules tolerate is not acceptable here:
    # never write into a directory that isn't actually a vault.
    if not is_vault(VAULT):
        sys.exit("%s is not an Alexandria vault (no Agent/config.json). Pass --vault PATH "
                 "or run from inside the vault — this capture writes into Agent/inbox/ "
                 "and refuses to guess where." % VAULT)

    now = dt.datetime.now()
    inbox = VAULT / INBOX_REL
    state = load_state()

    if args.since:
        cutoff, window_desc = parse_since(args.since), "window: --since %s" % args.since
    elif state["watermark_added"] is not None:
        cutoff, window_desc = state["watermark_added"], "new since last run"
    else:
        cutoff, window_desc = parse_since(DEFAULT_FIRST_WINDOW), "first run: last %s" % DEFAULT_FIRST_WINDOW

    con, tmp = snapshot_and_open(library)
    try:
        rows = query_screenshots(con, cutoff, state.get("missing", []))
    finally:
        con.close()
        shutil.rmtree(tmp, ignore_errors=True)

    candidates = [r for r in rows if r[0] not in state["seen"]]

    shots, missing = [], []
    max_added = state["watermark_added"] or 0.0
    processed = 0
    batch_dir = inbox / "Files" / ("Screenshots %s" % now.strftime("%Y-%m-%d-%H%M"))
    for uuid, directory, filename, created, added, orig_name, tz_off in candidates:
        if args.limit and len(shots) >= args.limit:     # limit counts *captured* shots — not-local rows never wedge a batch
            break
        processed += 1
        max_added = max(max_added, added or 0.0)
        t = taken_local(created or added, tz_off)
        src, kind = resolve_source(library, uuid, directory, filename)
        if src is None:
            missing.append({"uuid": uuid, "taken": t.strftime("%Y-%m-%d %H:%M %z"),
                            "original_name": orig_name})
            continue
        ext = ".jpg" if src.suffix.lower() in (".jpeg", ".jpg", ".heic") else src.suffix.lower()
        name = shot_filename(t, orig_name, uuid, ext)
        shot = {"uuid": uuid, "file": name, "taken": t.strftime("%Y-%m-%d %H:%M %z"),
                "source": "full-res derivative" if kind == "derivative" else kind,
                "original_name": orig_name, "size": fmt_bytes(src.stat().st_size),
                "src": str(src)}
        if not args.dry_run:
            batch_dir.mkdir(parents=True, exist_ok=True)
            dest = export_file(src, batch_dir / name, cocoa_to_unix(created or added))
            shot["file"], shot["size"] = dest.name, fmt_bytes(dest.stat().st_size)
        shots.append(shot)

    item_path = None
    if shots and not args.dry_run:
        item_path = unique_item_path(inbox, now)
        write_inbox_item(item_path, batch_dir.relative_to(VAULT).as_posix(),
                         shots, window_desc, now)

    remaining = len(candidates) - processed

    if not args.dry_run:
        for s in shots:
            state["seen"][s["uuid"]] = now.strftime("%Y-%m-%d")
        state["missing"] = [m["uuid"] for m in missing]
        state["watermark_added"] = max_added if processed else (state["watermark_added"] or max_added)
        save_state(state)

    report = {
        "generated": now.isoformat(timespec="seconds"),
        "host": socket.gethostname(),
        "library": str(library),
        "mode": "dry-run" if args.dry_run else "capture",
        "window": window_desc,
        "captured": [{k: v for k, v in s.items() if k != "src"} for s in shots],
        "missing": missing,
        "remaining": remaining,
        "batch_dir": str(batch_dir) if shots and not args.dry_run else None,
        "inbox_item": str(item_path) if item_path else None,
    }
    report_coverage(window_desc, len(candidates), len(shots), len(missing), remaining, args.dry_run)
    if args.json:
        print(json.dumps(report, indent=1))
        return

    verb = "would capture" if args.dry_run else "captured"
    print("Screenshots — %s %d (%s)" % (verb, len(shots), window_desc))
    for s in shots:
        print("  %-44s %s · %s · %s" % (s["file"], s["taken"], s["source"], s["size"]))
    if missing:
        print("  ! %d shot(s) have no local file yet (queued to retry next run):" % len(missing))
        for m in missing:
            print("    %s taken %s" % (m["original_name"] or m["uuid"][:8], m["taken"]))
    if remaining:
        print("  … %d more pending beyond --limit; run again (watermark held) or raise --limit" % remaining)
    if item_path:
        print("Inbox item: %s" % item_path)
        print("Next: commit the capture, verify `git show --stat HEAD` lists every image, then triage.")
    elif not shots:
        print("Nothing new — the inbox already has everything in this window.")


if __name__ == "__main__":
    main()
