"""library_vendor.py -- copy Forge-library GLBs the build agent chose into the app, at build time (18 Oct 2026).

The agent searches with `python3 /app/forge_engine/tools/library_search_cli.py "wardrobe"`, then declares what it wants in
`.forge/library-manifest.json`, a bare JSON list like the other manifests:

    [{"id": "prop.furniture.wardrobe/store-wardrobe-paxa", "path": "generated/library/wardrobe.glb"}]

The engine resolves each id in the published index, checks reuse.omu and status, downloads the content-addressed blob
(`blobs/<sha>.glb`, hash verified against its own name), writes it under `public/generated/library/` and records the
licence line in `.forge/library-attributions.json`. FAIL-OPEN and bounded: any problem drops that entry, never the build;
the app still references the local `/generated/library/...` path, never an https URL. Runs next to vendor_manifest_assets.
"""
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import re
import urllib.request
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional

from forge_engine.tools import library_search_cli as lib

log = logging.getLogger("forge.pipeline.library_vendor")

MANIFEST_REL = Path(".forge") / "library-manifest.json"
ATTRIBUTIONS_REL = Path(".forge") / "library-attributions.json"
DEST_ROOT = "generated/library/"          # entries must resolve under public/generated/library/
MAX_ENTRIES = 16
MAX_FILE_BYTES = 3 * 1024 * 1024          # phone budget per model
MAX_TOTAL_BYTES = 12 * 1024 * 1024
_SAFE_PATH = re.compile(r"^generated/library/[A-Za-z0-9._/-]+\.glb$")

FetchBytes = Callable[[str], Awaitable[bytes]]


async def _fetch_bytes(url: str) -> bytes:
    def get() -> bytes:
        return urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "forge-engine"}), timeout=60).read()
    return await asyncio.to_thread(get)


async def vendor_library_assets(sandbox: Path, *, index: Optional[dict] = None, fetch_bytes: Optional[FetchBytes] = None,
                                base: str = lib.BASE) -> dict[str, Any]:
    """Never raises. {manifest, vendored, skipped, failed, dropped, rejected:[{id,reason}], files:[path]}"""
    sandbox = Path(sandbox)
    res: dict[str, Any] = {"manifest": False, "vendored": 0, "skipped": 0, "failed": 0, "dropped": 0, "rejected": [], "files": []}
    mpath = sandbox / MANIFEST_REL
    if not mpath.exists():
        return res
    res["manifest"] = True
    try:
        raw = json.loads(mpath.read_text(encoding="utf-8"))
        if isinstance(raw, dict):
            raw = raw.get("assets") or raw.get("entries") or []
        if not isinstance(raw, list):
            raise ValueError("manifest is not a list")
    except Exception as exc:  # noqa: BLE001
        log.error("[library-vendor] %s: unreadable manifest: %s", sandbox.name, exc)
        res["error"] = f"unreadable manifest: {exc}"
        return res
    try:
        index = index or await asyncio.to_thread(lib.load_index)
    except Exception as exc:  # noqa: BLE001
        log.warning("[library-vendor] %s: library unreachable: %s", sandbox.name, exc)
        res["error"] = f"library unreachable: {exc}"
        return res
    fetch = fetch_bytes or _fetch_bytes
    byid = {a["id"]: a for a in index.get("assets", [])}
    total, credits = 0, []
    for ent in raw[:MAX_ENTRIES]:
        aid, rel = (ent.get("id") if isinstance(ent, dict) else None), (ent.get("path") if isinstance(ent, dict) else None)
        a = byid.get(aid or "")
        if not a or not isinstance(rel, str) or not _SAFE_PATH.match(rel) or ".." in rel:
            res["dropped"] += 1
            res["rejected"].append({"id": aid, "reason": "unknown id or path outside public/generated/library/*.glb"})
            continue
        if not lib.usable(a):
            res["dropped"] += 1
            res["rejected"].append({"id": aid, "reason": "not cleared for reuse (reuse.omu false or not approved)"})
            continue
        dest = sandbox / "public" / rel
        if dest.exists() and hashlib.sha256(dest.read_bytes()).hexdigest() == a["glb"]:
            res["skipped"] += 1
            continue
        if (a.get("bytes") or 0) > MAX_FILE_BYTES or total + (a.get("bytes") or 0) > MAX_TOTAL_BYTES:
            res["dropped"] += 1
            res["rejected"].append({"id": aid, "reason": "over the size budget"})
            continue
        try:
            blob = await fetch(f"{base}/blobs/{a['glb']}.glb")
            if hashlib.sha256(blob).hexdigest() != a["glb"]:
                raise ValueError("hash mismatch")
            dest.parent.mkdir(parents=True, exist_ok=True)
            dest.write_bytes(blob)
        except Exception as exc:  # noqa: BLE001
            log.warning("[library-vendor] %s: %s failed: %s", sandbox.name, aid, exc)
            res["failed"] += 1
            continue
        total += len(blob)
        res["vendored"] += 1
        res["files"].append(rel)
        credits.append({"path": rel, "id": aid, "release": a.get("release"), "licence": a.get("licence", "owned"), "author": a.get("author")})
    if credits:
        out = sandbox / ATTRIBUTIONS_REL
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(json.dumps(credits, indent=1), encoding="utf-8")
    return res
