#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# tools/build-set -- validates a set, stages it with its licence texts and notices, writes SHA256SUMS, and zips it.
#
# ```bash
# python3 tools/build-set <set> [--root DIR] [--build DIR] [--dist DIR]
# ```
#
# The set is validated first, and the staged set is written from the files the validator read -- the bytes it judged,
# not a second copy from the tree -- plus `LICENSES/<identifier>.txt` for each identifier the set, its skills and its
# vendored assets declare (from the set's own `LICENSES/` or the repository's) and the repository's `LICENSE` where the
# set has none, so every payload carries its notices. `SHA256SUMS` at the staged root lists every file in `sha256sum`
# format, paths relative to the set root, itself and its signature excluded. The staged set is validated again under
# the release profile, so what ships is what passed, and the zip, `dist/ai-tools-assets-sets-<set>-<version>.zip`, holds
# it with the plugin root at the top of the archive (Claude Code's `archive` source reads it so), with fixed timestamps
# and sorted entries so the same tree gives the same bytes; its SHA-256 is written beside it.
#
# The output directories are the operator's and are checked before a write: `<set>` is a name before a path is composed
# from it; the build and the dist root may not be `sets/`, hold it or lie inside it, so the source tree is never the
# destination; and every existing component of each is `lstat`ed, a symbolic link refusing. The set is staged into a
# fresh `mkdtemp` directory beside the destination and renamed over `build/<set>` only when the sibling marker
# `build/.<set>.ai-tools-assets-build` names this set and this marker version -- another directory at that name is left
# where it is and refused -- and the marker is written after the rename. The marker, the zip and its checksum are
# replaced as directory entries (`tools/lib/safe_write.py`): an entry at one of those names that is a link or has a
# second hard link is refused, not written into. When a step fails after staging, the staging directory is removed and
# the findings name what failed. The release workflow signs `SHA256SUMS` and the zip; this command does not sign a
# file. Standard library only.
from __future__ import annotations

import argparse
import hashlib
import io
import os
import pathlib
import shutil
import sys
import tempfile
import zipfile
from typing import Dict, Optional

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 license_policy import resolve_license_allowlist  # noqa: E402
from safe_read import RefusedRead, open_directory, open_root, read_file_under, read_text_under  # noqa: E402
from safe_write import create_file, replace_file  # noqa: E402
from set_validation import PROFILE_RELEASE, PROFILE_SOURCE, ValidationOptions, read_reserved_words, validate_set_directory  # noqa: E402

ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
INVENTORY_EXCLUDED = ("SHA256SUMS", "SHA256SUMS.asc")
BUILD_MARKER_VERSION = "ai-tools-assets-build 1"


def marker_text(set_name: str) -> str:
    return f"{BUILD_MARKER_VERSION}\nset={set_name}\n"


def marker_path(build_root: pathlib.Path, set_name: str) -> pathlib.Path:
    return build_root / f".{set_name}.ai-tools-assets-build"


def check_output_root(root: pathlib.Path, given: pathlib.Path, what: str, collector: FindingCollector) -> Optional[pathlib.Path]:
    """The absolute output root, or None after a refusal: it does not overlap sets/ and no existing component is a link."""
    output_root = pathlib.Path(os.path.abspath(root / given))
    sets_root = root / "sets"
    if output_root == sets_root or sets_root in output_root.parents or output_root in sets_root.parents:
        collector.refuse(str(given), "repo.layout", f"the {what} root {output_root} is sets/, holds it or lies inside it; the source tree is not a destination")
        return None
    for component in [output_root, *output_root.parents]:
        try:
            if os.path.islink(component):
                collector.refuse(str(given), "repo.layout", f"`{component}` on the {what} root's path is a symbolic link")
                return None
        except OSError:
            continue
    return output_root


def stage_license_texts(root_fd: int, set_name: str, staged: Dict[str, bytes], identifiers, collector: FindingCollector) -> None:
    """Add `LICENSES/<identifier>.txt` for each identifier the staged set does not carry, from the repository's, and
    the repository's `LICENSE` where the set has none."""
    for identifier in sorted(identifiers):
        relative = f"{fmt.LICENSE_TEXTS_DIRECTORY}/{identifier}.txt"
        if relative in staged:
            continue
        try:
            read = read_file_under(root_fd, pathlib.PurePath(relative), fmt.FILE_MAX_BYTES)
        except FileNotFoundError:
            collector.refuse(f"sets/{set_name}", "license.text", f"no `{identifier}.txt` under the set's or the repository's LICENSES/")
            continue
        except RefusedRead as refusal:
            collector.refuse(relative, "license.text", refusal.message)
            continue
        if read.truncated:
            collector.refuse(relative, "license.text", f"is over {fmt.FILE_MAX_BYTES} bytes")
            continue
        staged[relative] = read.data
    if "LICENSE" not in staged:
        try:
            read = read_file_under(root_fd, pathlib.PurePath("LICENSE"), fmt.FILE_MAX_BYTES)
            if not read.truncated:
                staged["LICENSE"] = read.data
        except (FileNotFoundError, RefusedRead):
            pass


def render_checksum_inventory(staged: Dict[str, bytes]) -> str:
    return "".join(f"{hashlib.sha256(data).hexdigest()}  {relative}\n"
                   for relative, data in sorted(staged.items()) if relative.split("/")[-1] not in INVENTORY_EXCLUDED)


def build_zip_archive(staged: Dict[str, bytes]) -> bytes:
    """The deterministic archive of the staged files: sorted entries, fixed timestamps, the plugin root at the top."""
    buffer = io.BytesIO()
    with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for relative, data in sorted(staged.items()):
            info = zipfile.ZipInfo(relative, date_time=ZIP_TIMESTAMP)
            info.compress_type = zipfile.ZIP_DEFLATED
            info.external_attr = 0o644 << 16
            archive.writestr(info, data)
    return buffer.getvalue()


def is_managed_build_directory(build_root: pathlib.Path, set_name: str) -> bool:
    """True when `build/<set>` is a real directory beside a marker naming this set under this marker version."""
    destination = build_root / set_name
    marker = marker_path(build_root, set_name)
    if destination.is_symlink() or not destination.is_dir() or marker.is_symlink() or not marker.is_file():
        return False
    try:
        return marker.read_text(encoding="utf-8") == marker_text(set_name)
    except (OSError, UnicodeDecodeError):
        return False


def main() -> int:
    parser = argparse.ArgumentParser(description="Validate, stage, inventory and zip a set.")
    parser.add_argument("set", help="the set to build, by name")
    parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path("."), help="the publisher repository (default: the current directory)")
    parser.add_argument("--build", type=pathlib.Path, default=pathlib.Path("build"), help="where the staged set lands (default: build/)")
    parser.add_argument("--dist", type=pathlib.Path, default=pathlib.Path("dist"), help="where the zip lands (default: dist/)")
    args = parser.parse_args()
    collector = FindingCollector("build-set")
    if not fmt.is_valid_name(args.set):
        collector.refuse(args.set, "name.grammar", "a set is named, not pathed: 1-64 characters of a-z, 0-9 and single hyphens")
        return collector.report()
    root = args.root.resolve()
    try:
        root_fd = open_root(root)
    except OSError:
        collector.refuse(str(root), "repo.layout", "is not a directory; a set is built inside its publisher repository")
        return collector.report()
    try:
        return build(root_fd, root, args.set, args.build, args.dist, collector)
    finally:
        os.close(root_fd)


def build(root_fd: int, root: pathlib.Path, set_name: str, build_given: pathlib.Path, dist_given: pathlib.Path,
          collector: FindingCollector) -> int:
    try:
        publisher = parse_key_value_text(read_text_under(root_fd, pathlib.PurePath("publisher.conf"), fmt.FILE_MAX_BYTES))
        sets_fd = open_directory("sets", root_fd)
    except FileNotFoundError:
        collector.refuse(f"sets/{set_name}", "repo.layout", f"publisher.conf and sets/ are read under {root}")
        return collector.report()
    except RefusedRead as refusal:
        collector.refuse("publisher.conf or sets", "repo.layout", refusal.message)
        return collector.report()
    build_root = check_output_root(root, build_given, "build", collector)
    dist_root = check_output_root(root, dist_given, "dist", collector)
    allowlist, allowlist_error = resolve_license_allowlist(publisher)
    if allowlist_error:
        collector.refuse("publisher.conf", "license.allowlist", allowlist_error)
    if collector.has_refusals():
        os.close(sets_fd)
        return collector.report()
    options = ValidationOptions(publisher=publisher.get("publisher") or None, profile=PROFILE_SOURCE,
                                reserved_words=read_reserved_words(TOOLS / "reserved-words.txt"), license_allowlist=allowlist,
                                license_text_directories=[root], display_root=root, publisher_conf=publisher)
    try:
        summary = validate_set_directory(root / "sets" / set_name, options, collector, parent_fd=sets_fd)
    finally:
        os.close(sets_fd)
    if collector.has_refusals() or summary.set_conf is None:
        print("build-set: the set does not validate; nothing was built", file=sys.stderr)
        return collector.report()

    staged: Dict[str, bytes] = {record.relative_path.as_posix(): record.data for record in summary.files if record.data is not None}
    stage_license_texts(root_fd, set_name, staged, summary.declared_licenses, collector)
    if collector.has_refusals():
        return collector.report()
    staged["SHA256SUMS"] = render_checksum_inventory(staged).encode("utf-8")

    build_root.mkdir(parents=True, exist_ok=True)
    staging_root = pathlib.Path(tempfile.mkdtemp(prefix=f".{set_name}.", dir=build_root))
    try:
        staging = staging_root / set_name
        for relative, data in staged.items():
            create_file(staging_root, pathlib.PurePath(set_name, relative), data)
        release_options = ValidationOptions(publisher=options.publisher, profile=PROFILE_RELEASE, reserved_words=options.reserved_words,
                                            license_allowlist=allowlist, license_text_directories=[], display_root=staging_root,
                                            publisher_conf=publisher)
        validate_set_directory(staging, release_options, collector)
        if collector.has_refusals():
            print("build-set: the staged set does not validate under the release profile; no zip was written", file=sys.stderr)
            return collector.report()
        destination = build_root / set_name
        if destination.exists() or destination.is_symlink():
            if not is_managed_build_directory(build_root, set_name):
                collector.refuse(str(destination), "repo.layout",
                                 f"exists and is not a build of `{set_name}` by this command (no `{marker_path(build_root, set_name).name}` beside it names it); remove it or name another --build")
                return collector.report()
            shutil.rmtree(destination)
        os.rename(staging, destination)
        try:
            replace_file(build_root, pathlib.PurePath(marker_path(build_root, set_name).name), marker_text(set_name).encode("utf-8"))
        except RefusedRead as refusal:
            collector.refuse(str(marker_path(build_root, set_name)), refusal.rule_id, refusal.message)
            return collector.report()
    finally:
        shutil.rmtree(staging_root, ignore_errors=True)

    dist_root.mkdir(parents=True, exist_ok=True)
    archive_path = dist_root / f"{fmt.PACKAGE_NAME_PREFIX}{set_name}-{summary.set_conf.get('version')}.zip"
    checksum_path = archive_path.with_suffix(".zip.sha256")
    archive = build_zip_archive(staged)
    try:
        replace_file(dist_root, pathlib.PurePath(archive_path.name), archive)
        replace_file(dist_root, pathlib.PurePath(checksum_path.name), f"{hashlib.sha256(archive).hexdigest()}  {archive_path.name}\n".encode("utf-8"))
    except RefusedRead as refusal:
        collector.refuse(str(archive_path), refusal.rule_id, refusal.message)
        return collector.report()
    for written in (destination, destination / "SHA256SUMS", archive_path, checksum_path):
        print(f"build-set: wrote {written.relative_to(root) if root in written.parents else written}")
    return collector.report()


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