#!/usr/bin/python3
"""Check that a License tag covers every license in a getdeps source tree.

Two sources of truth are combined:

- the expression go_vendor_license computed from the license *files* of the
  source and vendored trees (passed with --expression), and
- licensecheck (the Debian/Fedora per-file scanner, SPDX output) run over
  the same trees, which also sees licenses that only appear in file headers:
  a zlib-licensed file inside an Apache project, Boost-licensed third-party
  headers inside an MIT one.

Directories listed under [licensing] exclude_directories (build-time only,
not shipped) and [getdeps] prune_directories (deleted in %prep) in the
go-vendor-tools config are skipped. Every SPDX identifier
found must appear in the tag; for a file licensed "A or B" one of them is
enough. Exit 1 with the missing identifiers and example files otherwise.
"""

import argparse
import os
import re
import subprocess
import sys
import tomllib

OPERATORS = {"AND", "OR", "WITH"}

# licensecheck is asked for SPDX names but prints its internal (Debian-style)
# name where SPDX has none: a generic "GPL version 2" match without only/or
# later wording, or public domain. Translate those to what a Fedora License
# tag uses (the generic GPL-N is taken as -only, the stricter reading).
DEBIAN_TO_FEDORA = {
    "public-domain": "LicenseRef-Fedora-Public-Domain",
    "GPL-1": "GPL-1.0-only",
    "GPL-1+": "GPL-1.0-or-later",
    "GPL-2": "GPL-2.0-only",
    "GPL-2+": "GPL-2.0-or-later",
    "GPL-3": "GPL-3.0-only",
    "GPL-3+": "GPL-3.0-or-later",
    "LGPL-2": "LGPL-2.0-only",
    "LGPL-2+": "LGPL-2.0-or-later",
    "LGPL-2.1": "LGPL-2.1-only",
    "LGPL-2.1+": "LGPL-2.1-or-later",
    "LGPL-3": "LGPL-3.0-only",
    "LGPL-3+": "LGPL-3.0-or-later",
    "Expat": "MIT",
    "Apache-2": "Apache-2.0",
    "Artistic-2": "Artistic-2.0",
    "Zlib": "Zlib",
}


def tag_identifiers(expression):
    return {
        t for t in re.findall(r"[A-Za-z0-9.+-]+", expression) if t not in OPERATORS
    }


def excluded(config):
    """Directories to skip: the scan's exclude_directories (present at build
    time, not part of the shipped binaries) and getdeps' prune_directories
    (deleted in %prep; skipped here too so a report on an unpruned tree
    matches the build's check)."""
    if not config:
        return []
    with open(config, "rb") as f:
        cfg = tomllib.load(f)
    return cfg.get("licensing", {}).get("exclude_directories", []) + cfg.get(
        "getdeps", {}
    ).get("prune_directories", [])


def scan(root, skip):
    cmd = ["licensecheck", "--recursive", "--machine", "--shortname-scheme=spdx"]
    if skip:
        # licensecheck prints paths as ./a/b; match the excluded directories
        # at the root of the tree only
        cmd.append(
            "--ignore=^(\\./)?(" + "|".join(re.escape(d) for d in skip) + ")(/|$)"
        )
    cmd.append(".")
    out = subprocess.run(
        cmd, cwd=root, capture_output=True, text=True, check=False
    ).stdout
    found = {}  # identifier or tuple of alternatives -> example paths
    for line in out.splitlines():
        parts = line.split("\t")
        if len(parts) < 2:
            continue
        path, lic = parts[0], parts[1]
        lic = re.sub(r"\s*\[.*?\]\s*", " ", lic).strip()  # "[generated file]"
        if not lic or lic == "UNKNOWN":
            continue
        for clause in re.split(r"\s+and\s+", lic.replace(" and/or ", " or ")):
            alternatives = tuple(
                DEBIAN_TO_FEDORA.get(a.strip(), a.strip())
                for a in re.split(r"\s+or\s+", clause)
                if a.strip()
            )
            if alternatives and all(a != "UNKNOWN" for a in alternatives):
                found.setdefault(alternatives, []).append(path)
    return found


def suggested_expression(file_scan_expression, found):
    """The file-scan expression extended with what licensecheck found: one
    AND term per identifier or "(A OR B)" group the license files did not
    already cover."""
    covered = tag_identifiers(file_scan_expression)
    extra = []
    for alternatives in sorted(found):
        if any(a in covered for a in alternatives):
            continue
        term = alternatives[0] if len(alternatives) == 1 else "(" + " OR ".join(alternatives) + ")"
        if term not in extra:
            extra.append(term)
    parts = [file_scan_expression] if file_scan_expression else []
    return " AND ".join(parts + extra)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", help="go-vendor-tools config (exclude_directories)")
    ap.add_argument(
        "--expression", default="", help="expression computed from license files"
    )
    ap.add_argument("--root", default=".")
    ap.add_argument(
        "--no-licensecheck",
        action="store_true",
        help="skip the per-file licensecheck pass (expensive); only verify "
        "the tag against the license-file expression",
    )
    ap.add_argument(
        "--report",
        action="store_true",
        help="print the expression the tag needs (both scans combined) and "
        "the files behind each identifier the license files do not show",
    )
    ap.add_argument("tag", nargs="?", default="", help="the License tag to verify")
    args = ap.parse_args()
    if not args.report and not args.tag:
        ap.error("a License tag to verify is required unless --report is given")

    tag = tag_identifiers(args.tag)
    missing = {}
    for ident in sorted(tag_identifiers(args.expression) - tag):
        missing[ident] = ["(license file scan)"]
    found = {} if args.no_licensecheck else scan(args.root, excluded(args.config))
    seen = set()
    for alternatives, paths in found.items():
        seen.update(alternatives)
        if not any(a in tag for a in alternatives):
            missing.setdefault(" OR ".join(alternatives), []).extend(paths)

    if args.no_licensecheck:
        print("licensecheck pass skipped (license-file scan only)")
    else:
        print(
            "licensecheck: %d files with a recognised license"
            % sum(map(len, found.values()))
        )
    if args.report:
        print(suggested_expression(args.expression, found))
        file_scan = tag_identifiers(args.expression)
        for alternatives, paths in sorted(found.items()):
            if not any(a in file_scan for a in alternatives):
                print(
                    "#   %s: %s" % (" OR ".join(alternatives), ", ".join(paths[:3]))
                )
        return 0
    unused = tag - seen - tag_identifiers(args.expression)
    if unused:
        print("note: in the License tag but not detected anywhere: " + ", ".join(sorted(unused)))
    if missing:
        print("ERROR: the License tag lacks:", file=sys.stderr)
        for ident, paths in sorted(missing.items()):
            print("  %s  e.g. %s" % (ident, ", ".join(paths[:3])), file=sys.stderr)
        print(
            "the tag needs (run with --report for the file list): "
            + suggested_expression(args.expression, found),
            file=sys.stderr,
        )
        return 1
    print("License tag covers every detected license")
    return 0


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