#!/usr/bin/env python3
# Copyright: 2026, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Generate decopy's shipped licence-text data from the SPDX corpus.

Offline maintenance aid: reads the SPDX JSON vendored under tests/testdata/ and
writes the committed package-data under decopy/licensing/data/. It is NOT part
of the test suite and must never run in CI; re-run it by hand after refreshing
the SPDX corpus, then commit the regenerated files.

    scripts/emit-license-data.py --headers    # data/headers/<decopy-name>.txt
    scripts/emit-license-data.py --licenses   # data/licenses/<decopy-name>.txt
    scripts/emit-license-data.py --exceptions # data/exceptions/<dep5-label>.txt

Headers are the cleaned ``standardLicenseHeader`` keyed by decopy's DEP-5 name
(so GPL-2.0-only -> GPL-2, GPL-2.0-or-later -> GPL-2+). Only the common-licenses
families are emitted: the renderer prepends a header solely when it emits a
common-licenses reference (to disambiguate the +/only variant), so other SPDX
headers would never be read. When several SPDX ids canonicalise to one name the
smallest id wins, so the result is deterministic.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from decopy.licensing.data.common_licenses import COMMON_LICENSES  # noqa: E402
from decopy.licensing.spdx import (  # noqa: E402
    canonical_license_name,
    exception_to_spdx,
)

LICENSES_DIR = ROOT / "tests" / "testdata" / "licenses"
EXCEPTIONS_DIR = ROOT / "tests" / "testdata" / "exceptions"
DATA_DIR = ROOT / "decopy" / "licensing" / "data"
HEADERS_DIR = DATA_DIR / "headers"
LICENSES_OUT = DATA_DIR / "licenses"
EXCEPTIONS_OUT = DATA_DIR / "exceptions"

# Short / widely used licences embedded as full verbatim text instead of a
# common-licenses reference (they are too short for the indirection and are not
# in common-licenses). SPDX ids; the output is keyed by the decopy DEP-5 name.
# The original seed (MIT/ISC/Zlib/X11 + BSD-2/3) was extended 2026-07-02 from
# the completed tag-mode codesearch ranking (scripts/rank-license-usage-
# codesearch.py, .claude/reports/s3.5-license-status.md): every short candidate
# was queried and those at or above ~5 distinct source packages were added. The
# SPDX-License-Identifier signal is biased toward tag-adopting ecosystems
# (Rust/Go/modern) so it undercounts untagged C/Perl, but with the whole batch
# ranked it is a usable relative shortlist. BSD-2-Clause-FreeBSD is a deprecated
# SPDX id, kept because it still sees real (22-package) usage.
_EMBEDDED_LICENSE_IDS: tuple[str, ...] = (
    "0BSD",
    "BSD-1-Clause",
    "BSD-2-Clause",
    "BSD-2-Clause-FreeBSD",
    "BSD-3-Clause",
    "BSD-3-Clause-Clear",
    "BSD-4-Clause",
    "CC-PDDC",
    "FSFAP",
    "FSFUL",
    "FSFULLR",
    "ISC",
    "MIT",
    "MIT-0",
    "PSF-2.0",
    "Unicode-3.0",
    "Unlicense",
    "X11",
    "Zlib",
    "curl",
)

# Widely used licence exceptions embedded as full verbatim text so a
# "<base> with <label> exception" stanza is complete. These are decopy DEP-5
# labels (as find_licenses emits them); emit_exceptions maps each to its SPDX
# exception id via exception_to_spdx. The GPL-family seed was extended
# 2026-07-02 from the exception ranking (.claude/reports/s4.3-exception-status.md,
# which tag-queried all 84 SPDX exceptions): every uncovered exception at or
# above ~5 distinct source packages was added. Same SPDX-`WITH` tag bias as the
# licence ranking, so these are a relative shortlist, not a hard gate.
_EMBEDDED_EXCEPTION_LABELS: tuple[str, ...] = (
    "AutoConf",
    "AutoConf-3",
    "AutoConf-macro",
    "Bison",
    "Bootloader",
    "ClassPath",
    "Font",
    "GCC",
    "GCC-3.1",
    "LLVM",
    "Linux-syscall",
    "OCaml-LGPL",
    "PCRE2",
    "Qt-GPL-1",
    "Qt-LGPL-1.1",
)

# The resolver prepends a header only when it emits a common-licenses reference,
# so only these names are ever consumed; other SPDX headers would be dead data.
# Each family covers both the base and its "+" (or-later) variant.
_CONSUMED_HEADERS = set(COMMON_LICENSES) | {f"{name}+" for name in COMMON_LICENSES}

# A leading copyright-notice prefix: "Copyright" (opt. "Portions"), an optional
# (C)/© mark, an optional year token/placeholder, and an optional holder
# placeholder. Stripped from the START of a line so a header that packs the
# notice and the grant on one line (GFDL) keeps the grant; a line that is only
# the notice (GPL/Apache/AGPL/LGPL) strips to empty and is dropped. The holder
# belongs in the paragraph's Copyright field, not the shared text.
_COPYRIGHT_PREFIX = re.compile(
    r"^(?:portions\s+)?copyright\b\s*(?:\([cC]\)|©)?\s*"
    r"(?:year|yyyy|\[[^\]]*\]|<[^>]*>|\d{4}(?:-\d{4})?)?\s*"
    r"(?:your name|name of author|name of copyright owner|\[[^\]]*\]|<[^>]*>)?"
    r"\s*\.?\s*",
    re.IGNORECASE,
)
# A whole-line angle-bracketed instruction, e.g.
# "<one line to give the program's name ...>". A bare <url> has no internal
# space, so it is kept.
_INSTRUCTION_LINE = re.compile(r"^<[^>]*\s[^>]*>$")
# The same "how to apply" instruction the LGPL headers write without brackets:
# "one line to give the library's name and an idea of what it does."
_INSTRUCTION_PHRASE = re.compile(r"^<?one line to give\b", re.IGNORECASE)
# A fill-in-the-blank template line ("Contributor(s): ______", "[___] License").
_TEMPLATE_BLANK = re.compile(r"_{3,}")
# A standalone clerical "All Rights Reserved." fragment.
_RESERVED_LINE = re.compile(r"^all rights reserved\.?$", re.IGNORECASE)
# Copyright-notice lines and the licence's own heading are dropped from an
# embedded full-text licence: the holder goes in the Copyright field and the
# License field already names the licence, so neither belongs in the shared
# text. The substantive terms are kept verbatim.
_COPYRIGHT_START = re.compile(r"^copyright\b", re.IGNORECASE)
# The leading heading is recognised structurally rather than by keyword, so
# it catches "MIT License", "The FreeBSD Copyright", "UNICODE LICENSE V3",
# "MIT No Attribution" and "PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2"
# alike: a short first line (the substantive terms are always full sentences
# far longer than this) that does not read as a sentence (no closing period).
_TITLE_MAX_LEN = 55


def _is_title_line(line: str) -> bool:
    stripped = line.strip()
    return 0 < len(stripped) <= _TITLE_MAX_LEN and not stripped.endswith(".")


def clean_license(text: str) -> str:
    """Embedded full-text cleaning: drop a leading title/heading line, any
    copyright notice lines and standalone "All rights reserved." lines; keep
    the rest verbatim (trailing whitespace trimmed)."""
    lines = [line.rstrip() for line in text.splitlines()]
    first = next((i for i, line in enumerate(lines) if line.strip()), None)
    if first is not None and _is_title_line(lines[first]):
        del lines[first]
    kept = [
        line
        for line in lines
        if not _COPYRIGHT_START.match(line.strip())
        and not _RESERVED_LINE.match(line.strip())
    ]
    return "\n".join(kept).strip("\n")


# A GFDL no-invariants SPDX id; decopy names these GFDL-NIV-<version>.
_GFDL_NIV_ID = re.compile(r"^GFDL-(\d+\.\d+)-no-invariants-(only|or-later)$")


def _header_name(license_id: str) -> str | None:
    """The decopy name to key a header by, or None to skip this licence.

    GFDL is special: decopy detection names it GFDL-<v> (invariants allowed) and
    GFDL-NIV-<v> (no invariants). The invariants-allowing notice carries
    unfillable "Invariant Sections being LIST ..." placeholders (and unfilled it
    is non-free), so only the no-invariants variant gets a header, keyed by the
    detection name GFDL-NIV-<v>.
    """
    if license_id.startswith("GFDL-"):
        match = _GFDL_NIV_ID.match(license_id)
        if match is None:
            return None
        suffix = "+" if match.group(2) == "or-later" else ""
        return f"GFDL-NIV-{match.group(1)}{suffix}"
    return canonical_license_name(license_id)


def _strip_edge_quote(text: str, *, leading: bool) -> str:
    """Drop one wrapping quote at the given edge of a paragraph, if present.

    Applied to the first/last surviving paragraph rather than the raw header:
    once copyright/template lines are removed, the wrapper quote can end up on a
    new edge line. A leading/trailing quote is never real content in this corpus,
    whereas inner quotes (the "License") are.
    """
    if leading:
        return text[1:].strip() if text[:1] in ('"', "'") else text
    return text[:-1].strip() if text[-1:] in ('"', "'") else text


def _is_noise_line(line: str) -> bool:
    return bool(
        _INSTRUCTION_LINE.match(line)
        or _INSTRUCTION_PHRASE.match(line)
        or _TEMPLATE_BLANK.search(line)
        or _RESERVED_LINE.match(line)
    )


def _clean_line(line: str) -> str:
    """A header line with its leading copyright-notice prefix removed, or "".

    Returns "" for a line that is only noise (instruction/template/reserved) or
    only a copyright notice; otherwise the kept text (the grant that may follow
    the notice on the same line).
    """
    if _is_noise_line(line):
        return ""
    return _COPYRIGHT_PREFIX.sub("", line, count=1).strip()


def clean_header(text: str) -> str:
    """Clean an SPDX standardLicenseHeader into a shared-text notice.

    Drops copyright/template, instruction-placeholder and fill-in-the-blank
    lines, collapses each surviving paragraph to a single normalised line (the
    resolver reflows to width; blank lines mark paragraphs), then strips a quote
    wrapping the first/last surviving paragraph.
    """
    paragraphs: list[str] = []
    current: list[str] = []
    for raw in text.splitlines():
        line = _clean_line(raw.strip())
        if not line:
            if current:
                paragraphs.append(" ".join(current))
                current = []
            continue
        current.append(line)
    if current:
        paragraphs.append(" ".join(current))
    if not paragraphs:
        return ""
    paragraphs[0] = _strip_edge_quote(paragraphs[0], leading=True)
    paragraphs[-1] = _strip_edge_quote(paragraphs[-1], leading=False)
    return "\n\n".join(p for p in paragraphs if p)


def emit_headers() -> int:
    HEADERS_DIR.mkdir(parents=True, exist_ok=True)
    for stale in HEADERS_DIR.glob("*.txt"):
        stale.unlink()
    chosen: dict[str, str] = {}
    for path in sorted(LICENSES_DIR.glob("*.json"), key=lambda p: p.stem):
        data = json.loads(path.read_text(encoding="utf-8"))
        # Skip deprecated ids (the bare GPL-2.0 / GFDL-1.3 forms); the explicit
        # -only / -or-later ids carry the header that matches the decopy name's
        # meaning (GFDL-1.3 -> the "only" notice, not the "or later" one).
        if data.get("isDeprecatedLicenseId"):
            continue
        raw = (data.get("standardLicenseHeader") or "").strip()
        if not raw:
            continue
        name = _header_name(data.get("licenseId") or path.stem)
        if name is None or name not in _CONSUMED_HEADERS:
            continue
        cleaned = clean_header(raw)
        if cleaned and name not in chosen:  # smallest id wins (sorted iteration)
            chosen[name] = cleaned
    for name, text in chosen.items():
        (HEADERS_DIR / f"{name}.txt").write_text(text + "\n", encoding="utf-8")
    return len(chosen)


def emit_licenses() -> int:
    """Write each curated licence's verbatim SPDX licenseText to data/licenses.

    The text is authoritative, so it is copied unchanged (not cleaned or wrapped
    like headers); the resolver embeds it as-is. Keyed by the decopy DEP-5 name.
    """
    LICENSES_OUT.mkdir(parents=True, exist_ok=True)
    for stale in LICENSES_OUT.glob("*.txt"):
        stale.unlink()
    written = 0
    for spdx_id in sorted(_EMBEDDED_LICENSE_IDS):
        path = LICENSES_DIR / f"{spdx_id}.json"
        if not path.is_file():
            print(f"warning: no SPDX json for {spdx_id!r}", file=sys.stderr)
            continue
        raw = json.loads(path.read_text(encoding="utf-8")).get("licenseText") or ""
        text = clean_license(raw)
        if not text:
            print(f"warning: {spdx_id} has no licenseText", file=sys.stderr)
            continue
        name = canonical_license_name(spdx_id)
        (LICENSES_OUT / f"{name}.txt").write_text(text + "\n", encoding="utf-8")
        written += 1
    return written


def _exception_text_for(label: str) -> str | None:
    """The verbatim licenseExceptionText for a DEP-5 label, or None (warning)."""
    spdx_id = exception_to_spdx(label)
    if spdx_id is None:
        print(f"warning: no SPDX exception id for label {label!r}", file=sys.stderr)
        return None
    path = EXCEPTIONS_DIR / f"{spdx_id}.json"
    if not path.is_file():
        print(f"warning: no SPDX json for {spdx_id!r}", file=sys.stderr)
        return None
    raw = json.loads(path.read_text(encoding="utf-8")).get("licenseExceptionText")
    text = (raw or "").strip()
    if not text:
        print(f"warning: {spdx_id} has no licenseExceptionText", file=sys.stderr)
        return None
    return text


def emit_exceptions() -> int:
    """Write each curated exception's verbatim licenseExceptionText, keyed by
    the decopy DEP-5 label. The text is authoritative and copied unchanged (only
    surrounding blank lines trimmed); the resolver appends it as-is."""
    EXCEPTIONS_OUT.mkdir(parents=True, exist_ok=True)
    for stale in EXCEPTIONS_OUT.glob("*.txt"):
        stale.unlink()
    written = 0
    for label in sorted(_EMBEDDED_EXCEPTION_LABELS):
        text = _exception_text_for(label)
        if text is None:
            continue
        (EXCEPTIONS_OUT / f"{label}.txt").write_text(text + "\n", encoding="utf-8")
        written += 1
    return written


def main() -> None:
    parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
    parser.add_argument(
        "--headers", action="store_true", help="emit data/headers/<name>.txt"
    )
    parser.add_argument(
        "--licenses", action="store_true", help="emit data/licenses/<name>.txt"
    )
    parser.add_argument(
        "--exceptions", action="store_true", help="emit data/exceptions/<label>.txt"
    )
    args = parser.parse_args()
    if not (args.headers or args.licenses or args.exceptions):
        parser.error("nothing to do: pass --headers, --licenses and/or --exceptions")
    if args.headers:
        count = emit_headers()
        print(f"wrote {count} headers to {HEADERS_DIR.relative_to(ROOT)}")
    if args.licenses:
        count = emit_licenses()
        print(f"wrote {count} licences to {LICENSES_OUT.relative_to(ROOT)}")
    if args.exceptions:
        count = emit_exceptions()
        print(f"wrote {count} exceptions to {EXCEPTIONS_OUT.relative_to(ROOT)}")


if __name__ == "__main__":
    main()
