#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# tools/check-publisher -- checks that every name and contact this repository publishes under is copied from
# publisher.conf, so a fork that publishes without setting its own fails before it ships under this repository's
# identity.
#
# ```bash
# python3 tools/check-publisher [--root DIR]                        # every pull request
# python3 tools/check-publisher [--root DIR] --repository <o>/<r>   # every release: publisher.conf describes <o>/<r>
# ```
#
# The repository checked is the current directory, or `--root`; the reserved words are read beside this file. Every file
# under the root is read through `tools/lib/safe_read.py`, from the root's descriptor without following a symbolic
# link, so a linked `sets/`, set directory or manifest is reported and its target is not read; publisher.conf and each
# set.conf are read with the one `KEY=value` grammar (`tools/lib/key_value_config.py`), so a quoted value, a comment
# after it or a bare list compares as the validator and base read it.
# publisher.conf holds `publisher`, `contact`, `maintainers` and `source`. From them:
#   - the marketplace in .claude-plugin/marketplace.json is named `<publisher>-ai-tools-assets`, its `owner` is
#     the publisher and the contact, and it lists each set once as the plugin `ai-tools-assets-<set>` from
#     `./sets/<set>`;
#   - each set `sets/<set>/` is named `<publisher>` or `<publisher>-<topic>`, except `core`, which belongs to
#     UPSTREAM_PUBLISHER; its set.conf carries the publisher's `maintainers` and `source`;
#   - both of a set's plugin manifests carry the plugin name, the publisher and contact as `author`, and `source` as
#     `homepage` and `repository`.
# The publisher and each set name follow the asset name rule, do not contain `anthropic` or `claude`, and do not
# start with a word from tools/reserved-words.txt. With `--repository` (a release passes github.repository), the
# publisher is the repository's owner and `source` is its GitHub URL, compared case-insensitively as GitHub compares
# them; and a publisher other than UPSTREAM_PUBLISHER may not keep a contact at UPSTREAM_DOMAIN. Each finding is
# printed as `check-publisher: <file>: <what>`; the exit status is 1 when any is found. Standard library only.

import argparse
import json
import os
import pathlib
import re
import sys

TOOLS = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(TOOLS / "lib"))

from key_value_config import parse_key_value_text  # noqa: E402
from safe_read import RefusedRead, open_directory, open_root, read_text_under  # noqa: E402

UPSTREAM_PUBLISHER = "dag-node"
UPSTREAM_DOMAIN = "dagnode.com"
MARKETPLACE_SUFFIX = "-ai-tools-assets"
PLUGIN_PREFIX = "ai-tools-assets-"
NAME_RULE = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
CLAUDE_RESERVED = ("anthropic", "claude")
IDENTITY_KEYS = ("publisher", "contact", "maintainers", "source")
FILE_MAX_BYTES = 1024 * 1024

ROOT = pathlib.Path(".")
ROOT_FD = -1
findings = []


def finding(path, what):
    findings.append(f"check-publisher: {path}: {what}")


def read_text(relative_path):
    """The text of a file under the root, or None after a finding; a missing file is None without one."""
    try:
        return read_text_under(ROOT_FD, pathlib.PurePath(relative_path), FILE_MAX_BYTES)
    except FileNotFoundError:
        return None
    except RefusedRead as refusal:
        finding(relative_path, refusal.message)
        return None
    except OSError as error:
        finding(relative_path, f"cannot be read: {error.strerror}")
        return None


def maintainers_of(path, conf):
    """The `maintainers` list of a config document, or None after a finding that it is not a list."""
    items, reason = conf.get_list("maintainers")
    if reason is not None:
        finding(path, f"`maintainers` is not a list ({reason})")
        return None
    return items


def reserved_words():
    path = TOOLS / "reserved-words.txt"
    return {line.strip() for line in path.read_text(encoding="utf-8").splitlines()
            if line.strip() and not line.lstrip().startswith("#")}


def check_name(path, kind, name, reserved):
    if not NAME_RULE.match(name):
        finding(path, f"{kind} `{name}` is not 1-64 characters of a-z, 0-9 and single hyphens, starting and ending "
                      f"with a letter or digit")
        return
    for word in CLAUDE_RESERVED:
        if word in name:
            finding(path, f"{kind} `{name}` contains `{word}`, which Claude reserves")
    if name.split("-", 1)[0] in reserved:
        finding(path, f"{kind} `{name}` starts with `{name.split('-', 1)[0]}`, a word in tools/reserved-words.txt")


def load_json(relative_path):
    text = read_text(relative_path)
    if text is None:
        if not any(line.startswith(f"check-publisher: {relative_path}: ") for line in findings):
            finding(relative_path, "is absent")
        return None
    try:
        return json.loads(text)
    except (ValueError, RecursionError) as error:
        finding(relative_path, f"cannot be read as JSON: {error if isinstance(error, ValueError) else 'nests too deep'}")
        return None


def set_names():
    """The names of the directories under sets/ that hold a set.conf, sorted; a symbolic link is reported and skipped."""
    names = []
    try:
        sets_fd = open_directory("sets", ROOT_FD)
    except FileNotFoundError:
        finding("sets", "not found; a publisher repository holds its sets under sets/")
        return names
    except RefusedRead as refusal:
        finding("sets", refusal.message)
        return names
    try:
        with os.scandir(sets_fd) as listing:
            entries = sorted(listing, key=lambda entry: entry.name)
    finally:
        os.close(sets_fd)
    for entry in entries:
        if entry.is_symlink():
            finding(pathlib.Path("sets", entry.name), "is a symbolic link; a set is a directory under sets/")
        elif entry.is_dir(follow_symlinks=False) and read_text(pathlib.Path("sets", entry.name, "set.conf")) is not None:
            names.append(entry.name)
    return names


def expect(path, field, found, wanted):
    if found != wanted:
        finding(path, f"`{field}` is `{found}`, expected `{wanted}` from publisher.conf")


def main():
    parser = argparse.ArgumentParser(description="Check the published names and contacts against publisher.conf.")
    parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path("."),
                        help="the publisher repository (default: the current directory)")
    parser.add_argument("--repository", help="<owner>/<repo> the release runs in; a release passes github.repository")
    args = parser.parse_args()

    global ROOT, ROOT_FD
    ROOT = args.root.resolve()
    try:
        ROOT_FD = open_root(ROOT)
    except OSError:
        finding(ROOT, "is not a directory; run from the publisher repository or pass --root")
        return report()
    try:
        return check(reserved_words(), args.repository)
    finally:
        os.close(ROOT_FD)


def check(reserved, repository):
    publisher_text = read_text("publisher.conf")
    if publisher_text is None:
        if not findings:
            finding("publisher.conf", f"not found under {ROOT}; run from the publisher repository or pass --root")
        return report()
    conf = parse_key_value_text(publisher_text)
    missing = [key for key in IDENTITY_KEYS if not conf.get(key).strip()]
    if missing:
        finding("publisher.conf", "missing or empty: " + ", ".join(f"`{key}=`" for key in missing))
        return report()
    publisher, contact, source = conf.get("publisher"), conf.get("contact"), conf.get("source")
    maintainers = maintainers_of("publisher.conf", conf)
    if maintainers is None:
        return report()
    check_name("publisher.conf", "publisher", publisher, reserved)

    if repository is not None:
        owner = repository.split("/", 1)[0]
        if publisher != owner.lower():
            finding("publisher.conf", f"publisher is `{publisher}`, but this repository belongs to `{owner}`. To "
                                      f"publish from it, set `publisher={owner.lower()}`, rename the sets to start "
                                      f"with it, and run the check again")
        wanted_source = f"https://github.com/{repository}"
        if source.lower() != wanted_source.lower():
            finding("publisher.conf", f"source is `{source}`, but this repository is `{wanted_source}`")
        if publisher != UPSTREAM_PUBLISHER:
            for key in ("contact", "maintainers"):
                if UPSTREAM_DOMAIN in conf.get(key).lower():
                    finding("publisher.conf", f"`{key}` still names {UPSTREAM_PUBLISHER}'s address at "
                                              f"{UPSTREAM_DOMAIN}; set the publisher's own")

    author = {"name": publisher, "email": contact}
    sets = set_names()
    for name in sets:
        conf_path = pathlib.Path("sets", name, "set.conf")
        set_conf_text = read_text(conf_path)
        if set_conf_text is None:
            continue
        set_conf = parse_key_value_text(set_conf_text)
        if set_conf.get("name") != name:
            finding(conf_path, f"`name={set_conf.get('name')}` differs from the set's directory `{name}`")
        check_name(conf_path, "set name", name, reserved)
        if name == "core":
            if publisher != UPSTREAM_PUBLISHER:
                finding(conf_path, f"`core` belongs to {UPSTREAM_PUBLISHER}; a set published by `{publisher}` is "
                                   f"named `{publisher}` or `{publisher}-<topic>`")
        elif name != publisher and not name.startswith(publisher + "-"):
            finding(conf_path, f"set `{name}` does not start with its publisher: name it `{publisher}` or "
                               f"`{publisher}-<topic>`")
        set_maintainers = maintainers_of(conf_path, set_conf)
        if set_maintainers is not None:
            expect(conf_path, "maintainers", set_maintainers, maintainers)
        expect(conf_path, "source", set_conf.get("source"), source)

        plugin = PLUGIN_PREFIX + name
        for manifest in (pathlib.Path("sets", name, "plugin.json"),
                         pathlib.Path("sets", name, ".claude-plugin", "plugin.json")):
            data = load_json(manifest)
            if data is None:
                continue
            expect(manifest, "name", data.get("name"), plugin)
            expect(manifest, "author", data.get("author"), author)
            expect(manifest, "homepage", data.get("homepage"), source)
            expect(manifest, "repository", data.get("repository"), source)

    marketplace_path = pathlib.Path(".claude-plugin", "marketplace.json")
    marketplace = load_json(marketplace_path)
    if marketplace is not None:
        expect(marketplace_path, "name", marketplace.get("name"), publisher + MARKETPLACE_SUFFIX)
        expect(marketplace_path, "owner", marketplace.get("owner"), author)
        entries = {entry.get("name"): entry.get("source") for entry in marketplace.get("plugins", [])}
        wanted = {PLUGIN_PREFIX + name: f"./sets/{name}" for name in sets}
        for plugin, plugin_source in wanted.items():
            if entries.get(plugin) != plugin_source:
                finding(marketplace_path, f"lists `{plugin}` from `{entries.get(plugin)}`, expected "
                                          f"`{plugin_source}`")
        for plugin in sorted(set(entries) - set(wanted)):
            finding(marketplace_path, f"lists `{plugin}`, which is not a set in sets/")

    return report()


def report():
    for line in findings:
        print(line, file=sys.stderr)
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
