#!/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.

"""Convert an SPDX template field into a regex aligned with clean_comments().

SPDX licence and exception JSON files ship templated forms of their text using
the SPDX template markup:

* ``<<beginOptional>> ... <<endOptional>>`` wraps an omissible span;
* ``<<var;name=..;original=..;match="RE">>`` marks a slot whose allowed
  variation is given by the ``match`` regex.

Three fields use this markup:

============================== =========================
template field                 corresponding text field
============================== =========================
standardLicenseTemplate        licenseText
standardLicenseHeaderTemplate  standardLicenseHeader
licenseExceptionTemplate       licenseExceptionText
============================== =========================

``template_to_regex()`` converts the markup into a Python regex that matches
the corresponding text *after* decopy's ``clean_comments()`` normalisation,
so the output can seed a hand-written matcher in ``decopy/res.py``. The
generated regex is deliberately permissive (and large); it is a starting
point to read the required-vs-optional structure from, not a drop-in
production matcher.

``template_literal_spans()`` and ``distinctive_window()`` serve a different
consumer: picking a short, distinctive, non-template substring of a licence's
text to search for verbatim (e.g. against a source-code search engine, to
estimate real-world usage independently of whether anyone tagged the file
with an SPDX identifier). That needs the *raw* text, not the
clean_comments-normalised form, so it deliberately does not reuse
``_literal_to_regex``'s charset-stripping cleanup.

Run from the repository root, e.g.:

    python3 scripts/spdx_template_as_re.py tests/testdata/licenses/HPND.json
    python3 scripts/spdx_template_as_re.py tests/testdata/exceptions/\
GCC-exception-2.0.json --field licenseExceptionTemplate --validate
"""

from __future__ import annotations

import argparse
import json
import sys
import typing as t
from pathlib import Path

import regex as rex

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

from decopy.licensing import licenses  # noqa: E402

# The text field each template field describes (and a stable processing order).
TEMPLATE_FIELDS: dict[str, str] = {
    "standardLicenseTemplate": "licenseText",
    "standardLicenseHeaderTemplate": "standardLicenseHeader",
    "licenseExceptionTemplate": "licenseExceptionText",
}

# A template marker: an optional-span delimiter or a variable slot.
_TOKEN_RE = rex.compile(r"<<(beginOptional|endOptional|var;.*?)>>", rex.DOTALL)
_MATCH_RE = rex.compile(r'match="((?:[^"\\]|\\.)*)"')

# clean_comments() keeps these characters; everything else becomes whitespace.
_KEPT = r" \w'.,@:;()/+-"

# Unbounded wildcards in a slot's match expression are clamped to this many
# characters so the generated regex cannot backtrack catastrophically.
_WILDCARD_BOUND = 200


def _literal_to_regex(literal: str) -> str:
    """Turn template literal text into a clean_comments-tolerant regex."""
    cleaned = rex.sub(rf"[^{_KEPT}]", " ", literal)
    cleaned = rex.sub(r"\s+", " ", cleaned).strip()
    if not cleaned:
        return ""
    return "".join(r"\s+" if ch == " " else rex.escape(ch) for ch in cleaned)


def _var_to_regex(spec: str) -> str:
    """Turn a ``var;..;match="RE"`` slot into a bounded regex group."""
    match = _MATCH_RE.search(spec)
    expr = match.group(1) if match else ".*"
    expr = expr.replace(".*", rf".{{0,{_WILDCARD_BOUND}}}")
    expr = expr.replace(".+", rf".{{1,{_WILDCARD_BOUND}}}")
    return f"(?:{expr})"


def _iter_segments(template: str) -> t.Iterator[tuple[str, str]]:
    """Yield (kind, text) pairs walking a template's literal/token runs.

    ``kind`` is ``"literal"`` for a text run, or the token's own name
    (``beginOptional``, ``endOptional``, or the raw ``var;...`` spec).
    """
    pos = 0
    for token in _TOKEN_RE.finditer(template):
        yield "literal", template[pos : token.start()]
        yield token.group(1), token.group(1)
        pos = token.end()
    yield "literal", template[pos:]


def template_to_regex(template: str) -> str:
    """Convert SPDX template markup into a single regex string."""
    pieces: list[str] = []
    for kind, text in _iter_segments(template):
        if kind == "literal":
            pieces.append(_literal_to_regex(text))
        elif kind == "beginOptional":
            pieces.append("(?:")
        elif kind == "endOptional":
            pieces.append(")?")
        else:
            pieces.append(_var_to_regex(kind))
    # Tolerate whitespace between every piece; clean_comments single-spaces.
    return r"\s*".join(piece for piece in pieces if piece)


# A blank line (paragraph break) ends a literal span, same as a <<var>>
# token: a source-code search engine may bridge an ordinary wrapped newline
# but not a blank line, so no search window may straddle one.
_PARA_BREAK_RE = rex.compile(r"\n[ \t]*\n")

# Shapes that suggest an unmarked template placeholder the SPDX curators
# forgot to tag with <<var>> (a bracket/underscore fill-in, or a bare
# placeholder word) rather than stable prose. Conservative and imprecise: it
# also flags stable disclaimer prose that happens to use one of these words
# (e.g. 0BSD's "THE AUTHOR DISCLAIMS..."). A hit means "check this by hand",
# not "reject automatically".
_DUBIOUS_RE = rex.compile(
    r"[\[\]<>]|_{3,}|\b(?:LIST|NAME|YEAR|HOLDER|AUTHOR|ORGANIZATION|TITLE|OWNER|COMPANY|DATE)\b"
)


def _normalize_ws(text: str) -> str:
    return rex.sub(r"\s+", " ", text).strip()


def template_literal_spans(template: str) -> list[str]:
    """Literal chunks between ``<<var>>``/optional tokens and blank lines.

    Unlike ``_literal_to_regex``, this only collapses whitespace runs and
    leaves every other character as-is, so the result stays a substring of
    the real (raw) licence text - suitable for a verbatim/raw-text search,
    not for matching decopy's own clean_comments-normalised text.
    """
    raw_spans = [text for kind, text in _iter_segments(template) if kind == "literal"]
    return [
        cleaned
        for raw in raw_spans
        for para in _PARA_BREAK_RE.split(raw)
        if (cleaned := _normalize_ws(para))
    ]


def is_dubious(span: str) -> bool:
    """Flag literal text that looks like an unmarked template placeholder."""
    return bool(_DUBIOUS_RE.search(span))


def _windows_from_words(words: list[str], min_len: int, max_len: int) -> list[str]:
    """Word-windows of ``words`` whose joined length falls in [min_len, max_len]."""
    out: list[str] = []
    for start in range(len(words)):
        for end in range(start + 1, len(words) + 1):
            window = " ".join(words[start:end])
            if len(window) > max_len:
                break
            if len(window) >= min_len:
                out.append(window)
    return out


def _candidate_windows(spans: list[str], min_len: int, max_len: int) -> list[str]:
    """Every span's word-windows in [min_len, max_len] chars, longest first."""
    windows: list[str] = []
    for span in spans:
        windows.extend(_windows_from_words(span.split(), min_len, max_len))
    windows.sort(key=len, reverse=True)
    return windows


def distinctive_window(
    spans: list[str],
    other_texts: list[str],
    *,
    min_len: int = 40,
    max_len: int = 140,
) -> str | None:
    """Longest word-window that is not dubious and absent from ``other_texts``.

    ``spans`` is one licence's ``template_literal_spans()``. ``other_texts``
    is the concatenated literal text of every other licence this one must
    stay distinguishable from; scope it to the licences actually competing in
    a given ranking pass (not the whole SPDX corpus) - a global-uniqueness
    bar rejects e.g. BSD-2-Clause/BSD-3-Clause/HPND for colliding with
    obscure, practically-irrelevant SPDX ids that would never affect a real
    embedding decision. Returns ``None`` if nothing qualifies.
    """
    for window in _candidate_windows(spans, min_len, max_len):
        if is_dubious(window):
            continue
        if any(window in text for text in other_texts):
            continue
        return window
    return None


def window_to_regexp_query(window: str) -> str:
    """Plain-text window -> source-search ``regexp``-mode query (``\\s+`` gaps).

    Each word is regex-escaped and the words are joined by ``\\s+`` so the
    query tolerates re-wrapped whitespace (including across a line break)
    without asserting whitespace anywhere the raw text doesn't actually have
    it (``template_literal_spans`` already keeps punctuation literal for
    exactly this reason).
    """
    return r"\s+".join(rex.escape(w) for w in window.split())


def _read_json(path: Path) -> dict[str, object]:
    """Load the SPDX JSON file as an object, or exit on malformed input."""
    raw = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(raw, dict):
        sys.exit(f"{path}: not a JSON object")
    return t.cast("dict[str, object]", raw)


def _pick_template(data: dict[str, object], field: str, path: Path) -> str:
    """Return the requested template field, or exit with a helpful message."""
    template = data.get(field)
    if not isinstance(template, str) or not template:
        present = sorted(k for k in TEMPLATE_FIELDS if isinstance(data.get(k), str))
        hint = f"; template fields present: {present}" if present else ""
        sys.exit(f"{path}: no usable {field!r}{hint}")
    return template


def _validate(data: dict[str, object], field: str, regex_str: str) -> bool:
    """Check the generated regex matches the corresponding text field."""
    text_field = TEMPLATE_FIELDS[field]
    text = data.get(text_field)
    if not isinstance(text, str) or not text:
        print(f"validate: {text_field!r} absent; skipped", file=sys.stderr)
        return True
    pattern = rex.compile(regex_str, rex.IGNORECASE | rex.DOTALL)
    matched = bool(pattern.search(licenses.clean_comments(text)))
    status = "matches" if matched else "DOES NOT MATCH"
    print(f"validate: regex {status} {text_field}", file=sys.stderr)
    return matched


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Convert an SPDX template field into a clean_comments-aligned "
        "regex (seed for a hand-written matcher).",
    )
    parser.add_argument("file", type=Path, help="SPDX licence/exception JSON file")
    parser.add_argument(
        "--field",
        choices=sorted(TEMPLATE_FIELDS),
        default="standardLicenseTemplate",
        help="template field to convert (default: standardLicenseTemplate)",
    )
    parser.add_argument(
        "--validate",
        action="store_true",
        help="check the regex matches the file's corresponding text field",
    )
    args = parser.parse_args()

    data = _read_json(args.file)
    template = _pick_template(data, args.field, args.file)
    regex_str = template_to_regex(template)
    print(regex_str)

    if args.validate and not _validate(data, args.field, regex_str):
        sys.exit(1)


if __name__ == "__main__":
    main()
