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

from decopy.licensing.license_expr import (
    Node,
    UnconvertibleError,
    map_leaves,
    parse_dep5_expression,
    parse_spdx_expression,
    render_dep5_expression,
)

if t.TYPE_CHECKING:
    from decopy.common.core_types import Options

# Complete-name substitutions (DEP-5 name -> SPDX identifier). The reverse of the
# exact renames canonical_license_name applies on read, so --spdx restores the
# SPDX id instead of emitting decopy's DEP-5 name. The bzip2 / SMLNJ entries are
# many-to-one on read (several SPDX ids collapse to one DEP-5 name); the current
# canonical SPDX id is the chosen representative for the reverse.
_ALIASES: dict[str, str] = {
    "CC0": "CC0-1.0",
    "Expat": "MIT",
    "MIT/X11": "MIT",
    "MIT-like-icu": "ICU",
    "MIT-Imlib2": "Imlib2",
    "MIT-like-SMLNJ": "SMLNJ",
    "BSD-like-Entessa": "Entessa",
    "BSD-like-Mup": "Mup",
    "BSD-like-Saxpath": "Saxpath",
    "BSD-like-TMate": "TMate",
    "BSD-like-TOSL": "TOSL",
    "BSD-like-bzip2": "bzip2-1.0.6",
    "mpich": "mpich2",
    "BSD-2-clause-AMPAS": "AMPAS",
    # Per-licence reverses with no shared pattern: a multi-component .0.0 or a
    # version before a non-version suffix that the version rule cannot restore
    # mid-string, and a couple of mid-string renames.
    "Artistic-1-Perl": "Artistic-1.0-Perl",
    "Artistic-1-cl8": "Artistic-1.0-cl8",
    "BSD-3-clause-no-nuclear": "BSD-3-Clause-No-Nuclear-License",
    "BSD-3-clause-no-nuclear-2014": "BSD-3-Clause-No-Nuclear-License-2014",
    "BlueOak-1.0": "BlueOak-1.0.0",
    "CAL-1-Combined-Work-Exception": "CAL-1.0-Combined-Work-Exception",
    "FSL-1.1-Expat": "FSL-1.1-MIT",
    "LiLiQ-R+-1.1": "LiLiQ-Rplus-1.1",
    "MPL-2-no-copyleft-exception": "MPL-2.0-no-copyleft-exception",
    "OFL-1-RFN": "OFL-1.0-RFN",
    "OFL-1-no-RFN": "OFL-1.0-no-RFN",
    "Parity-6.0": "Parity-6.0.0",
    "Parity-7.0": "Parity-7.0.0",
    "PolyForm-Noncommercial-1.0": "PolyForm-Noncommercial-1.0.0",
    "PolyForm-Small-Business-1.0": "PolyForm-Small-Business-1.0.0",
    "QPL-1-INRIA-2004": "QPL-1.0-INRIA-2004",
    "RSCPL-1": "RSCPL",
    "copyleft-next-0.3": "copyleft-next-0.3.0",
}

# 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)


def exception_to_dep5(exception_id: str) -> str | None:
    """SPDX exception ID -> DEP-5 exception label, or None if unknown.

    The public read of the SPDX-exception map (inverse of exception_to_spdx).
    Several SPDX IDs collapse onto one label (the -generic / -note / -short /
    dated variants), so this direction is many-to-one.
    """
    return _SPDX_EXCEPTION_MAP.get(exception_id)


# 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 canonical_license_name(token: str) -> str:
    """Canonical DEP-5 name for a single licence id.

    The single licence-name normalisation point: maps any SPDX-or-DEP-5 spelling
    of a recognised licence to decopy's canonical DEP-5 short name (``GPL-2.0-only``
    -> ``GPL-2``, ``MIT`` -> ``Expat``, ``Apache-2.0`` -> ``Apache-2``). It is
    idempotent (a name already canonical is returned unchanged) and identity on
    any name it does not recognise -- it never mangles a bespoke licence 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 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). The leaf conversions
    (id -> name, exception id -> label) are this module's; the AST structure and
    comma rendering live in license_expr.
    """
    try:
        return render_dep5_expression(
            parse_spdx_expression(text),
            convert_id=canonical_license_name,
            convert_exception=_SPDX_EXCEPTION_MAP.get,
        )
    except (ValueError, UnconvertibleError):
        return text


def parse_license_synopsis(synopsis: str) -> Node:
    """Parse a debian/copyright License field into a DEP-5-vocabulary AST.

    A field containing parentheses is decopy's SPDX fallback form (DEP-5 has no
    parens): parse it as SPDX and convert the leaves to DEP-5 names. Otherwise
    parse it as a DEP-5 expression. Either way the leaf names are canonicalised
    (GPL-2.0-only -> GPL-2, MIT -> Expat), so a field written in any recognised
    spelling keys the same group a freshly detected file does. Raises ValueError
    on a malformed field.
    """
    if "(" in synopsis or ")" in synopsis:
        spdx_ast = parse_spdx_expression(synopsis)
        return map_leaves(
            spdx_ast,
            canonical_license_name,
            lambda exc: _SPDX_EXCEPTION_MAP.get(exc, exc),
        )
    return map_leaves(parse_dep5_expression(synopsis), canonical_license_name)


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)

# GFDL invariants form: capture (GFDL-<ver>-[no-]invariants) and optional (+).
_GFDL_INVARIANTS_RE = reo.compile(r"^(GFDL-\d+\.\d+-(?:no-)?invariants)(\+)?$")

# CC-BY jurisdiction port: capture (family + single-digit major) and (suffix).
# The BY/NC/SA/ND modifiers are 2 letters each; the jurisdiction suffix is a
# 2-3 letter code (AT, DE, UK, IGO, ...).
_CCBY_PORT_RE = reo.compile(r"^(CC-BY(?:-[A-Z]{2})*-[1-9])(-[A-Z]{2,3})$")

# NAME-N (single-digit 1-9 major version, no decimal) -> NAME-N.0
# Only a single non-zero digit is a version here: every bare-integer SPDX major
# in the corpus is one digit (Apache-2, Academic-3, ...), while multi-digit and
# zero trailing groups are years/dates/identifiers, not versions (Adobe-2006,
# W3C-19980720, Spencer-86, MIT-0) and must not gain a spurious .0.
_VERSION_RE = reo.compile(r"^(.+)-([1-9])$")

# Reverse of canonical_license_name's family *prefix* renames (DEP-5 -> SPDX),
# anchored at the start so only the family token reverts and any version stays
# for _VERSION_RE to restore (Academic-2 -> AFL-2; CeCILL-1 -> CECILL-1).
_REVERSE_RENAMES: tuple[tuple[reo.Pattern[str], str], ...] = tuple(
    (reo.compile(pattern), repl)
    for pattern, repl in (
        ("^Academic-", "AFL-"),
        ("^CeCILL-", "CECILL-"),
    )
)


def _apply_reverse_renames(name: str) -> str:
    for pattern, repl in _REVERSE_RENAMES:
        name = pattern.sub(repl, name)
    return name


# Reverse of the forward subs that lower-case these SPDX words on read. Word-
# bounded so only the whole token reverts -- neighbouring words SPDX keeps
# lower-case (disclaimer, acknowledgment, no-conversion) are untouched.
_SUFFIX_CASE: tuple[tuple[reo.Pattern[str], str], ...] = tuple(
    (reo.compile(rf"\b{low}\b"), cap)
    for low, cap in (
        ("clause", "Clause"),
        ("attribution", "Attribution"),
        ("protection", "Protection"),
        ("source-code", "Source-Code"),
    )
)


def _recapitalise_suffix(name: str) -> str:
    for pattern, repl in _SUFFIX_CASE:
        name = pattern.sub(repl, name)
    return name


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 _rule_bsd_clause(name: str) -> str | None:
    """BSD-N-clause... -> BSD-N-Clause... (capitalise the word "clause")."""
    match = _BSD_RE.match(name)
    if not match:
        return None
    return name[: match.end(1)] + "-Clause" + name[match.end() :]


def _rule_gfdl_invariants(name: str) -> str | None:
    """GFDL invariants variant the copyleft rule skips (its -invariants suffix
    breaks that anchor): GFDL-1.1-invariants -> GFDL-1.1-invariants-only;
    GFDL-1.2-no-invariants+ -> GFDL-1.2-no-invariants-or-later."""
    match = _GFDL_INVARIANTS_RE.match(name)
    if not match:
        return None
    qualifier = "-or-later" if match.group(2) else "-only"
    return f"{match.group(1)}{qualifier}"


def _rule_copyleft(name: str) -> str | None:
    """Copyleft (GPL/LGPL/AGPL/GFDL): add .0 if bare integer, then -only/-or-later."""
    match = _COPYLEFT_RE.match(name)
    if not match:
        return None
    minor = match.group(3) or ".0"
    qualifier = "-or-later" if match.group(4) else "-only"
    return f"{match.group(1).upper()}-{match.group(2)}{minor}{qualifier}"


def _rule_ccby_port(name: str) -> str | None:
    """CC-BY jurisdiction port: restore the major's .0 before the suffix.

    A port keeps a 2-3 letter jurisdiction suffix (CC-BY-3-AT, CC-BY-NC-SA-2-DE),
    so the major's .0 sits mid-string and the general version rule (anchored at
    the end) cannot restore it. The BY/NC/SA/ND modifiers are 2 letters each.
    """
    match = _CCBY_PORT_RE.match(name)
    if not match:
        return None
    return f"{match.group(1)}.0{match.group(2)}"


def _rule_version(name: str) -> str | None:
    """General version restoration: NAME-N -> NAME-N.0 (single-digit major only)."""
    match = _VERSION_RE.match(name)
    if not match:
        return None
    return f"{match.group(1)}-{match.group(2)}.0"


# Ordered DEP-5 -> SPDX conversion rules; the first to return non-None wins. New
# reverse-conversion families (CC-BY ports, GFDL invariants, ...) slot in here.
_SINGLE_RULES: tuple[t.Callable[[str], str | None], ...] = (
    _rule_bsd_clause,
    _rule_gfdl_invariants,
    _rule_copyleft,
    _rule_ccby_port,
    _rule_version,
)


def _convert_single(name: str) -> str:
    # A "<base> with <label> exception" expression converts as a whole first.
    with_expr = _convert_with_exception(name)
    if with_expr is not None:
        return with_expr

    # An exact full-name alias matches the DEP-5 name as written, before the
    # structural transforms below could rewrite it (e.g. the suffix-casing pass
    # must not disturb the BSD-2-clause-AMPAS alias key).
    alias = _ALIASES.get(name)
    if alias is not None:
        return alias

    # Reverse the family prefix renames before the rules, so e.g. the version
    # rule then sees AFL-2 (from Academic-2) and restores AFL-2.0.
    name = _apply_reverse_renames(name)
    # Restore the SPDX casing of suffix words the read path lower-cased.
    name = _recapitalise_suffix(name)

    for rule in _SINGLE_RULES:
        result = rule(name)
        if result is not None:
            return result
    return name  # pass through unchanged
