#!/usr/bin/env python3
"""Validate plugin.json files against the ClawHub schema.

Required fields (exactly these 8):
  name, description, version, author{name,url}, homepage, repository, license, skills

Two approved extension fields (documented in CLAUDE.md, stripped at ClawHub-publish):
  source, attribution

skills layouts — per the live Claude Code plugin spec
(https://code.claude.com/docs/en/plugins-reference), "All paths must be
relative to the plugin root and start with ./". CC 2.1.145 returns
`Validation errors: skills: Invalid input` on a bare string without "./".
Legacy bare-string form is still accepted by this validator during the
migration window, but emits a WARN line.

  CANONICAL (post-CC 2.1.144):
    - Single-skill plugin (SKILL.md at root):      "skills": ["./"]
    - Plugin with skills/ subdir:                  "skills": "./skills"  (or ["./skills"])
    - Multi-skill domain plugin (subfolders):      "skills": ["./sub1", "./sub2", ...]

  LEGACY (pre-migration, still passes with WARN):
    - "skills": "skills"  (bare subdir name, no "./" prefix)

  REJECTED:
    - Empty string / empty array
    - Non-string array entries
    - Strings that are neither "skills"-style legacy nor "./"-prefixed
"""
import argparse
import json
import os
import re
import sys

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ALLOWED = {"name", "description", "version", "author", "homepage", "repository", "license", "skills"}
APPROVED_EXTENSIONS = {"source", "attribution"}
STRING_FIELDS = ("name", "description", "homepage", "repository", "license")
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[\w.]+)?$")


def _check_keys(data):
    keys = set(data.keys())
    errors = []
    extra = keys - ALLOWED - APPROVED_EXTENSIONS
    missing = ALLOWED - keys
    if extra:
        errors.append(f"extra fields: {sorted(extra)}")
    if missing:
        errors.append(f"missing fields: {sorted(missing)}")
    return errors


def _check_strings(data):
    return [f"{k}: must be string" for k in STRING_FIELDS if k in data and not isinstance(data[k], str)]


def _check_version(data):
    if "version" not in data:
        return []
    v = data["version"]
    if not isinstance(v, str) or not SEMVER.match(v):
        return [f"version: must match semver, got {v!r}"]
    return []


def _check_author(data):
    if "author" not in data:
        return []
    a = data["author"]
    if not isinstance(a, dict):
        return ["author: must be object {name, url}"]
    errors = []
    if not isinstance(a.get("name"), str):
        errors.append("author.name: must be string")
    if not isinstance(a.get("url"), str):
        errors.append("author.url: must be string")
    extra = set(a.keys()) - {"name", "url"}
    if extra:
        errors.append(f"author: extra fields {sorted(extra)}")
    return errors


_LEGACY_SKILLS_VALUES = {"skills"}


def _check_skills_string(s):
    if s == "":
        return ["skills: empty string"]
    if s == "./":
        return ['skills: bare "./" must be wrapped in an array — use ["./"] for single-skill plugins']
    if s.startswith("./"):
        return []
    if s in _LEGACY_SKILLS_VALUES:
        return [f'WARN skills: legacy bare {s!r} — Claude Code 2.1.144+ requires the "./" prefix '
                f'per the plugin spec. Migrate to "./{s}" or ["./{s}"].']
    return [f'skills: {s!r} must start with "./" (Claude Code plugin spec: "All paths must be '
            f'relative to the plugin root and start with ./")']


def _check_skills_array(s):
    if not s:
        return ["skills: array is empty"]
    errors = []
    for entry in s:
        if not isinstance(entry, str):
            errors.append(f"skills: entries must be strings, got {entry!r}")
            continue
        if entry == "":
            errors.append("skills: array contains empty string")
            continue
        if not entry.startswith("./"):
            errors.append(f'skills: array entry {entry!r} must start with "./" '
                          f'(Claude Code plugin spec)')
    return errors


def _check_skills(data):
    if "skills" not in data:
        return []
    s = data["skills"]
    if isinstance(s, str):
        return _check_skills_string(s)
    if isinstance(s, list):
        return _check_skills_array(s)
    return ["skills: must be string or array of strings"]


def validate(path):
    try:
        with open(path) as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError) as e:
        return [f"unreadable JSON: {e}"]
    return (_check_keys(data) + _check_strings(data) + _check_version(data)
            + _check_author(data) + _check_skills(data))


def find_all():
    out = []
    for root, dirs, files in os.walk(REPO):
        if any(skip in root for skip in (".git", "node_modules", "eval-workspace", ".gemini")):
            dirs[:] = []
            continue
        if "plugin.json" in files and root.endswith(".claude-plugin"):
            out.append(os.path.join(root, "plugin.json"))
    return sorted(out)


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("path", nargs="?", help="Path to a plugin.json file")
    g.add_argument("--all", action="store_true", help="Validate every plugin.json in the repo")
    args = ap.parse_args()

    targets = find_all() if args.all else [args.path]
    failed = 0
    warned = 0
    for t in targets:
        msgs = validate(t)
        rel = os.path.relpath(t, REPO)
        hard = [m for m in msgs if not m.startswith("WARN ")]
        soft = [m for m in msgs if m.startswith("WARN ")]
        if hard:
            failed += 1
            print(f"FAIL {rel}")
            for e in hard:
                print(f"  - {e}")
            for w in soft:
                print(f"  - {w[5:]}")
        elif soft:
            warned += 1
            print(f"WARN {rel}")
            for w in soft:
                print(f"  - {w[5:]}")
        else:
            print(f"OK   {rel}")
    if warned:
        print(f"\n{warned} file(s) passed with warnings (legacy schema)", file=sys.stderr)
    if failed:
        print(f"\n{failed} file(s) failed validation", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
