#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# tools/sync-manifests -- writes each set's two plugin manifests and the repository's marketplace from set.conf and
# publisher.conf, or with `--check` reports the committed copies that differ.
#
# ```bash
# python3 tools/sync-manifests [--root DIR]            # write the three generated files
# python3 tools/sync-manifests [--root DIR] --check    # exit 1 naming a committed copy that is absent or differs
# ```
#
# The generated files are `sets/<set>/plugin.json`, `sets/<set>/.claude-plugin/plugin.json` and
# `.claude-plugin/marketplace.json`. They are committed, since a git install reads the repository as committed, and CI
# runs `--check` so a committed copy that drifted from `set.conf` is refused. The rendering is `tools/lib/manifests.py`.
# Standard library only.
from __future__ import annotations

import argparse
import os
import pathlib
import sys

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

import asset_format as fmt  # noqa: E402
from findings import FindingCollector  # noqa: E402
from key_value_config import parse_key_value_text  # noqa: E402
from manifests import render_manifest_files, find_stale_manifest_files  # noqa: E402
from safe_read import RefusedRead, open_directory, read_text_under  # noqa: E402
from safe_write import replace_file  # noqa: E402


def read_inputs(root_fd: int, collector: FindingCollector):
    """publisher.conf and every set's set.conf, read from the root's descriptor, or None after reporting what could not be read."""
    try:
        publisher = parse_key_value_text(read_text_under(root_fd, pathlib.PurePath("publisher.conf"), fmt.FILE_MAX_BYTES))
    except FileNotFoundError:
        collector.refuse("publisher.conf", "repo.layout", "not found; a publisher repository holds publisher.conf at its root")
        return None, []
    except RefusedRead as refusal:
        collector.refuse("publisher.conf", "repo.publisher-conf", refusal.message)
        return None, []
    missing = [key for key in fmt.PUBLISHER_CONF_REQUIRED if not publisher.get(key).strip()]
    if missing:
        collector.refuse("publisher.conf", "repo.publisher-conf", "missing or empty: " + ", ".join(f"`{key}=`" for key in missing))
        return None, []
    sets = []
    try:
        sets_fd = open_directory("sets", root_fd)
    except FileNotFoundError:
        return publisher, sets
    except RefusedRead as refusal:
        collector.refuse("sets", "repo.layout", refusal.message)
        return None, []
    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():
            collector.refuse(f"sets/{entry.name}", "file.symlink", "is a symbolic link; a set is a directory under sets/")
            continue
        if not entry.is_dir(follow_symlinks=False):
            continue
        display = f"sets/{entry.name}/{fmt.SET_CONF_FILE}"
        try:
            set_conf = parse_key_value_text(read_text_under(root_fd, pathlib.PurePath("sets", entry.name, fmt.SET_CONF_FILE), fmt.FILE_MAX_BYTES))
        except FileNotFoundError:
            collector.refuse(display, "set.conf.missing", "a set directory holds set.conf")
            continue
        except RefusedRead as refusal:
            collector.refuse(display, refusal.rule_id, refusal.message)
            continue
        missing = [key for key in ("name", "version", "summary", "license") if not set_conf.get(key).strip()]
        if missing:
            collector.refuse(display, "set.conf.required-key", "missing or empty: " + ", ".join(f"`{key}=`" for key in missing))
            continue
        sets.append((entry.name, set_conf))
    return publisher, sets


def main() -> int:
    parser = argparse.ArgumentParser(description="Write the plugin manifests and the marketplace from set.conf and publisher.conf.")
    parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path("."), help="the publisher repository (default: the current directory)")
    parser.add_argument("--check", action="store_true", help="report the committed copies that differ, writing nothing")
    args = parser.parse_args()
    root = args.root.resolve()
    collector = FindingCollector("sync-manifests")
    try:
        root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
    except OSError:
        collector.refuse(str(root), "repo.layout", "is not a directory")
        return collector.report()
    try:
        return sync(root_fd, root, args.check, collector)
    finally:
        os.close(root_fd)


def sync(root_fd: int, root: pathlib.Path, check: bool, collector: FindingCollector) -> int:
    publisher, sets = read_inputs(root_fd, collector)
    if publisher is None or collector.has_refusals():
        return collector.report()
    expected = render_manifest_files(publisher, sets)
    if check:
        for relative_path, reason in find_stale_manifest_files(root_fd, expected):
            collector.refuse(str(relative_path), "set.manifest.plugin" if relative_path.name == "plugin.json" else "repo.marketplace",
                             f"{reason}; run `python3 tools/sync-manifests`")
        return collector.report()
    # A stale copy is replaced as a directory entry, never written through a link or into an inode a second hard link
    # names, on a path whose components are checked; a copy that is not one regular file is reported and left.
    for relative_path, _ in find_stale_manifest_files(root_fd, expected):
        try:
            replace_file(root, relative_path, expected[relative_path].encode("utf-8"))
        except RefusedRead as refusal:
            collector.refuse(str(relative_path), refusal.rule_id, refusal.message)
            continue
        print(f"sync-manifests: wrote {relative_path}")
    return collector.report()


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