#!/usr/bin/env python3
"""PageHawk AI-Organize — content-aware folder organization, 100% ON-DEVICE (Ollama).

The wedge (vs the field): Finder/Sparkle rename by FILENAME, Hazel can't read PDFs,
Acrobat can't rename by content, DEVONthink only sorts into a taxonomy you already
built. PageHawk reads what each document SAYS, proposes a clean name + a destination
folder + tags + a plain-English reason, and can BUILD the taxonomy from scratch.

Everything runs against the local Ollama model, so a folder of confidential
contracts never leaves the Mac (a cloud "zero-retention" tier can be swapped in at
the single `classify()` seam later — the rest of the engine is backend-agnostic).

Nothing is moved or renamed here: this engine only PROPOSES. The app applies the
plan behind a preview + dry-run + durable-undo gate.

CLI:  python pagehawk_organize.py status
      python pagehawk_organize.py plan --folder DIR [--json out.json]
"""
import os, re, json, time
import pagehawk_ai as ai   # text extraction (pdftotext + OCR fallback)
import pagehawk_llm as llm  # tiered classify backend: Apple Foundation Models → API key → Ollama

# Snippet budgets (words). Truncating before the model is the cost/latency lever the
# research called out (sub-$1 / 500 docs on a cloud tier; near-instant on-device).
SNIPPET_WORDS = 480          # first pass: enough to identify most documents
ESCALATE_WORDS = 1500        # low-confidence retry: more context (the on-device "escalate")

# a short, stable category vocabulary the model is nudged toward, but it may add its own
SEED_CATEGORIES = [
    "Contracts", "Financials", "Corporate", "Correspondence",
    "Legal", "Tax", "HR", "Insurance", "Medical", "Receipts",
    "Reports", "Presentations", "Forms", "Assets", "Superseded", "Misc",
]
_DOC_EXTS = (".pdf", ".doc", ".docx", ".rtf", ".txt", ".md", ".markdown", ".odt", ".html", ".htm")
_ILLEGAL = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
_WS = re.compile(r"\s+")


# ---------------------------------------------------------------- helpers
def status():
    # The organizer runs on whatever tier is ready; Foundation Models is the zero-download
    # default, so this reports "running" even when Ollama isn't installed.
    return llm.status()


# ---------------------------------------------------------------- deterministic enrichment
# Structured tokens (date, issuer, account) are pulled by rules, NOT the model — the
# on-device model is strong at "what kind of document" but weak at precise numbers/dates,
# so we fill those from the text and let the model supply the descriptive phrase. Mirrors
# the /jp-process-scans naming rules + pagehawk_medical patterns.
_MONTHS = ("january february march april may june july august september october "
           "november december").split()
_MONTH_IDX = {m: i + 1 for i, m in enumerate(_MONTHS)}
_MONTH_IDX.update({m[:3]: i + 1 for i, m in enumerate(_MONTHS)})
_GENERIC_NAME = re.compile(
    r"^(the\s+)?(invoice|statement|receipt|letter|report|contract|agreement|form|notice|"
    r"bill|memo|document|scan|account statement|statement of account|bank statement)s?$", re.I)

# Document-type detection from the text — the label that makes a name scannable ("Invoice",
# "EOB") when the model returns something bare like "Document". Ordered: first hit wins, so
# put the more specific phrases first. Detected from the doc's own words, never guessed.
_DOCTYPES = [
    ("Explanation of Benefits", r"explanation of benefits|\beob\b"),
    ("Statement of Account", r"statement of account"),
    ("Bank Statement", r"\b(bank|account|checking|savings)\s+statement\b|statement period|closing balance"),
    ("Invoice", r"\binvoice\b"),
    ("Receipt", r"\breceipt\b|amount paid|paid in full"),
    ("Purchase Order", r"purchase order\b|\bp\.?o\.?\s*(no|number|#)"),
    ("Remittance", r"remittance advice|remittance\b"),
    ("Tax Form", r"\b(w-?2|w-?9|1099|1040|k-?1|schedule [a-z])\b|internal revenue service|form \d{3,4}"),
    ("Explanation of Charges", r"explanation of charges"),
    ("Policy", r"\bpolicy\b|declarations page|coverage summary"),
    ("Agreement", r"\bagreement\b|master service agreement|terms and conditions"),
    ("Contract", r"\bcontract\b"),
    ("Lease", r"\blease\b|rental agreement"),
    ("Letter", r"\bdear\s+[A-Z]|sincerely,|regards,"),
    ("Notice", r"\bnotice\b|please be advised"),
    ("Report", r"\breport\b"),
]
_DOCTYPES = [(n, re.compile(p, re.I)) for n, p in _DOCTYPES]


def _extract_doctype(text):
    t = text[:3000]
    for name, rx in _DOCTYPES:
        if rx.search(t):
            return name
    return None


def _extract_date(text):
    """A single representative date as YYYY-MM-DD (or YYYY-MM), preferring a labeled
    statement/invoice/closing date, else the first plausible date in the text."""
    t = text[:4000]
    # ISO first (unambiguous)
    m = re.search(r"\b(20\d{2})-(\d{1,2})-(\d{1,2})\b", t)
    if m:
        y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
        if 1 <= mo <= 12 and 1 <= d <= 31:
            return "%04d-%02d-%02d" % (y, mo, d)
    # "March 14, 2026" / "14 March 2026" / "March 2026"
    m = re.search(r"\b([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(20\d{2})\b", t)
    if m and m.group(1).lower() in _MONTH_IDX:
        return "%s-%02d-%02d" % (m.group(3), _MONTH_IDX[m.group(1).lower()], int(m.group(2)))
    m = re.search(r"\b(\d{1,2})\s+([A-Za-z]{3,9})\.?\s+(20\d{2})\b", t)
    if m and m.group(2).lower() in _MONTH_IDX:
        return "%s-%02d-%02d" % (m.group(3), _MONTH_IDX[m.group(2).lower()], int(m.group(1)))
    m = re.search(r"\b([A-Za-z]{3,9})\.?\s+(20\d{2})\b", t)
    if m and m.group(1).lower() in _MONTH_IDX:
        return "%s-%02d" % (m.group(2), _MONTH_IDX[m.group(1).lower()])
    # US numeric m/d/yyyy
    m = re.search(r"\b(\d{1,2})/(\d{1,2})/(20\d{2})\b", t)
    if m:
        mo, d = int(m.group(1)), int(m.group(2))
        if 1 <= mo <= 12 and 1 <= d <= 31:
            return "%s-%02d-%02d" % (m.group(3), mo, d)
    return None


def _extract_account(text):
    """A trailing account/reference number, if the document labels one (last 4-6 digits)."""
    m = re.search(r"(?:account|acct|a/c|card|policy|member|ref(?:erence)?)\.?\s*"
                  r"(?:no\.?|number|#|ending(?:\s+in)?|x+)?\s*[:#]?\s*[xX*•\-]*\s*(\d{4,6})\b",
                  text[:2500], re.I)
    return m.group(1) if m else None


def _extract_issuer(text):
    """A best-effort issuer/organization from the letterhead — the first short, proper-noun
    line near the top that isn't a document-type header. Conservative: returns None when
    it can't find a clean candidate (a wrong issuer is worse than none)."""
    for raw in text[:1200].splitlines():
        line = raw.strip().strip(".,:;-—•|")
        if not (2 <= len(line) <= 46):
            continue
        low = line.lower()
        if _GENERIC_NAME.match(low) or low in ("invoice", "statement", "receipt"):
            continue
        if re.search(r"\d", line) and not re.search(r"(inc|llc|corp|co|bank|group|ltd)", low):
            continue   # a line with numbers but no company marker is probably data, not a name
        words = line.split()
        if not (1 <= len(words) <= 6):
            continue
        titleish = sum(1 for w in words if w[:1].isupper()) >= max(1, len(words) - 1)
        allcaps = line.isupper() and len(line) <= 40
        if titleish or allcaps:
            return re.sub(r"\s+", " ", line.title() if allcaps else line)
    return None


def _enrich_name(name, text):
    """Fill the structured tokens the model tends to omit. Three moves, each guarded so a name
    the model already got right is never over-stuffed:
      1. If the model returned nothing usable or a bare document-type word, REBUILD the name
         from the document's own tokens: <issuer> - <doc type> - <date>.
      2. Else if the name is a bare generic type, prepend the issuer.
      3. Always append the document date when the name carries no year.
    Everything comes from the text via rules, never guessed."""
    n = (name or "").strip()
    issuer = _extract_issuer(text)
    doctype = _extract_doctype(text)
    date = _extract_date(text)
    low = n.lower()
    generic = (not n) or _GENERIC_NAME.match(low) is not None or low in ("document", "scan")
    # 1. Rebuild a weak/empty name from structured tokens (only if we actually have signal).
    if generic and (issuer or doctype):
        parts = [p for p in (issuer, doctype or (n if n else None), date) if p]
        if parts:
            n = " - ".join(dict.fromkeys(parts))   # de-dupe while preserving order
            return n
    if not n:
        return n
    # 2. Prepend issuer to a still-generic name.
    if issuer and _GENERIC_NAME.match(n.lower()) and issuer.lower() not in n.lower():
        n = "%s - %s" % (issuer, n)
    # 3. Append the date when no year is present yet.
    if date and date not in n and not re.search(r"\b20\d{2}\b", n):
        n = "%s - %s" % (n, date)
    return n


def _strip_json(raw):
    """Pull the JSON object out of a model reply that may be wrapped in ``` fences or prose."""
    s = (raw or "").strip()
    if s.startswith("```"):
        s = s.strip("`")
        if s[:4].lower() == "json":
            s = s[4:]
    a, b = s.find("{"), s.rfind("}")
    return s[a:b + 1] if a != -1 and b != -1 and b > a else s


def _snippet(path, max_words):
    """First-N-words of a document's text (native layer, OCR fallback for scans)."""
    try:
        pages = ai.extract_pages(path)
    except Exception:
        pages = [""]
    words = _WS.sub(" ", "\n".join(pages)).split()
    return " ".join(words[:max_words]).strip(), len(pages)


def _safe_name(name, ext):
    """Clean a proposed filename: strip illegal chars, collapse whitespace, keep ext."""
    name = (name or "").replace("—", " - ").replace("–", "-")   # no em/en dashes
    name = _ILLEGAL.sub("", name).strip().strip(".")
    name = _WS.sub(" ", name)
    if not name:
        name = "Document"
    if len(name) > 120:
        name = name[:120].rstrip()
    if ext and not name.lower().endswith("." + ext.lower()):
        name = name + "." + ext
    return name


def _clean_folder(folder):
    if not folder:
        return None
    folder = _ILLEGAL.sub("", folder).strip().strip("/").strip()
    folder = _WS.sub(" ", folder)
    return folder or None


_CONF = {"high": "h", "med": "m", "medium": "m", "low": "l"}


# ---------------------------------------------------------------- classification (the swappable seam)
def classify(old_name, snippet, taxonomy, model, timeout=120):
    """Propose {name, folder, confidence, why, tags} for ONE document from its text.
    This is the single backend seam: today it calls local Ollama; a cloud zero-retention
    tier would replace only this function body."""
    if not snippet or len(snippet) < 3:
        return {"name": None, "folder": None, "confidence": "low",
                "why": "No readable text (likely a scan with no OCR text or an image).",
                "tags": [], "empty": True}
    cats = ", ".join(taxonomy or SEED_CATEGORIES)
    prompt = (
        "You are organizing a messy folder of documents on the user's Mac, fully offline.\n"
        "Read the document text below and propose how to file it. Judge ONLY from the\n"
        "content, never the current filename.\n\n"
        "Return STRICT JSON, no prose, exactly this shape:\n"
        '{"name":"a clear descriptive filename WITHOUT extension (parties, doc type, date/number if present)",\n'
        ' "folder":"one destination category",\n'
        ' "confidence":"high|med|low",\n'
        ' "why":"one sentence citing what the DOCUMENT says (a title, party, amount, date)",\n'
        ' "tags":["2-4","short","lowercase"]}\n\n'
        "Prefer these categories when one fits (add a new one only if none do): " + cats + "\n"
        "Rules: name like 'Acme Corp — Master Service Agreement — 2026-03-14'. Use 'high' only\n"
        "when the document clearly states its type/parties; 'low' when the text is too thin or\n"
        "ambiguous. Keep folder to a short noun phrase.\n\n"
        "CURRENT FILENAME (context only, may be meaningless): " + old_name + "\n\n"
        "DOCUMENT TEXT:\n" + snippet + "\n\nJSON:")
    try:
        raw = llm.generate(
            prompt,
            instructions="You are a precise document-filing assistant. Return ONLY strict "
                         "JSON matching the requested shape — no prose, no code fences.",
            want_json=True, num_predict=400, temperature=0.0, timeout=timeout)
        d = json.loads(_strip_json(raw) or "{}")
    except Exception as e:
        return {"name": None, "folder": None, "confidence": "low",
                "why": "On-device AI error: %s" % e, "tags": [], "error": True}
    conf = _CONF.get(str(d.get("confidence", "")).lower().strip(), "m")
    tags = d.get("tags") or []
    if not isinstance(tags, list):
        tags = [str(tags)]
    tags = [str(t).strip().lower() for t in tags if str(t).strip()][:4]
    # Deterministic enrichment: fill the precise date/issuer tokens the on-device model
    # tends to omit, from the document text itself (no model, no guessing).
    name = (d.get("name") or "").strip() or None
    if name:
        name = _enrich_name(name, snippet)
    return {"name": name,
            "folder": _clean_folder(d.get("folder")),
            "confidence": conf,
            "why": (d.get("why") or "").strip(),
            "tags": tags}


# ---------------------------------------------------------------- plan
def _list_docs(folder):
    return sorted(f for f in os.listdir(folder)
                  if f.lower().endswith(_DOC_EXTS) and not f.startswith("."))


def _change_type(old, new_name, new_folder):
    renamed = bool(new_name) and new_name != old
    moved = bool(new_folder)
    if renamed and moved:
        return "both"
    if moved:
        return "move"
    return "rename"


def _dedupe_names(items):
    """Guarantee no two proposals collide inside the same destination folder."""
    seen = {}
    for it in items:
        key = ((it.get("folder") or "").lower(), (it.get("name") or "").lower())
        if not it.get("name"):
            continue
        if key in seen:
            base, dot, ext = it["name"].rpartition(".")
            stem = base if dot else it["name"]
            n = seen[key] = seen[key] + 1
            it["name"] = "%s (%d)%s" % (stem, n, ("." + ext) if dot else "")
        else:
            seen[key] = 1


def build_plan(folder, timeout=120, log=None):
    """Two-pass content-aware plan for a folder. Pass 1 classifies every doc from a short
    snippet and grows the taxonomy from what's actually there; pass 2 re-reads any
    low-confidence doc with more context against the settled taxonomy (the on-device
    analog of 'escalate the hard ones')."""
    t0 = time.time()
    # Backend-agnostic: Foundation Models (default), a BYO API key, or Ollama — whichever is
    # ready. `model` stays for the Ollama tier but classify() picks the tier via pagehawk_llm.
    st = llm.status()
    if not st.get("running"):
        return {"error": ("No on-device AI is available. Turn on Apple Intelligence "
                          "(macOS 26), add an API key in Settings, or run Ollama."),
                "files": []}
    model = None
    if not os.path.isdir(folder):
        return {"error": "Not a folder: %s" % folder, "files": []}
    files = _list_docs(folder)
    if not files:
        return {"error": "No documents to organize in this folder.", "files": []}

    def emit(m):
        if log:
            log(m)

    # ---- pass 1: classify each doc, grow the taxonomy from what's seen ----
    taxonomy = list(SEED_CATEGORIES)
    results = []
    for i, f in enumerate(files):
        emit("Reading %d/%d — %s" % (i + 1, len(files), f))
        path = os.path.join(folder, f)
        ext = f.rsplit(".", 1)[-1] if "." in f else ""
        snip, npages = _snippet(path, SNIPPET_WORDS)
        c = classify(f, snip, taxonomy, model, timeout=timeout)
        if c.get("folder") and c["folder"] not in taxonomy:
            taxonomy.append(c["folder"])
        results.append({"old": f, "ext": ext, "pages": npages, "path": path,
                        "snip_words": len(snip.split()), "c": c})

    # ---- pass 2: escalate low-confidence docs with more context + settled taxonomy ----
    live_tax = _rank_taxonomy(results)
    for r in results:
        # Only escalate when there is MORE text to read (pass 1 truncated the doc). A thin
        # doc whose whole text already fit the first snippet won't get clearer on a re-read.
        more_context = r["snip_words"] >= SNIPPET_WORDS
        if r["c"].get("confidence") == "l" and not r["c"].get("empty") and more_context:
            emit("Taking a closer look — %s" % r["old"])
            snip, _ = _snippet(r["path"], ESCALATE_WORDS)
            c2 = classify(r["old"], snip, live_tax, model, timeout=timeout)
            # keep pass-2 only if it did not regress to low with no better reason
            if c2.get("name") and c2.get("confidence") != "l":
                r["c"] = c2
                if c2.get("folder") and c2["folder"] not in live_tax:
                    live_tax.append(c2["folder"])

    # ---- assemble the plan ----
    plan_files = []
    for r in results:
        c = r["c"]
        name = _safe_name(c.get("name"), r["ext"]) if c.get("name") else r["old"]
        folder_c = c.get("folder")
        plan_files.append({
            "old": r["old"],
            "name": name,
            "folder": folder_c,
            "conf": c.get("confidence", "m"),
            "why": c.get("why", ""),
            "tags": c.get("tags", []),
            "type": _change_type(r["old"], name, folder_c),
            "pages": r["pages"],
            "flag": c.get("confidence") == "l" or bool(c.get("empty")),
        })
    _dedupe_names(plan_files)

    used = sorted({pf["folder"] for pf in plan_files if pf["folder"]})
    stats = {
        "documents": len(plan_files),
        "renamed": sum(1 for p in plan_files if p["type"] != "move"),
        "moved": sum(1 for p in plan_files if p["folder"]),
        "folders": len(used),
        "flagged": sum(1 for p in plan_files if p["flag"]),
        "ms": int((time.time() - t0) * 1000),
    }
    return {"folder": folder, "model": model, "taxonomy": used,
            "files": plan_files, "stats": stats}


def _rank_taxonomy(results):
    """The categories actually proposed, most-used first, then the unused seeds."""
    from collections import Counter
    seen = Counter(r["c"]["folder"] for r in results if r["c"].get("folder"))
    ranked = [c for c, _ in seen.most_common()]
    for s in SEED_CATEGORIES:
        if s not in ranked:
            ranked.append(s)
    return ranked


# ---------------------------------------------------------------- apply + undo (the destructive-adjacent part)
# Applying is MOVE + RENAME only, never delete ("trash = move, never delete"). Every
# change is written to a durable undo manifest under <folder>/.pagehawk-organize/ so one
# click restores every original name and location, for as long as the record is kept.
ORG_DIR = ".pagehawk-organize"


def _unique_path(dest_dir, filename):
    """A destination path that never overwrites an existing file (Finder-style ' (2)')."""
    base = os.path.join(dest_dir, filename)
    if not os.path.exists(base):
        return base
    stem, dot, ext = filename.rpartition(".")
    stem = stem if dot else filename
    ext = ("." + ext) if dot else ""
    n = 2
    while True:
        cand = os.path.join(dest_dir, "%s (%d)%s" % (stem, n, ext))
        if not os.path.exists(cand):
            return cand
        n += 1


def _write_manifest(folder, moves, created):
    """Persist the from→to record so the batch is fully reversible."""
    if not moves and not created:
        return ""
    d = os.path.join(folder, ORG_DIR)
    try:
        os.makedirs(d, exist_ok=True)
    except Exception:
        return ""
    path = os.path.join(d, "undo-%s.json" % time.strftime("%Y%m%d-%H%M%S"))
    payload = {"app": "PageHawk AI-Organize", "created": time.strftime("%Y-%m-%d %H:%M:%S"),
               "folder": folder, "moves": moves, "folders": created}
    try:
        open(path, "w").write(json.dumps(payload, indent=2))
    except Exception:
        return ""
    return path


def apply_plan(folder, rows, log=None):
    """Apply the approved rows: create destination folders, then move/rename each file into
    <folder>/<row.folder>/<row.name>. Moves only (never deletes), collision-safe, and records
    a durable undo manifest. rows = [{old, name, folder}]."""
    def emit(m):
        if log:
            log(m)
    folder = os.path.abspath(folder)
    if not os.path.isdir(folder):
        return {"error": "Not a folder: %s" % folder}
    moves, created, errors, made = [], [], [], set()
    for r in rows or []:
        old = r.get("old")
        if not old:
            continue
        src = os.path.join(folder, old)
        if not os.path.isfile(src):
            errors.append({"old": old, "error": "no longer in the folder"})
            continue
        ext = old.rsplit(".", 1)[-1] if "." in old else ""
        name = _safe_name(r.get("name") or old, ext)
        dest_dir = folder
        sub = _clean_folder(r.get("folder"))
        if sub:
            dest_dir = os.path.join(folder, sub)
            if not os.path.isdir(dest_dir):
                try:
                    os.makedirs(dest_dir, exist_ok=True)
                    if dest_dir not in made:
                        made.add(dest_dir)
                        created.append(dest_dir)
                except Exception as e:
                    errors.append({"old": old, "error": "could not create folder: %s" % e})
                    continue
        dest = _unique_path(dest_dir, name)
        if os.path.abspath(dest) == os.path.abspath(src):
            continue                                   # no-op (already named + placed right)
        try:
            os.rename(src, dest)                        # fast path (same volume)
        except OSError:
            import shutil
            try:
                shutil.move(src, dest)                  # cross-volume fallback
            except Exception as e:
                errors.append({"old": old, "error": "could not move: %s" % e})
                continue
        moves.append({"from": src, "to": dest})
        emit("Filed %s" % os.path.basename(dest))
    manifest = _write_manifest(folder, moves, created)
    return {"folder": folder, "applied": len(moves), "folders_created": len(created),
            "errors": errors, "manifest": manifest, "moved": moves}


def undo(manifest, log=None):
    """Reverse an applied batch from its manifest: move every file back to its original path,
    then remove any now-empty folders the batch created. Non-destructive."""
    def emit(m):
        if log:
            log(m)
    if not manifest or not os.path.isfile(manifest):
        return {"error": "Undo record not found (it may have already been undone)."}
    try:
        m = json.loads(open(manifest).read())
    except Exception as e:
        return {"error": "Could not read the undo record: %s" % e}
    restored, errors = 0, []
    for mv in reversed(m.get("moves", [])):
        frm, to = mv.get("from"), mv.get("to")         # to = where it is now; frm = original
        if not frm or not to:
            continue
        if not os.path.exists(to):
            errors.append({"file": to, "error": "not found (moved or deleted since)"})
            continue
        parent = os.path.dirname(frm)
        try:
            os.makedirs(parent, exist_ok=True)
        except Exception:
            pass
        target = frm if not os.path.exists(frm) else _unique_path(parent, os.path.basename(frm))
        try:
            os.rename(to, target)
        except OSError:
            import shutil
            try:
                shutil.move(to, target)
            except Exception as e:
                errors.append({"file": to, "error": str(e)})
                continue
        restored += 1
        emit("Restored %s" % os.path.basename(target))
    removed = 0
    for d in sorted(m.get("folders", []), key=len, reverse=True):   # deepest first
        try:
            if os.path.isdir(d) and not os.listdir(d):
                os.rmdir(d)
                removed += 1
        except Exception:
            pass
    if not errors:
        try:
            os.remove(manifest)                        # clean, fully-undone batch leaves no record
        except Exception:
            pass
    return {"restored": restored, "folders_removed": removed, "errors": errors}


# ---------------------------------------------------------------- CLI
if __name__ == "__main__":
    import argparse, sys
    ap = argparse.ArgumentParser(description="PageHawk on-device AI folder organizer")
    ap.add_argument("cmd", choices=["status", "plan", "apply", "undo"])
    ap.add_argument("--folder")
    ap.add_argument("--json", help="also write the plan JSON to this path")
    ap.add_argument("--plan", help="apply: path to the approved plan JSON ({folder, files:[...]})")
    ap.add_argument("--manifest", help="undo: path to the undo manifest JSON")
    ap.add_argument("--quiet", action="store_true")
    a = ap.parse_args()
    logger = (lambda m: None) if a.quiet else (lambda m: sys.stderr.write("· " + m + "\n"))

    if a.cmd == "status":
        print(json.dumps(status())); sys.exit(0)

    if a.cmd == "apply":
        if not a.plan or not os.path.isfile(a.plan):
            print(json.dumps({"error": "Approved plan not found: %s" % a.plan})); sys.exit(1)
        data = json.loads(open(a.plan).read())
        rows = data.get("files") if isinstance(data, dict) else data
        folder = a.folder or (data.get("folder") if isinstance(data, dict) else None)
        print(json.dumps(apply_plan(folder, rows or [], log=logger))); sys.exit(0)

    if a.cmd == "undo":
        print(json.dumps(undo(a.manifest, log=logger))); sys.exit(0)

    # plan
    if not a.folder or not os.path.isdir(a.folder):
        print(json.dumps({"error": "Folder not found: %s" % a.folder})); sys.exit(1)
    plan = build_plan(a.folder, log=logger)
    out = json.dumps(plan, indent=2)
    if a.json:
        open(a.json, "w").write(out)
    print(out)
