#!/usr/bin/env bash
set -eo pipefail

# generate-packs-json — Build packs.json (first-party pack catalog) from
# packs.config.json + skills.json.
#
# Usage:
#   bin/generate-packs-json            Generate the catalog at catalog/packs.json (compact)
#   bin/generate-packs-json --pretty   Pretty-print the output
#
# packs.json is the catalog the dashboard reads to group skills into installable
# first-party packs. Membership is derived deterministically; every skill in
# skills.json must land in exactly one pack or this script exits non-zero.
# Community (external-repo) packs live separately in catalog/skill-packs.json.
#
# A skill's pack IS its `category` (one grouping — see docs/skill-packs.md).
# Precedence when assigning a skill to a pack:
#   1. packs that hand-list explicit `skills` (rare; kept for flexibility)
#   2. packs that claim a `category` (take that category's skills) — the norm
#   3. catch-all `lab` (category `other`) for a missing/unknown category

ROOT="$(cd "$(dirname "$0")/.." && pwd)"

PRETTY=false
if [[ "${1:-}" == "--pretty" ]]; then
  PRETTY=true
fi

PRETTY="$PRETTY" python3 - "$ROOT" <<'PY'
import json, os, sys, datetime

root = sys.argv[1]
pretty = os.environ.get("PRETTY") == "true"

with open(os.path.join(root, "catalog", "packs.config.json")) as f:
    config = json.load(f)
with open(os.path.join(root, "catalog", "skills.json")) as f:
    catalog = json.load(f)

skills = {s["slug"]: s for s in catalog["skills"]}
assigned = {}  # slug -> pack key

def claim(slug, key):
    if slug not in skills:
        sys.exit(f"ERROR: pack '{key}' lists unknown skill '{slug}' (not in skills.json)")
    if slug in assigned:
        sys.exit(f"ERROR: skill '{slug}' claimed by both '{assigned[slug]}' and '{key}'")
    assigned[slug] = key

# 1) explicit-membership packs (rare; first-party packs are category-driven, but
# this stays for any pack that hand-lists slugs). Runs before category so an
# explicit listing wins.
for p in config["packs"]:
    for slug in p.get("skills", []):
        claim(slug, p["key"])

# 2) category packs — a skill's pack IS its category. Each pack claims the skills
# whose `category` equals its `category` key. This is the single source of truth:
# to move a skill, change its SKILL.md `category:` line.
cat_to_pack = {p["category"]: p["key"] for p in config["packs"]
               if p.get("category") and not p.get("skills")}
for slug, s in skills.items():
    if slug in assigned:
        continue
    key = cat_to_pack.get(s.get("category"))
    if key:
        assigned[slug] = key

# 3) community installs override everything above. Anything recorded in
# skills.lock was deliberately installed from another repo — it must NOT be
# folded into a first-party category pack (where the Core-only visibility lens
# would hide it). Pull it into a synthetic, always-visible "installed" pack so
# the dashboard surfaces it as yours. Runs after the category pass so it
# overrides whatever first-party pack claimed the slug by category, and BEFORE
# the catch-all check below — a community SKILL.md is written to its author's
# conventions and frequently carries no `category:` at all, so a lock-installed
# skill must be assigned here or step 4 kills the run for a skill that was
# never going to belong to a first-party pack. No skills.lock (the upstream
# case) → no override → packs.json is byte-identical to before.
installed_src = {}  # slug -> source_repo
lock_path = os.path.join(root, "skills.lock")
if os.path.exists(lock_path):
    try:
        with open(lock_path) as f:
            lock = json.load(f)
    except (ValueError, OSError):
        lock = []
    for rec in lock or []:
        slug = rec.get("skill_name")
        if slug in skills:
            installed_src[slug] = rec.get("source_repo", "")
            assigned[slug] = "installed"

# 4) catch-all — any skill whose category has no pack (freshly authored or
# imported skills land here as category 'other') goes to the catch-all pack
# (the one claiming category 'other'), so adding a skill never breaks the
# catalog. Without a catch-all declared, an unassigned skill is a hard error.
# Reaching here means a first-party skill in this repo has a missing or unknown
# `category:` — the ci-skill-category gate is the fix, not a pack edit.
catch_all = cat_to_pack.get("other")
unassigned = sorted(set(skills) - set(assigned))
if unassigned:
    if not catch_all:
        sys.exit(f"ERROR: {len(unassigned)} skill(s) not assigned to any pack and "
                 f"no catch-all pack (category 'other') is declared: {unassigned}\n"
                 f"Set a valid `category:` in each SKILL.md "
                 f"(core|evolution|basics|dev|crypto|productivity) — see docs/skill-packs.md.")
    for slug in unassigned:
        assigned[slug] = catch_all

def skill_entry(slug):
    s = skills[slug]
    return {"slug": slug, "name": s["name"],
            "description": s["description"], "category": s["category"]}

def members(key):
    return sorted(sl for sl, k in assigned.items() if k == key)

def pack_out(key, name, description, color, category, default_enabled):
    return {"key": key, "name": name, "description": description,
            "color": color, "category": category, "kind": "first-party",
            "default_enabled": default_enabled,
            "skills": [skill_entry(sl) for sl in members(key)]}

out_packs = [pack_out(p["key"], p["name"], p["description"], p["color"],
                      p.get("category"), p.get("default_enabled", []))
             for p in config["packs"]]

# Append the synthetic "installed" pack only when something was installed, so
# the upstream manifest (no skills.lock) is unchanged. Each entry carries its
# source_repo so the dashboard can show where it came from.
if installed_src:
    inst_skills = []
    for sl in members("installed"):
        e = skill_entry(sl)
        e["source_repo"] = installed_src.get(sl, "")
        inst_skills.append(e)
    out_packs.append({
        "key": "installed", "name": "Installed",
        "description": "Skills you installed from community repos (recorded in "
                       "skills.lock). Always shown; never folded into a "
                       "first-party pack.",
        "color": "#A1A1AA", "category": None, "kind": "community",
        "default_enabled": [], "skills": inst_skills,
    })

result = {
    "version": "1.0",
    "generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    "repo": catalog.get("repo", "aeonfun/aeon"),
    "total_packs": len(out_packs),
    "total_skills": len(assigned),
    "packs": out_packs,
}

out = os.path.join(root, "catalog", "packs.json")
with open(out, "w") as f:
    if pretty:
        json.dump(result, f, indent=2)
    else:
        json.dump(result, f, separators=(",", ":"))
    f.write("\n")

print(f"Generated {out} with {len(out_packs)} packs / {len(assigned)} skills")
PY
