#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# tools/link-set -- links a set's skills into the directory an agent reads, for a host without ai-tools-base.
#
# ```bash
# python3 tools/link-set <set> --target DIR [--copy] [--only NAME,...] [--root DIR]
# python3 tools/link-set <set> --target DIR --remove
# ```
#
# The target is the skills directory the agent reads (`~/.claude/skills`, `~/.agents/skills`); each skill of the set
# becomes `<target>/<name>`, a symbolic link to the skill directory, or a copy with `--copy` for an agent that imports
# by copying. A subagent is not placed, since a skills directory holds skills alone. A copied skill does not follow the
# set, so an update is a `--remove` and a new run.
#
# The set is validated first, through the validator's own descriptor chain from the root (`tools/lib/safe_read.py`),
# and no skill of a set that does not validate is placed: the validator refuses every symbolic link inside a set, so
# the skills placed are the ones it recorded, and a copy is taken from a tree that held no link when it was judged. A
# linked `sets/` or set directory is refused the same way, before a byte of its target is read.
#
# What was placed is recorded in `<target>/.ai-tools-assets-<set>.links`, one `<name>\t<mode>\t<identity>` line each:
# a `link` line carries the absolute skill directory the link points at, a `copy` line the SHA-256 of the copy's
# inventory (every entry's relative path and, for a file, the SHA-256 of its content). `--remove` takes an entry out
# only while it still is what this command placed -- a symbolic link whose target reads as recorded, a directory whose
# inventory digests as recorded -- and leaves every other entry where it is, reported, so a replacement at a recorded
# name is not deleted on the record's word. The set and every name in `--only` and in the record are names, and a link's
# target is composed from the root and those names alone, so neither an argument nor a tampered record reaches a path
# outside `<root>/sets/<set>/skills/`. A record is refused whole when `lstat` reports anything but a regular file at
# its name, or when a line fails the three-field shape of a name, `link` or `copy`, and an identity; the record is
# written back as a directory entry (`tools/lib/safe_write.py`), so a link that appeared at its name after the read
# is refused rather than written through. Standard library only.
from __future__ import annotations

import argparse
import hashlib
import os
import pathlib
import shutil
import stat
import sys
from typing import Dict, Optional, Tuple

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_text_under  # noqa: E402
from safe_write import replace_file  # noqa: E402
from set_validation import PROFILE_SOURCE, ValidationOptions, read_reserved_words, validate_set_directory  # noqa: E402

MODE_LINK = "link"
MODE_COPY = "copy"
MODES = (MODE_LINK, MODE_COPY)
Record = Dict[str, Tuple[str, str]]  # name -> (mode, identity)


def record_path(target: pathlib.Path, set_name: str) -> pathlib.Path:
    return target / f".ai-tools-assets-{set_name}.links"


def read_record(record: pathlib.Path, collector: FindingCollector) -> Optional[Record]:
    """The placed names with their mode and identity; an empty record where none exists; None after refusing one
    that is not a regular file or holds a line outside the grammar."""
    try:
        status = os.lstat(record)
    except FileNotFoundError:
        return {}
    if not stat.S_ISREG(status.st_mode):
        collector.refuse(str(record), "name.collision", "the record is not a regular file; nothing is removed on its word")
        return None
    placed: Record = {}
    for line_number, line in enumerate(record.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        fields = line.split("\t")
        if len(fields) != 3 or not fmt.is_valid_name(fields[0]) or fields[1] not in MODES or not fields[2]:
            collector.refuse(str(record), "name.collision", f"line {line_number} is not `<name>\\t<link|copy>\\t<identity>`; the record is refused whole")
            return None
        placed[fields[0]] = (fields[1], fields[2])
    return placed


def write_record(record: pathlib.Path, placed: Record, collector: FindingCollector) -> None:
    """Replace the record as a directory entry (`safe_write.replace_file`), so a link or a second hard link that
    appeared at its name since it was read is refused and reported, not written through."""
    if placed:
        data = "".join(f"{name}\t{mode}\t{identity}\n" for name, (mode, identity) in sorted(placed.items())).encode("utf-8")
        try:
            replace_file(record.parent, pathlib.PurePath(record.name), data)
        except RefusedRead as refusal:
            collector.refuse(str(record), refusal.rule_id, refusal.message + "; the record was not written")
    elif record.is_symlink() or record.exists():
        record.unlink()


def compute_directory_digest(directory: pathlib.Path) -> str:
    """The SHA-256 over every entry under `directory`: its relative path and, for a regular file, its content's digest;
    another entry type contributes its type, so a link or a special file placed into a copy changes the digest."""
    inventory = hashlib.sha256()
    for current, directories, files in os.walk(directory, followlinks=False):
        directories.sort()
        for name in sorted(directories + files):
            path = pathlib.Path(current) / name
            relative = path.relative_to(directory).as_posix()
            status = os.lstat(path)
            if stat.S_ISREG(status.st_mode):
                identity = hashlib.sha256(path.read_bytes()).hexdigest()
            elif stat.S_ISDIR(status.st_mode):
                identity = "directory"
            else:
                identity = "other"
            inventory.update(f"{relative}\0{identity}\n".encode("utf-8"))
    return inventory.hexdigest()


def matches_installation_record(destination: pathlib.Path, mode: str, identity: str) -> bool:
    """True while the entry at `destination` is the link or the copy the record describes."""
    if mode == MODE_LINK:
        return destination.is_symlink() and os.readlink(destination) == identity
    return destination.is_dir() and not destination.is_symlink() and compute_directory_digest(destination) == identity


def remove_installed_skills(target: pathlib.Path, set_name: str, collector: FindingCollector) -> int:
    record = record_path(target, set_name)
    placed = read_record(record, collector)
    if placed is None:
        return collector.report()
    if not placed:
        print(f"link-set: nothing recorded for `{set_name}` under {target}")
        return 0
    remaining: Record = {}
    for name, (mode, identity) in sorted(placed.items()):
        destination = target / name
        if not destination.exists() and not destination.is_symlink():
            continue
        if not matches_installation_record(destination, mode, identity):
            collector.refuse(str(destination), "name.collision", f"is not the {mode} this command placed; left as it is")
            remaining[name] = (mode, identity)
            continue
        if mode == MODE_LINK:
            destination.unlink()
        else:
            shutil.rmtree(destination)
        print(f"link-set: removed {destination}")
    write_record(record, remaining, collector)
    return collector.report()


def validate_set_and_list_skill_names(root: pathlib.Path, set_name: str, collector: FindingCollector) -> Optional[list]:
    """The names of the skills the validator recorded for the set, or None when the set did not validate.

    The root is the operator's argument; `sets/` and the set are opened from its descriptor without following a link,
    and the set is judged under the source profile with the repository's `publisher.conf` and `LICENSES/`.
    """
    try:
        root_fd = open_root(root)
    except OSError:
        collector.refuse(str(root), "repo.layout", "is not a directory; a set is placed from its publisher repository")
        return None
    try:
        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 None
        except RefusedRead as refusal:
            collector.refuse("publisher.conf or sets", "repo.layout", refusal.message)
            return None
        allowlist, allowlist_error = resolve_license_allowlist(publisher)
        if allowlist_error:
            collector.refuse("publisher.conf", "license.allowlist", allowlist_error)
        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)
    finally:
        os.close(root_fd)
    if collector.has_refusals():
        print(f"link-set: `{set_name}` does not validate; nothing was placed", file=sys.stderr)
        return None
    return sorted(asset.name for asset in summary.assets if asset.kind_id == "skills")


def main() -> int:
    parser = argparse.ArgumentParser(description="Link or copy a set's skills into an agent's skills directory.")
    parser.add_argument("set", help="the set whose skills are placed, by name")
    parser.add_argument("--target", type=pathlib.Path, required=True, help="the skills directory the agent reads")
    parser.add_argument("--copy", action="store_true", help="copy each skill instead of linking it")
    parser.add_argument("--only", metavar="NAME,...", help="place these skills alone")
    parser.add_argument("--remove", action="store_true", help="remove what an earlier run placed for this set")
    parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path("."), help="the publisher repository (default: the current directory)")
    args = parser.parse_args()
    collector = FindingCollector("link-set")
    wanted = set(item for item in args.only.replace(",", " ").split() if item) if args.only else None
    for name in [args.set, *sorted(wanted or ())]:
        if not fmt.is_valid_name(name):
            collector.refuse(name, "name.grammar", "a set and a skill are named, not pathed: 1-64 characters of a-z, 0-9 and single hyphens")
    if collector.has_refusals():
        return collector.report()
    root = args.root.resolve()
    target = args.target.expanduser().resolve()
    if args.remove:
        return remove_installed_skills(target, args.set, collector)
    names = validate_set_and_list_skill_names(root, args.set, collector)
    if names is None:
        return collector.report()
    skills_root = root / "sets" / args.set / "skills"
    if wanted is not None:
        for name in sorted(wanted - set(names)):
            collector.refuse(f"sets/{args.set}/skills/{name}", "kind.shape", "no such skill")
        names = [name for name in names if name in wanted]
    if collector.has_refusals():
        return collector.report()
    skills = [skills_root / name for name in names]
    target.mkdir(parents=True, exist_ok=True)
    record = record_path(target, args.set)
    placed = read_record(record, collector)
    if placed is None:
        return collector.report()
    for skill in skills:
        destination = target / skill.name
        if destination.exists() or destination.is_symlink():
            if skill.name in placed and matches_installation_record(destination, *placed[skill.name]):
                print(f"link-set: {destination} is already placed")
                continue
            collector.refuse(str(destination), "name.collision", "exists and is not what this command placed; left as it is")
            continue
        if args.copy:
            shutil.copytree(skill, destination, symlinks=False)
            placed[skill.name] = (MODE_COPY, compute_directory_digest(destination))
        else:
            destination.symlink_to(skill, target_is_directory=True)
            placed[skill.name] = (MODE_LINK, str(skill))
        print(f"link-set: {'copied' if args.copy else 'linked'} {destination}")
    write_record(record, placed, collector)
    return collector.report()


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