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

"""DEP-5 licence name -> SPDX identifier conversion."""

from __future__ import annotations

import re as reo
import typing as t

if t.TYPE_CHECKING:
    from .core_types import Options

# Complete-name substitutions (DEP-5 name -> SPDX identifier).
_ALIASES: dict[str, str] = {
    "CC0": "CC0-1.0",
    "Expat": "MIT",
    "MIT/X11": "MIT",
}

# SPDX exception identifier -> DEP-5 exception label.
#
# This is the clean, total direction: every SPDX exception ID decopy recognises
# maps to exactly one DEP-5 label (the label emitted by the matchers in res.py).
# Several SPDX IDs collapse onto the same label (e.g. the -generic / -note /
# -short variants), which is why the authoritative map runs ID -> label rather
# than the reverse.  replace_spdx() uses it to expand SPDX "WITH" expressions
# back into DEP-5 "with ... exception" form.  A future forward map (label -> a
# representative SPDX ID) for dep5_to_spdx() will build on this data.
_SPDX_EXCEPTION_MAP: dict[str, str] = {
    "Font-exception-2.0": "Font",
    "cryptsetup-OpenSSL-exception": "OpenSSL-cryptsetup",
    "sqlitestudio-OpenSSL-exception": "OpenSSL-sqlitestudio",
    "kvirc-openssl-exception": "OpenSSL-kvirc",
    "vsftpd-openssl-exception": "OpenSSL-vsftpd",
    "x11vnc-openssl-exception": "OpenSSL-x11vnc",
    "openvpn-openssl-exception": "openvpn-openssl",
    "rsync-linking-exception": "rsync-linking",
    "GPL-3.0-389-ds-base-exception": "GPL-3-389-ds",
    "Autoconf-exception-2.0": "AutoConf",
    "Autoconf-exception-generic": "AutoConf",
    "Autoconf-exception-3.0": "AutoConf-3",
    "Autoconf-exception-generic-3.0": "AutoConf-3",
    "Autoconf-exception-macro": "AutoConf-macro",
    "Bison-exception-1.24": "Bison-1.24",
    "Bison-exception-2.2": "Bison",
    "Independent-modules-exception": "Independent-modules",
    "Classpath-exception-2.0": "ClassPath",
    "Classpath-exception-2.0-short": "ClassPath",
    "GCC-exception-2.0": "GCC",
    "GCC-exception-2.0-note": "GCC",
    "GCC-exception-3.1": "GCC-3.1",
    "Libtool-exception": "LibTool",
    "389-exception": "389",
    "CLISP-exception-2.0": "CLISP",
    "DigiRule-FOSS-exception": "DigiRule-FOSS",
    "eCos-exception-2.0": "eCos-2",
    "FLTK-exception": "FLTK",
    "freertos-exception-2.0": "FreeRTOS",
    "gnu-javamail-exception": "GNU-JavaMail",
    "i2p-gpl-java-exception": "i2p-Java",
    "LZMA-exception": "LZMA",
    "mif-exception": "MIF",
    "OCCT-exception-1.0": "OCCT-1",
    "Bootloader-exception": "Bootloader",
    "RRDtool-FLOSS-exception-2.0": "RRDtool-FLOSS",
    "Qt-LGPL-exception-1.1": "Qt-LGPL-1.1",
    "Digia-Qt-LGPL-exception-1.1": "Digia-Qt",
    "Nokia-Qt-exception-1.1": "Qt1.1",
    "u-boot-exception-2.0": "u-boot",
    "WxWindows-exception-3.1": "WxWindows-3.1+",
    "erlang-otp-linking-exception": "erlang-otp",
    "harbour-exception": "harbour",
    "romic-exception": "romic",
    "stunnel-exception": "stunnel",
    "SWI-exception": "SWI",
    "Texinfo-exception": "Texinfo",
    "UBDL-exception": "UBDL",
    "CGAL-linking-exception": "CGAL-linking",
    "OCaml-LGPL-linking-exception": "OCaml-LGPL",
    "libpri-OpenH323-exception": "libpri-OpenH323",
    "polyparse-exception": "polyparse",
    "Qwt-exception-1.0": "Qwt-1",
    "Simple-Library-Usage-exception": "Simple-Library-Usage",
    "Qt-GPL-exception-1.0": "Qt-GPL-1",
    "GNAT-exception": "GNAT",
    "GNU-compiler-exception": "GNU-compiler",
    "GPL-CC-1.0": "GPL-CC",
    "GStreamer-exception-2005": "GStreamer",
    "GStreamer-exception-2008": "GStreamer",
    "Gmsh-exception": "Gmsh",
    "Linux-syscall-note": "Linux-syscall",
    "GPL-3.0-linking-source-exception": "GPL-3-linking-source",
    "GPL-3.0-linking-exception": "GPL-3-linking",
    "GPL-3.0-interface-exception": "GPL-3-interface",
    "SANE-exception": "SANE",
    "OpenJDK-assembly-exception-1.0": "OpenJDK-assembly",
    "PS-or-PDF-font-exception-20170817": "PS-or-PDF-font",
    "Asterisk-exception": "Asterisk",
    "Asterisk-linking-protocols-exception": "Asterisk-linking",
    "LGPL-3.0-linking-exception": "LGPL-3-linking",
    "LLGPL": "LLGPL",
    "Swift-exception": "Swift",
    "SHL-2.1": "SHL-2.1",
    "SHL-2.0": "SHL-2",
    "LLVM-exception": "LLVM",
    "mxml-exception": "mxml",
    "fmt-exception": "fmt",
    "GNOME-examples-exception": "GNOME-examples",
    "KiCad-libraries-exception": "KiCad-libraries",
    "QPL-1.0-INRIA-2004-exception": "QPL-INRIA-2004",
    "PCRE2-exception": "PCRE2",
    "Universal-FOSS-exception-1.0": "Universal-FOSS",
}

# Deprecated SPDX *license* IDs that bundle a GPL base with an exception, mapped
# to their modern "<base> WITH <exception-id>" equivalent.  replace_spdx()
# resolves these to the modern form and re-processes it through the WITH handling,
# so the DEP-5 conversion lives in one place.  The GPL-3.0 forms select the v3
# exception variant.
_DEPRECATED_LICENSE_EXCEPTIONS: dict[str, str] = {
    "GPL-2.0-with-GCC-exception": "GPL-2.0-only WITH GCC-exception-2.0",
    "GPL-2.0-with-autoconf-exception": "GPL-2.0-only WITH Autoconf-exception-2.0",
    "GPL-2.0-with-bison-exception": "GPL-2.0-only WITH Bison-exception-2.2",
    "GPL-2.0-with-classpath-exception": "GPL-2.0-only WITH Classpath-exception-2.0",
    "GPL-2.0-with-font-exception": "GPL-2.0-only WITH Font-exception-2.0",
    "GPL-3.0-with-GCC-exception": "GPL-3.0-only WITH GCC-exception-3.1",
    "GPL-3.0-with-autoconf-exception": "GPL-3.0-only WITH Autoconf-exception-3.0",
}

# Forward exception lookup (DEP-5 label -> SPDX exception ID), the inverse of
# _SPDX_EXCEPTION_MAP, used by dep5_to_spdx() to emit "WITH" expressions.
# decopy's labels already encode the version variant (GCC vs GCC-3.1, AutoConf
# vs AutoConf-3), so the mapping is a plain lookup -- no base-version resolution
# is needed.  The only many-to-one cases are the -generic / -note / -short /
# dated SPDX variants that collapse onto one label; the representative ID for
# those is pinned here so the result is deterministic regardless of dict order.
_EXCEPTION_FORWARD_PREFERRED: dict[str, str] = {
    "AutoConf": "Autoconf-exception-2.0",
    "AutoConf-3": "Autoconf-exception-3.0",
    "GCC": "GCC-exception-2.0",
    "ClassPath": "Classpath-exception-2.0",
    "GStreamer": "GStreamer-exception-2008",
}


def _build_exception_forward() -> dict[str, str]:
    forward: dict[str, str] = {}
    for spdx_id, label in _SPDX_EXCEPTION_MAP.items():
        forward.setdefault(label, spdx_id)
    forward.update(_EXCEPTION_FORWARD_PREFERRED)
    # Invariant: the forward and reverse maps must agree, so a pinned
    # representative id (or a typo in it) cannot silently drift.
    bad = [
        label
        for label, spdx_id in forward.items()
        if _SPDX_EXCEPTION_MAP.get(spdx_id) != label
    ]
    if bad:
        msg = f"inconsistent exception maps for labels: {bad}"
        raise ValueError(msg)
    return forward


_EXCEPTION_FORWARD: dict[str, str] = _build_exception_forward()


def exception_to_spdx(label: str) -> str | None:
    """DEP-5 exception label -> SPDX exception ID, or None if unknown."""
    return _EXCEPTION_FORWARD.get(label)


# SPDX licence-expression parser
#
# Grammar (precedence, tightest -> loosest: WITH > AND > OR; parens override):
#   or-expr   := and-expr  ( "OR"  and-expr )*
#   and-expr  := with-expr ( "AND" with-expr )*
#   with-expr := atom ( "WITH" exception-id )?
#   atom      := license-id | "(" or-expr ")"
# AND/OR/WITH are case-sensitive uppercase operators; a license/exception id is
# any whitespace-free run that is not a parenthesis.  The SPDX "+" operator is
# always attached to its id (e.g. GPL-2.0+), so it rides along inside the id
# token and is resolved by the leaf converter -- no separate handling needed.


class License(t.NamedTuple):
    id: str


class With(t.NamedTuple):
    license: License
    exception: str


class And(t.NamedTuple):
    terms: tuple


class Or(t.NamedTuple):
    terms: tuple


type Node = License | With | And | Or

_TOKEN_RE = reo.compile(r"\(|\)|\bAND\b|\bOR\b|\bWITH\b|[^\s()]+")
_OPERATORS = frozenset({"(", ")", "AND", "OR", "WITH"})


class _SpdxParser:
    """Recursive-descent parser for an SPDX licence expression."""

    def __init__(self, text: str, tokens: list[str]) -> None:
        self.text = text
        self.tokens = tokens
        self.pos = 0

    def _fail(self, reason: str) -> t.NoReturn:
        msg = f"{reason} in SPDX expression: {self.text!r}"
        raise ValueError(msg)

    def _peek(self) -> str | None:
        return self.tokens[self.pos] if self.pos < len(self.tokens) else None

    def _advance(self) -> str:
        if self.pos >= len(self.tokens):
            self._fail("unexpected end")
        tok = self.tokens[self.pos]
        self.pos += 1
        return tok

    def parse(self) -> Node:
        tree = self._or()
        if self.pos != len(self.tokens):
            self._fail("trailing tokens")
        return tree

    def _or(self) -> Node:
        terms = [self._and()]
        while self._peek() == "OR":
            self._advance()
            terms.append(self._and())
        return terms[0] if len(terms) == 1 else Or(tuple(terms))

    def _and(self) -> Node:
        terms = [self._with()]
        while self._peek() == "AND":
            self._advance()
            terms.append(self._with())
        return terms[0] if len(terms) == 1 else And(tuple(terms))

    def _with(self) -> Node:
        node = self._atom()
        if self._peek() != "WITH":
            return node
        self._advance()
        # SPDX: WITH applies to a licence id only, not a parenthesised group.
        if not isinstance(node, License):
            self._fail("WITH must follow a licence id")
        exc = self._advance()
        if exc in _OPERATORS:
            self._fail("expected exception id after WITH")
        return With(node, exc)

    def _atom(self) -> Node:
        tok = self._peek()
        if tok is None:
            self._fail("unexpected end")
        if tok == "(":
            self._advance()
            node = self._or()
            if self._peek() != ")":
                self._fail("expected ')'")
            self._advance()
            return node
        if tok in _OPERATORS:
            self._fail(f"unexpected {tok!r}")
        self._advance()
        return License(tok)


def parse_spdx_expression(text: str) -> Node:
    """Parse a full SPDX licence expression into an AST.

    Raises ValueError on a malformed or incomplete expression (unbalanced
    parens, dangling operator, trailing tokens, empty input).
    """
    return _SpdxParser(text, _TOKEN_RE.findall(text)).parse()


class _UnconvertibleError(Exception):
    """An expression cannot be fully rendered to DEP-5 (e.g. unknown exception)."""


# SPDX licence-id -> DEP-5 name substitutions, applied in order (case-sensitive).
_LICENSE_ID_SUBS: tuple[tuple[reo.Pattern[str], str], ...] = tuple(
    (reo.compile(pattern), repl)
    for pattern, repl in (
        ("AFL-", "Academic-"),
        (r"(?<!-)AMPAS", "BSD-2-clause-AMPAS"),
        ("Attribution", "attribution"),
        (r"(?<!-)bzip2-1\.0\.[56]", "BSD-like-bzip2"),
        ("CC0-1.0", "CC0"),
        ("CECILL-", "CeCILL-"),
        ("Clause", "clause"),
        (r"(?<!-)Entessa", "BSD-like-Entessa"),
        ("ICU", "MIT-like-icu"),
        (r"(?<!-)Imlib2", "MIT-Imlib2"),
        ("LiLiQ-Rplus-", "LiLiQ-R+-"),
        (r"MIT\b(?![-/])", "Expat"),
        ("mpich2", "mpich"),
        ("No-Nuclear-License", "no-nuclear"),
        (r"(?<!-)Mup", "BSD-like-Mup"),
        ("Protection", "protection"),
        ("RSCPL", "RSCPL-1"),
        (r"(?<!-)Saxpath", "BSD-like-Saxpath"),
        (r"(?<!-)SMLNJ", "MIT-like-SMLNJ"),
        ("StandardML-NJ", "MIT-like-SMLNJ"),
        ("Source-Code", "source-code"),
        (r"(?<!-)TMate", "BSD-like-TMate"),
        (r"(?<!-)\bTOSL", "BSD-like-TOSL"),
        ("-only", ""),
        ("-or-later", "+"),
        (r"\.0(?![.\d])", ""),
    )
)


def _convert_license_id(token: str) -> str:
    """Convert a single SPDX licence id to its DEP-5 name.

    A deprecated combined id (e.g. ``GPL-2.0-with-classpath-exception``) is
    expanded through its modern ``... WITH ...`` form; otherwise the ordered
    substitution rules strip version qualifiers and apply name aliases.
    """
    modern = _DEPRECATED_LICENSE_EXCEPTIONS.get(token)
    if modern is not None:
        return spdx_expression_to_dep5(modern)
    for pattern, repl in _LICENSE_ID_SUBS:
        token = pattern.sub(repl, token)
    return token


def _render_with(node: With) -> str:
    base = _convert_license_id(node.license.id)
    label = _SPDX_EXCEPTION_MAP.get(node.exception)
    if label is None:
        raise _UnconvertibleError
    return f"{base} with {label} exception"


def _render_dep5(node: Node) -> str:
    if isinstance(node, License):
        return _convert_license_id(node.id)
    if isinstance(node, With):
        return _render_with(node)
    if isinstance(node, And):
        # DEP-5 sec 7.2: `and` binds tighter than `or`, so an `or` operand of an
        # `and` must be set off with a comma (", and") to bind first. Plain
        # " and " otherwise. (Mixed nesting deeper than one comma level cannot
        # be expressed; such rare trees are rendered best-effort.)
        parts = [_render_dep5(term) for term in node.terms]
        sep = ", and " if any(isinstance(t, Or) for t in node.terms) else " and "
        return sep.join(parts)
    return " or ".join(_render_dep5(term) for term in node.terms)


def spdx_expression_to_dep5(text: str) -> str:
    """Convert an SPDX licence expression to its DEP-5 form.

    Returns the input unchanged if it cannot be parsed or fully converted
    (e.g. an unknown exception id with no DEP-5 label).
    """
    try:
        return _render_dep5(parse_spdx_expression(text))
    except (ValueError, _UnconvertibleError):
        return text


def replace_spdx(string: str) -> str:
    """Convert an SPDX licence expression to DEP-5 (SPDX-License-Identifier path).

    Thin public alias over spdx_expression_to_dep5: a single identifier or a
    full OR/AND/WITH expression is parsed and rendered; anything that does not
    parse (or has an unknown exception) is returned unchanged.
    """
    return spdx_expression_to_dep5(string)


# Copyleft families where SPDX appends -only / -or-later.
# Pattern captures: (prefix)-(major)(minor)?(+)?
_COPYLEFT_RE = reo.compile(
    r"^(AGPL|GFDL|GPL|L?GPL)-(\d+)(\.\d+)?(\+)?$",
    reo.IGNORECASE,
)

# BSD-N-clause... -> BSD-N-Clause... (capitalise the word "clause").
_BSD_RE = reo.compile(r"^(BSD-\d+)-clause\b", reo.IGNORECASE)

# NAME-N (bare integer version, no decimal) -> NAME-N.0
_VERSION_RE = reo.compile(r"^(.+)-(\d+)$")


def dep5_to_spdx(name: str) -> str:
    """Convert a DEP-5 licence name or compound 'A or B' expression to SPDX.

    Compound DEP-5 expressions (joined with ' or ') become SPDX compound
    expressions (joined with ' OR ').  Unrecognised names are returned
    unchanged.
    """
    if " or " in name:
        return " OR ".join(dep5_to_spdx(part.strip()) for part in name.split(" or "))
    return _convert_single(name)


def render_license_name(name: str, options: Options | None) -> str:
    """Render a DEP-5 licence name for output, honouring the --spdx option."""
    return dep5_to_spdx(name) if options and options.spdx else name


def _convert_with_exception(name: str) -> str | None:
    """Convert "<base> with <label> exception" to "<spdx-base> WITH <id>".

    Returns None when `name` is not such an expression. The DEP-5 label already
    encodes the version variant, so the exception id is a plain lookup; an
    unmapped label yields the unchanged name.
    """
    if not (name.endswith(" exception") and " with " in name):
        return None
    base, _, label = name.partition(" with ")
    spdx_exc = exception_to_spdx(label[: -len(" exception")].strip())
    if spdx_exc is None:
        return name
    return f"{_convert_single(base.strip())} WITH {spdx_exc}"


def _convert_single(name: str) -> str:
    # 0. "<base> with <label> exception" -> SPDX WITH expression.
    with_expr = _convert_with_exception(name)
    if with_expr is not None:
        return with_expr

    # 1. Exact aliases
    if name in _ALIASES:
        return _ALIASES[name]

    # 2. BSD clause capitalisation: BSD-N-clause... -> BSD-N-Clause...
    bsd_m = _BSD_RE.match(name)
    if bsd_m:
        return name[: bsd_m.end(1)] + "-Clause" + name[bsd_m.end() :]

    # 3. Copyleft families (GPL, LGPL, AGPL, GFDL): add .0 if bare integer,
    #    then append -only or -or-later.
    cop_m = _COPYLEFT_RE.match(name)
    if cop_m:
        prefix = cop_m.group(1).upper()
        major = cop_m.group(2)
        minor = cop_m.group(3) or ".0"
        qualifier = "-or-later" if cop_m.group(4) else "-only"
        return f"{prefix}-{major}{minor}{qualifier}"

    # 4. General version restoration: NAME-N -> NAME-N.0 (bare integer only)
    ver_m = _VERSION_RE.match(name)
    if ver_m:
        return f"{ver_m.group(1)}-{ver_m.group(2)}.0"

    # 5. Pass through unchanged
    return name
