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

"""Licence-expression AST, parsers and renderers.

A single AST (License/With/And/Or) is the internal representation for licence
expressions. SPDX expressions parse into it via parse_spdx_expression(); it
renders to DEP-5 short-name form via render_dep5_expression(). The leaf
conversions (SPDX id -> DEP-5 name, SPDX exception id -> DEP-5 label) are
spdx-specific knowledge, so they are injected into the renderer rather than
imported -- this module is a leaf and never imports spdx.
"""

from __future__ import annotations

import re as reo
import typing as t
from collections.abc import Callable, Iterator


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


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

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


class _ExprParser:
    """Shared token cursor for the recursive-descent expression parsers."""

    _kind = "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 {self._kind}: {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


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

    _kind = "SPDX expression"

    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 _normalize_operator(token: str) -> str:
    """Uppercase a token that is an operator word, leaving ids untouched.

    SPDX mandates uppercase AND/OR/WITH, but real-world source writes them in any
    case (e.g. `MIT or Apache-2.0`). The detection regex already matches operators
    case-insensitively, so accept them here too. No SPDX id or exception id is the
    bare word and/or/with, so this cannot corrupt an id; parens are separate
    tokens and are left alone.
    """
    upper = token.upper()
    return upper if upper in {"AND", "OR", "WITH"} else token


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

    Operators are accepted case-insensitively. Raises ValueError on a malformed
    or incomplete expression (unbalanced parens, dangling operator, trailing
    tokens, empty input).
    """
    tokens = [_normalize_operator(tok) for tok in _TOKEN_RE.findall(text)]
    return _SpdxParser(text, tokens).parse()


# DEP-5 licence-expression parser (copyright-format 1.0 sec 7.2)
#
# Grammar (loosest -> tightest); the comma is a precedence level looser than
# `or`, applied as a left-associative fold:
#   comma-expr := or-expr ( "," ("and"|"or") or-expr )*
#   or-expr    := and-expr ( "or" and-expr )*
#   and-expr   := with-expr ( "and" with-expr )*
#   with-expr  := license-id ( "with" label "exception" )?
# Operators are lowercase, matched case-insensitively; tokens split on
# whitespace and commas, so a licence name merely containing "and"/"or" (e.g.
# "Sandia") is never split. The reader is lenient: it accepts the documented
# comma-before-`and` override and the undocumented `, or` / multi-comma forms.

_DEP5_TOKEN_RE = reo.compile(r",|[^\s,]+")
_DEP5_KEYWORDS = frozenset({"and", "or", "with", "exception"})


class _Dep5Parser(_ExprParser):
    """Recursive-descent parser for a DEP-5 short-name licence expression."""

    _kind = "DEP-5 expression"

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

    def _peek_is(self, word: str) -> bool:
        tok = self._peek()
        return tok is not None and tok.lower() == word

    def _comma_expr(self) -> Node:
        acc = self._or_expr()
        while self._peek() == ",":
            self._advance()
            op = self._advance()
            if op.lower() not in ("and", "or"):
                self._fail("expected 'and' or 'or' after comma")
            rhs = self._or_expr()
            acc = And((acc, rhs)) if op.lower() == "and" else Or((acc, rhs))
        return acc

    def _or_expr(self) -> Node:
        terms = [self._and_expr()]
        while self._peek_is("or"):
            self._advance()
            terms.append(self._and_expr())
        return terms[0] if len(terms) == 1 else Or(tuple(terms))

    def _and_expr(self) -> Node:
        terms = [self._with_expr()]
        while self._peek_is("and"):
            self._advance()
            terms.append(self._with_expr())
        return terms[0] if len(terms) == 1 else And(tuple(terms))

    def _with_expr(self) -> Node:
        node = self._atom()
        if not self._peek_is("with"):
            return node
        self._advance()  # "with"
        label = self._advance()
        if label.lower() in _DEP5_KEYWORDS:
            self._fail("expected exception label after 'with'")
        if not self._peek_is("exception"):
            self._fail("expected 'exception' to close 'with' clause")
        self._advance()  # "exception"
        return With(node, label)

    def _atom(self) -> License:
        tok = self._peek()
        if tok is None:
            self._fail("unexpected end")
        if tok == "," or tok.lower() in _DEP5_KEYWORDS:
            self._fail(f"unexpected {tok!r}")
        self._advance()
        return License(tok)


def parse_dep5_expression(text: str) -> Node:
    """Parse a DEP-5 short-name licence expression into an AST.

    Raises ValueError on empty or malformed input. The reader is lenient (see
    the grammar comment): it parses the documented comma-before-`and` override
    and the undocumented `, or` / multi-comma forms a real-world field may carry.
    """
    return _Dep5Parser(text, _DEP5_TOKEN_RE.findall(text)).parse()


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


def _identity(name: str) -> str:
    return name


def _render_with(
    node: With,
    convert_id: Callable[[str], str],
    convert_exception: Callable[[str], str | None],
) -> str:
    base = convert_id(node.license.id)
    label = convert_exception(node.exception)
    if label is None:
        raise UnconvertibleError
    return f"{base} with {label} exception"


def render_dep5_expression(
    node: Node,
    convert_id: Callable[[str], str] = _identity,
    convert_exception: Callable[[str], str | None] = _identity,
) -> str:
    """Render a licence-expression AST to its DEP-5 short-name form.

    convert_id maps a leaf id to its DEP-5 name and convert_exception maps an
    exception id to its DEP-5 label (returning None when unknown, which raises
    UnconvertibleError). Both default to identity, which is what a DEP-5-sourced
    AST -- whose leaves already hold DEP-5 names/labels -- round-trips through;
    the SPDX path injects the spdx.py converters.
    """
    if isinstance(node, License):
        return convert_id(node.id)
    if isinstance(node, With):
        return _render_with(node, convert_id, convert_exception)
    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_expression(term, convert_id, convert_exception)
            for term in node.terms
        ]
        sep = ", and " if any(isinstance(term, Or) for term in node.terms) else " and "
        return sep.join(parts)
    return " or ".join(
        render_dep5_expression(term, convert_id, convert_exception)
        for term in node.terms
    )


def _spdx_paren(
    node: Node,
    convert_name: Callable[[str], str],
    convert_exception: Callable[[str], str],
) -> str:
    """Render `node` as an SPDX operand, parenthesising a compound (And/Or)."""
    inner = render_spdx_expression(node, convert_name, convert_exception)
    return f"({inner})" if isinstance(node, (And, Or)) else inner


def render_spdx_expression(
    node: Node,
    convert_name: Callable[[str], str] = _identity,
    convert_exception: Callable[[str], str] = _identity,
) -> str:
    """Render a licence-expression AST to an unambiguous SPDX string.

    Every compound operand is parenthesised, so the result round-trips through
    parse_spdx_expression(). Used both as the fallback for trees DEP-5 cannot
    express and as the --spdx output path. convert_name maps a leaf's DEP-5 name
    to its SPDX id and convert_exception maps an exception label to its SPDX id
    (both default to identity, which leaves an SPDX-sourced AST unchanged).
    """
    if isinstance(node, License):
        return convert_name(node.id)
    if isinstance(node, With):
        base = convert_name(node.license.id)
        return f"{base} WITH {convert_exception(node.exception)}"
    joiner = " AND " if isinstance(node, And) else " OR "
    return joiner.join(
        _spdx_paren(term, convert_name, convert_exception) for term in node.terms
    )


def map_leaves(
    node: Node,
    convert_id: Callable[[str], str] = _identity,
    convert_exception: Callable[[str], str] = _identity,
) -> Node:
    """Rebuild `node` with each leaf name (and With exception) converted.

    Structural only: moves an AST between vocabularies (e.g. an SPDX-parsed tree
    into DEP-5 names) without touching its and/or/with shape.
    """
    if isinstance(node, License):
        return License(convert_id(node.id))
    if isinstance(node, With):
        return With(
            License(convert_id(node.license.id)), convert_exception(node.exception)
        )
    mapped = (map_leaves(term, convert_id, convert_exception) for term in node.terms)
    if isinstance(node, And):
        return And(tuple(mapped))
    return Or(tuple(mapped))


def _leaf_keys(node: Node) -> Iterator[str]:
    """Yield each leaf's DEP-5 key (a With's full 'NAME with LABEL exception')."""
    if isinstance(node, License):
        yield node.id
    elif isinstance(node, With):
        yield render_dep5_expression(node)
    else:
        for term in node.terms:
            yield from _leaf_keys(term)


def license_atoms(node: Node) -> list[str]:
    """The leaf licence names of an expression, in encounter order, de-duplicated.

    A With node contributes its full DEP-5 'NAME with LABEL exception' string,
    which is the licence key decopy groups by. Used by the read path to populate
    a group's licence set from a parsed expression regardless of its and/or shape.
    """
    atoms: list[str] = []
    seen: set[str] = set()
    for key in _leaf_keys(node):
        if key not in seen:
            seen.add(key)
            atoms.append(key)
    return atoms
