#!/usr/bin/env python3
"""
Sync HyperFrames catalog source code to local cache.

npm install: full parallel download (3-5 concurrent)
npm update : incremental — only download new/missing items

Output: ~/.claude/skills/hyper-animator/references/source-cache/
         blocks/<id>.html + components/<id>.html

Usage:
    python3 scripts/sync-catalog.py          # incremental (skip existing)
    python3 scripts/sync-catalog.py --full   # full redownload
    python3 scripts/sync-catalog.py --check  # report cache status only
"""
import argparse
import concurrent.futures
import json
import os
import pathlib
import subprocess
import sys

# Output to stderr so npm postinstall doesn't suppress it
def log(*args, **kwargs):
    print(*args, file=sys.stderr, flush=True, **kwargs)
import tempfile
import time

CACHE_DIR = pathlib.Path.home() / ".claude" / "skills" / "hyper-animator" / "references" / "source-cache"
CATALOG_MAP = pathlib.Path.home() / ".claude" / "skills" / "hyper-animator" / "references" / "hyperframes-catalog-map.json"
MANIFEST_FILE = CACHE_DIR / "manifest.json"


def load_catalog():
    with open(CATALOG_MAP) as f:
        return json.load(f)


def load_manifest():
    if MANIFEST_FILE.exists():
        with open(MANIFEST_FILE) as f:
            return json.load(f)
    return {"items": {}, "last_sync": None}


def save_manifest(manifest):
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    manifest["last_sync"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
    with open(MANIFEST_FILE, 'w') as f:
        json.dump(manifest, f, indent=2)


def download_item(item_id, item_type, install_command):
    """Download a single item via hyperframes add. Returns (id, success, path)."""
    path = CACHE_DIR / f"{item_type}s" / f"{item_id}.html"
    if path.exists():
        return (item_id, "cached", str(path))

    with tempfile.TemporaryDirectory() as tmpdir:
        try:
            subprocess.run(
                ["hyperframes", "add", item_id, "--dir", tmpdir, "--no-clipboard", "--json"],
                capture_output=True, text=True, timeout=60,
                cwd=tmpdir
            )

            # Find the downloaded HTML
            for root, dirs, files in os.walk(tmpdir):
                for fname in files:
                    if fname.endswith('.html'):
                        src = os.path.join(root, fname)
                        dest_dir = CACHE_DIR / f"{item_type}s"
                        dest_dir.mkdir(parents=True, exist_ok=True)
                        dest = dest_dir / f"{item_id}.html"
                        with open(src) as f:
                            content = f.read()
                        with open(dest, 'w') as f:
                            f.write(content)
                        os.chmod(dest, 0o644)
                        return (item_id, "downloaded", str(dest))
        except Exception as e:
            return (item_id, f"error: {e}", None)

    return (item_id, "not_found", None)


# ── Catalog-map merge logic ──────────────────────────────────────────────

REFERENCES_DIR = pathlib.Path.home() / ".claude" / "skills" / "hyper-animator" / "references"


def infer_intent(tags, taxonomies):
    """Map tags to intentDomains based on keyword matching."""
    tag_text = " ".join(t.lower() for t in tags)

    matched = set()
    if any(kw in tag_text for kw in ["code", "terminal", "developer", "sdk", "cli"]):
        matched.add("developer_demo")
    if any(kw in tag_text for kw in ["data", "chart", "statistics", "map", "geo",
                                      "flow", "bubble", "choropleth"]):
        matched.add("data_visualization")
    if any(kw in tag_text for kw in ["social", "tiktok", "reels", "short", "trending"]):
        matched.add("social_media")
    if any(kw in tag_text for kw in ["product", "hero", "feature", "app", "showcase"]):
        matched.add("product_launch")
    if any(kw in tag_text for kw in ["podcast", "interview", "caption", "lower", "subtitle"]):
        matched.add("podcast_interview")
    if any(kw in tag_text for kw in ["outro", "logo", "brand", "endcard"]):
        matched.add("branding_outro")
    if any(kw in tag_text for kw in ["effect", "vfx", "particle", "glitch", "transition",
                                      "wipe", "morph"]):
        matched.add("vfx")
    if any(kw in tag_text for kw in ["device", "phone", "mockup", "screen", "frame"]):
        matched.add("device_showcase")
    if any(kw in tag_text for kw in ["label", "text", "title", "headline", "quote"]):
        matched.add("caption_style")
    if any(kw in tag_text for kw in ["transition", "scene", "cut"]):
        matched.add("transition")
    if any(kw in tag_text for kw in ["grain", "vignette", "overlay", "atmosphere"]):
        matched.add("visual_enhancement")

    if not matched:
        matched.add("visual_enhancement")
    return sorted(matched)


def infer_style(item, scoring_data):
    """Infer style tags from item tags and description."""
    tags = item.get("tags", [])
    desc = item.get("description", "").lower()
    text = " ".join(t.lower() for t in tags) + " " + desc

    matched = []
    if any(kw in text for kw in ["dark", "terminal", "code", "hacker"]):
        matched.append("dark")
    if any(kw in text for kw in ["editorial", "nyt", "news", "magazine", "typography"]):
        matched.append("editorial")
    if any(kw in text for kw in ["cinematic", "film", "movie", "dramatic"]):
        matched.append("cinematic")
    if any(kw in text for kw in ["social", "dynamic", "trending", "upbeat"]):
        matched.append("social_dynamic")
    if any(kw in text for kw in ["cyber", "punk", "neon", "glitch"]):
        matched.append("cyberpunk")
    if any(kw in text for kw in ["apple", "clean", "modern", "minimal"]):
        matched.append("apple_like")
    if any(kw in text for kw in ["game", "playful", "fun", "colorful", "emoji"]):
        matched.append("playful")
    if any(kw in text for kw in ["retro", "vintage", "pixel", "8bit"]):
        matched.append("retro")
    if any(kw in text for kw in ["premium", "luxury", "elegant", "gold"]):
        matched.append("premium")
    if any(kw in text for kw in ["light", "bright", "white", "clean"]):
        matched.append("light")
    if not matched:
        matched.append("minimal")
    return matched


def infer_roles(item_type, tags):
    """Infer asset roles based on item type and tags."""
    if item_type == "block":
        return ["main_scene"]
    tag_text = " ".join(t.lower() for t in tags)
    roles = []
    if any(kw in tag_text for kw in ["caption", "text", "title", "subtitle", "lower"]):
        roles.append("caption")
    if any(kw in tag_text for kw in ["effect", "particle", "glitch", "overlay", "grain"]):
        roles.append("effect")
    if any(kw in tag_text for kw in ["outro", "logo", "end", "brand"]):
        roles.append("outro")
    if any(kw in tag_text for kw in ["device", "phone", "mockup", "screen"]):
        roles.append("device_showcase")
    if any(kw in tag_text for kw in ["transition", "wipe", "slide"]):
        roles.append("transition")
    if not roles:
        roles.append("overlay")
    return roles


def infer_format(dimensions):
    """Infer format aspect from dimensions."""
    if not dimensions:
        return {"width": 1920, "height": 1080, "aspect": "landscape_16_9", "durationSeconds": None}
    w = dimensions.get("width") or 1920
    h = dimensions.get("height") or 1080
    if w > h:
        aspect = "landscape_16_9"
    elif h > w:
        aspect = "portrait_9_16"
    else:
        aspect = "square"
    return {"width": w, "height": h, "aspect": aspect, "durationSeconds": None}


def generate_install(item_id, item_type):
    """Generate install metadata for a catalog item."""
    return {
        "command": f"hyperframes add {item_id}",
        "path": f"compositions/{item_id}.html",
    }


def infer_motion(tags):
    """Infer motion tags from item tags."""
    tag_text = " ".join(t.lower() for t in tags)
    motions = []
    if any(kw in tag_text for kw in ["reveal", "stagger", "fade"]):
        motions.append("reveal")
        if "stagger" in tag_text:
            motions.append("staggered_reveal")
    if any(kw in tag_text for kw in ["zoom", "scale"]):
        motions.append("zoom")
    if any(kw in tag_text for kw in ["scroll"]):
        motions.append("scroll")
    if any(kw in tag_text for kw in ["parallax"]):
        motions.append("parallax")
    if any(kw in tag_text for kw in ["glitch"]):
        motions.append("glitch")
    if any(kw in tag_text for kw in ["bounce", "spring"]):
        motions.append("bounce")
    if any(kw in tag_text for kw in ["blur"]):
        motions.append("blur")
    if any(kw in tag_text for kw in ["wipe"]):
        motions.append("wipe")
    if any(kw in tag_text for kw in ["morph"]):
        motions.append("morph")
    if any(kw in tag_text for kw in ["particle"]):
        motions.append("particle")
    if any(kw in tag_text for kw in ["typing", "typewriter"]):
        motions.append("typing")
    if any(kw in tag_text for kw in ["shader", "gpu"]):
        motions.append("shader")
    if any(kw in tag_text for kw in ["pan"]):
        motions.append("pan")
    if any(kw in tag_text for kw in ["slam"]):
        motions.append("slam")
    return motions


def generate_triggers(item):
    """Generate natural language triggers from item metadata."""
    triggers = [item["name"]]
    if item.get("title"):
        triggers.append(item["title"])
    for tag in item.get("tags", []):
        if tag not in triggers:
            triggers.append(tag)
    return triggers


def merge_catalog_map(live_catalog, scoring_data, existing_map=None):
    """Merge live catalog items with existing map data.

    For new items, infer all fields from tags and metadata.
    For existing items, preserve custom fields while updating basic info.
    """
    existing_items = {}
    if existing_map and "items" in existing_map:
        for item in existing_map["items"]:
            existing_items[item["id"]] = item

    taxonomies = scoring_data.get("taxonomies", {})

    merged = []
    for item in live_catalog:
        item_id = item["name"]
        existing = existing_items.get(item_id)

        fmt = infer_format(item.get("dimensions", {}))
        fmt["durationSeconds"] = item.get("duration")

        if existing:
            merged_item = dict(existing)
            merged_item["id"] = item_id
            merged_item["title"] = item.get("title", existing.get("title", item_id))
            merged_item["type"] = item.get("type", existing.get("type"))
            merged_item["install"] = merged_item.get("install", generate_install(item_id, item.get("type", "")))
            merged_item["format"] = fmt
        else:
            merged_item = {
                "id": item_id,
                "title": item.get("title", item_id),
                "type": item["type"],
                "intentDomains": infer_intent(item.get("tags", []), taxonomies),
                "styleTags": infer_style(item, scoring_data),
                "motionTags": infer_motion(item.get("tags", [])),
                "assetRoles": infer_roles(item["type"], item.get("tags", [])),
                "naturalLanguageTriggers": generate_triggers(item),
                "format": fmt,
                "install": generate_install(item_id, item["type"]),
                "customizableParams": [],
                "assetCount": None,
                "hasPreview": True,
                "bestFor": [],
                "avoidWhen": [],
                "clarifyingQuestions": {"beforeSelection": [], "afterSelection": []},
                "generationHints": {},
                "confidenceBoosts": {},
                "source": {"syncType": "auto-generated",
                           "syncTimestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
            }

        merged.append(merged_item)

    return {
        "schemaVersion": "1.1.0",
        "mergedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "items": merged,
    }


def main():
    parser = argparse.ArgumentParser(description="Sync HyperFrames source cache")
    parser.add_argument("--full", action="store_true", help="Full redownload (ignore existing)")
    parser.add_argument("--check", action="store_true", help="Report cache status only")
    parser.add_argument("--workers", type=int, default=4, help="Parallel download workers (default: 4)")
    parser.add_argument("--sync-catalog-map", action="store_true",
                        help="Sync hyperframes-catalog-map.json from live catalog")
    args = parser.parse_args()

    if args.sync_catalog_map:
        log("Fetching live catalog from hyperframes...")
        r = subprocess.run(["hyperframes", "catalog", "--json"],
                           capture_output=True, text=True, timeout=30)
        if r.returncode != 0:
            log(f"Error: hyperframes catalog failed: {r.stderr}")
            sys.exit(1)
        live_catalog = json.loads(r.stdout)

        scoring_path = REFERENCES_DIR / "scoring.json"
        with open(scoring_path) as f:
            scoring_data = json.load(f)

        existing_map = None
        if CATALOG_MAP.exists():
            with open(CATALOG_MAP) as f:
                existing_map = json.load(f)

        result = merge_catalog_map(live_catalog, scoring_data, existing_map)

        CATALOG_MAP.parent.mkdir(parents=True, exist_ok=True)
        with open(CATALOG_MAP, 'w') as f:
            json.dump(result, f, indent=2, ensure_ascii=False)

        log(f"Catalog map synced: {len(result['items'])} items to {CATALOG_MAP}")
        sys.exit(0)

    catalog = load_catalog()
    items = catalog.get("items", [])
    manifest = load_manifest()

    if args.check:
        cached = sum(1 for _ in CACHE_DIR.rglob("*.html")) if CACHE_DIR.exists() else 0
        log(f"Catalog items: {len(items)}")
        log(f"Cached HTML files: {cached}")
        log(f"Last sync: {manifest.get('last_sync', 'never')}")
        sys.exit(0 if cached >= len(items) else 1)

    to_download = []
    for item in items:
        item_id = item["id"]
        item_type = item["type"]
        install_cmd = item.get("install", {}).get("command", f"hyperframes add {item_id}")

        if not args.full:
            dest = CACHE_DIR / f"{item_type}s" / f"{item_id}.html"
            if dest.exists() and dest.stat().st_size > 100:
                manifest["items"][item_id] = {"type": item_type, "cached": True}
                continue

        to_download.append((item_id, item_type, install_cmd))

    if not to_download:
        log("All items cached. Nothing to download.")
        save_manifest(manifest)
        sys.exit(0)

    total = len(to_download)
    log(f"Syncing {total} catalog items ({args.workers} workers)...", flush=True)
    start = time.time()
    results = {"downloaded": 0, "cached": 0, "error": 0, "not_found": 0}

    def progress_bar(done, total, elapsed, results):
        pct = done * 100 // total
        ok = results.get('downloaded', 0) + results.get('cached', 0)
        err = results.get('error', 0)
        bar_width = 30
        filled = bar_width * done // total
        bar = '█' * filled + '░' * (bar_width - filled)
        if done > 0 and done < total:
            eta = elapsed / done * (total - done)
            eta_str = f" ETA {eta:.0f}s"
        else:
            eta_str = ""
        return f"\r  [{bar}] {done}/{total} ({pct}%) | ok={ok} err={err} | {elapsed:.0f}s{eta_str}"

    with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as ex:
        futures = {ex.submit(download_item, iid, itype, icmd): iid
                   for iid, itype, icmd in to_download}
        done = 0
        for future in concurrent.futures.as_completed(futures):
            item_id, status, dest = future.result()
            done += 1
            results[status] = results.get(status, 0) + 1

            if status in ("downloaded", "cached"):
                manifest["items"][item_id] = {"type": to_download[0][1], "cached": True}

            if done % 5 == 0 or done == total or status == "error":
                elapsed = time.time() - start
                log(progress_bar(done, total, elapsed, results), end='', flush=True)

    log()  # newline after progress bar

    elapsed = time.time() - start
    save_manifest(manifest)
    total_ok = results.get("downloaded", 0) + results.get("cached", 0)
    log(f"Done: {total_ok}/{len(items)} cached ({elapsed:.0f}s)")
    log(f"  downloaded: {results.get('downloaded', 0)}")
    log(f"  cached: {results.get('cached', 0)}")
    log(f"  failed: {results.get('error', 0) + results.get('not_found', 0)}")
    log(f"  location: {CACHE_DIR}")


if __name__ == "__main__":
    main()
