#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# tools/check-licenses -- holds every file of a repository to the SPDX allowlist in force.
#
# ```bash
# python3 tools/check-licenses [--root DIR] [--allow ID]... [--exception GLOB=ID]...
# ```
#
# Each tracked file's licence is what REUSE 3.2 resolves for it (`tools/lib/license_policy.py`): every
# `SPDX-License-Identifier` header anywhere in the file (read whole, at most 1 MiB, a `REUSE-IgnoreStart` block left
# out) or in its `<file>.license` sidecar, combined under the matching `[[annotations]]` table's `precedence` with the
# expressions that table declares; a file with none is reported, a file over the bound is refused unjudged, and
# every expression that applies is judged; a file the reader refuses (a second hard link, a special file) or cannot
# open is refused, not judged on an annotation as though it declared no licence. Every identifier is on the list in
# force -- `publisher.conf` `licenses=`, or the GPLv3-compatible permissive default, plus each `--allow` -- and has its
# text under `LICENSES/`. An `--exception` accepts one identifier for the files its glob (REUSE's grammar: `**` crosses
# `/`, `*` does not) matches alone, which is how this repository carries its `AGPL-3.0-only` `formatters/` beside an
# MIT tree. `LICENSE`, `LICENSES/`, `REUSE.toml` and a `.license` sidecar are not judged: they are the texts and the
# declarations themselves. A symbolic link is not judged either, as `reuse lint` does not judge one: it holds a path,
# not content, and its target is judged where it is tracked. A `REUSE.toml` inside a subdirectory is not read, and
# every file under it is reported. Standard library only.
from __future__ import annotations

import argparse
import os
import pathlib
import stat
import subprocess
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 license_policy import (LICENSE_SIDECAR_SUFFIX, applicable_expressions, check_declared_license,  # noqa: E402
                            check_license_texts, exception_for, read_reuse_annotations, resolve_license_allowlist,
                            reuse_glob_problem)
from safe_read import RefusedRead, open_root, read_text_under  # noqa: E402

NOT_JUDGED = ("LICENSE", "REUSE.toml")


def tracked_files(root: pathlib.Path):
    completed = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], capture_output=True, text=True)
    if completed.returncode == 0:
        return [pathlib.Path(name) for name in completed.stdout.split("\0") if name]
    return sorted(path.relative_to(root) for path in root.rglob("*") if path.is_file() and ".git" not in path.parts)


def main() -> int:
    parser = argparse.ArgumentParser(description="Hold every file's licence to the SPDX allowlist in force.")
    parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path("."), help="the repository (default: the current directory)")
    parser.add_argument("--allow", action="append", default=[], metavar="ID", help="an identifier accepted beside the list in force")
    parser.add_argument("--exception", action="append", default=[], metavar="GLOB=ID", help="accept ID for the files GLOB matches")
    args = parser.parse_args()
    root = args.root.resolve()
    collector = FindingCollector("check-licenses")
    exceptions = []
    for item in args.exception:
        if "=" not in item:
            parser.error(f"--exception takes GLOB=ID, not `{item}`")
        glob, identifier = item.rsplit("=", 1)
        problem = reuse_glob_problem(glob)
        if problem is not None:
            parser.error(f"--exception glob `{glob}` {problem}")
        exceptions.append((glob, identifier))
    try:
        root_fd = open_root(root)
    except OSError:
        collector.refuse(str(root), "repo.layout", "is not a directory")
        return collector.report()
    try:
        check_tree(root_fd, root, tuple(args.allow), exceptions, collector)
    finally:
        os.close(root_fd)
    return collector.report()


def is_symbolic_link(root_fd: int, relative_path: pathlib.Path) -> bool:
    """Whether the entry at `relative_path` under `root_fd` is a symbolic link: a path, not content, so not judged.

    `lstat` reads the entry itself; a link at a component before it is the reader's refusal when the file is read.
    """
    try:
        return stat.S_ISLNK(os.lstat(relative_path.as_posix(), dir_fd=root_fd).st_mode)
    except OSError:
        return False


def check_tree(root_fd: int, root: pathlib.Path, allowed: tuple, exceptions: list, collector: FindingCollector) -> None:
    try:
        publisher = parse_key_value_text(read_text_under(root_fd, pathlib.PurePath("publisher.conf"), fmt.FILE_MAX_BYTES))
    except FileNotFoundError:
        publisher = None
    except RefusedRead as refusal:
        collector.refuse("publisher.conf", "license.allowlist", f"{refusal.message}; every licence is refused until it is readable")
        return
    allowlist, allowlist_error = resolve_license_allowlist(publisher)
    if allowlist_error:
        collector.refuse("publisher.conf", "license.allowlist", allowlist_error)
    allowlist = tuple(allowlist) + allowed
    annotations, reuse_error = read_reuse_annotations(root_fd)
    if reuse_error is not None:
        collector.refuse("REUSE.toml", "license.file", reuse_error)
        return
    tracked = tracked_files(root)
    tracked_names = {path.as_posix() for path in tracked}
    nested_reuse_roots = [path.parent for path in tracked if path.name == "REUSE.toml" and path.parent != pathlib.Path(".")]
    used = set()
    for relative_path in tracked:
        path_text = relative_path.as_posix()
        nested = next((directory for directory in nested_reuse_roots if directory in relative_path.parents or directory == relative_path.parent), None)
        if nested is not None:
            collector.refuse(path_text, "license.file", f"lies under `{nested.as_posix()}/REUSE.toml`, which this check does not read; one REUSE.toml, at the root")
            continue
        if relative_path.name in NOT_JUDGED or relative_path.parts[0] == fmt.LICENSE_TEXTS_DIRECTORY or relative_path.suffix == LICENSE_SIDECAR_SUFFIX:
            continue
        if is_symbolic_link(root_fd, relative_path):
            continue
        try:
            expressions = applicable_expressions(root_fd, relative_path, tracked_names, annotations)
        except RefusedRead as refusal:
            collector.refuse(path_text, refusal.rule_id, refusal.message)
            continue
        if not expressions:
            collector.refuse(path_text, "license.file", "states no licence: no SPDX header, no .license sidecar and no REUSE.toml annotation matches")
            continue
        accepted = exception_for(path_text, exceptions)
        in_force = allowlist + ((accepted,) if accepted else ())
        for expression in expressions:
            evaluation = check_declared_license(collector, path_text, expression, in_force, "the file's licence")
            if evaluation.is_well_formed:
                used.update(evaluation.identifiers)
    check_license_texts(collector, "LICENSES", used, set(), [root])
    if not collector.has_refusals():
        print(f"check-licenses: every file is under {', '.join(sorted(used))}; texts present")


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