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

"""Tests for the licence-expression module (AST, parsers, renderers).

Cover the public surface: the AST node types, the SPDX and DEP-5 parsers, the
DEP-5 and SPDX renderers with their injected leaf converters, license_atoms and
map_leaves.
"""

import unittest

from decopy.licensing.license_expr import (
    And,
    License,
    Or,
    UnconvertibleError,
    With,
    license_atoms,
    map_leaves,
    parse_dep5_expression,
    parse_spdx_expression,
    render_dep5_expression,
    render_spdx_expression,
)


class TestRenderDep5Expression(unittest.TestCase):
    """render_dep5_expression() with the default identity converters."""

    def test_single_license(self):
        self.assertEqual(render_dep5_expression(License("MIT")), "MIT")

    def test_or_join(self):
        tree = Or((License("A"), License("B")))
        self.assertEqual(render_dep5_expression(tree), "A or B")

    def test_and_join(self):
        tree = And((License("A"), License("B")))
        self.assertEqual(render_dep5_expression(tree), "A and B")

    def test_or_operand_of_and_gets_comma(self):
        # DEP-5 sec 7.2: an `or` grouped under an `and` is set off with a comma.
        tree = And((Or((License("A"), License("B"))), License("C")))
        self.assertEqual(render_dep5_expression(tree), "A or B, and C")

    def test_with_uses_identity_label_by_default(self):
        # The default identity convert_exception keeps the label verbatim, so a
        # DEP-5-sourced With node round-trips without an SPDX lookup.
        tree = With(License("GPL-2+"), "OpenSSL")
        self.assertEqual(render_dep5_expression(tree), "GPL-2+ with OpenSSL exception")


class TestRenderDep5InjectedConverters(unittest.TestCase):
    """The injected convert_id / convert_exception hooks."""

    def test_convert_id_applied_to_leaves(self):
        tree = Or((License("x"), License("y")))
        self.assertEqual(render_dep5_expression(tree, convert_id=str.upper), "X or Y")

    def test_unknown_exception_raises(self):
        # convert_exception returning None (an unknown exception) is unrenderable.
        tree = With(License("GPL-2+"), "Nope")
        with self.assertRaises(UnconvertibleError):
            render_dep5_expression(tree, convert_exception=lambda _e: None)


class TestParseSpdxExpression(unittest.TestCase):
    """parse_spdx_expression() lives here now; precedence sanity checks."""

    def test_and_binds_tighter_than_or(self):
        self.assertEqual(
            parse_spdx_expression("A OR B AND C"),
            Or((License("A"), And((License("B"), License("C"))))),
        )

    def test_malformed_raises(self):
        with self.assertRaises(ValueError):
            parse_spdx_expression("(A OR B")


class TestParseDep5Expression(unittest.TestCase):
    """parse_dep5_expression(): DEP-5 short-name expression -> AST."""

    def test_or(self):
        self.assertEqual(
            parse_dep5_expression("A or B"), Or((License("A"), License("B")))
        )

    def test_and(self):
        self.assertEqual(
            parse_dep5_expression("A and B"), And((License("A"), License("B")))
        )

    def test_and_binds_tighter_than_or(self):
        self.assertEqual(
            parse_dep5_expression("A or B and C"),
            Or((License("A"), And((License("B"), License("C"))))),
        )

    def test_comma_overrides_precedence(self):
        # The one documented comma case: ", and" loosens the `and` below `or`.
        self.assertEqual(
            parse_dep5_expression("A or B, and C"),
            And((Or((License("A"), License("B"))), License("C"))),
        )

    def test_spec_complex_example(self):
        self.assertEqual(
            parse_dep5_expression("GPL-2+ or Artistic-2.0, and BSD-3-clause"),
            And(
                (
                    Or((License("GPL-2+"), License("Artistic-2.0"))),
                    License("BSD-3-clause"),
                )
            ),
        )

    def test_lenient_comma_or_left_fold(self):
        # Undocumented `, or` plus a second comma; the reader must still parse it.
        self.assertEqual(
            parse_dep5_expression("B or C, and D, or A"),
            Or(
                (
                    And((Or((License("B"), License("C"))), License("D"))),
                    License("A"),
                )
            ),
        )

    def test_with_exception(self):
        self.assertEqual(
            parse_dep5_expression("GPL-2+ with OpenSSL exception"),
            With(License("GPL-2+"), "OpenSSL"),
        )

    def test_name_containing_operator_substring_not_split(self):
        # "Sandia" contains "and"; "Borceux" contains "or" -- neither is split,
        # because tokenisation is on whitespace/commas, not substrings.
        self.assertEqual(
            parse_dep5_expression("Sandia and Borceux"),
            And((License("Sandia"), License("Borceux"))),
        )

    def test_empty_raises(self):
        with self.assertRaises(ValueError):
            parse_dep5_expression("")
        with self.assertRaises(ValueError):
            parse_dep5_expression("   ")


class TestDep5RoundTrip(unittest.TestCase):
    """render_dep5_expression(parse_dep5_expression(s)) == s, documented subset."""

    def test_round_trip(self):
        for s in (
            "A or B",
            "A and B",
            "A or B and C",
            "A or B, and C",
            "GPL-2+ or Artistic-2.0, and BSD-3-clause",
            "GPL-2+ with OpenSSL exception",
        ):
            with self.subTest(expr=s):
                self.assertEqual(render_dep5_expression(parse_dep5_expression(s)), s)


class TestLicenseAtoms(unittest.TestCase):
    """license_atoms(): leaf names in order, de-duplicated."""

    def test_flat_or(self):
        self.assertEqual(license_atoms(parse_dep5_expression("A or B")), ["A", "B"])

    def test_nested(self):
        self.assertEqual(
            license_atoms(parse_dep5_expression("A or B, and C")), ["A", "B", "C"]
        )

    def test_with_contributes_full_string(self):
        self.assertEqual(
            license_atoms(With(License("GPL-2+"), "OpenSSL")),
            ["GPL-2+ with OpenSSL exception"],
        )

    def test_dedup_preserves_order(self):
        self.assertEqual(
            license_atoms(parse_dep5_expression("A or A or B")), ["A", "B"]
        )


class TestRenderSpdxExpression(unittest.TestCase):
    """render_spdx_expression(): AST -> unambiguous parenthesised SPDX."""

    def test_single(self):
        self.assertEqual(render_spdx_expression(License("MIT")), "MIT")

    def test_flat_and_or(self):
        self.assertEqual(
            render_spdx_expression(And((License("A"), License("B")))), "A AND B"
        )
        self.assertEqual(
            render_spdx_expression(Or((License("A"), License("B")))), "A OR B"
        )

    def test_with(self):
        self.assertEqual(
            render_spdx_expression(With(License("GPL-2+"), "Classpath-exception-2.0")),
            "GPL-2+ WITH Classpath-exception-2.0",
        )

    def test_convert_name_applied(self):
        tree = Or((License("a"), License("b")))
        self.assertEqual(render_spdx_expression(tree, convert_name=str.upper), "A OR B")

    def test_convert_exception_applied(self):
        # The exception id is converted by convert_exception (default identity).
        self.assertEqual(
            render_spdx_expression(
                With(License("a"), "x"),
                convert_name=str.upper,
                convert_exception=str.upper,
            ),
            "A WITH X",
        )

    def test_compound_operands_parenthesised(self):
        # A tree DEP-5 cannot hold without losing meaning, rendered as SPDX.
        tree = Or((License("A"), And((Or((License("B"), License("C"))), License("D")))))
        self.assertEqual(render_spdx_expression(tree), "A OR ((B OR C) AND D)")

    def test_two_independent_clauses(self):
        # An or of two independent and-over-or clauses is inexpressible in DEP-5.
        tree = Or(
            (
                And((Or((License("A"), License("B"))), License("C"))),
                And((Or((License("D"), License("E"))), License("F"))),
            )
        )
        self.assertEqual(
            render_spdx_expression(tree),
            "((A OR B) AND C) OR ((D OR E) AND F)",
        )


class TestMapLeaves(unittest.TestCase):
    """map_leaves(): rebuild an AST with converted leaf names/exceptions."""

    def test_identity_default(self):
        tree = And((Or((License("A"), License("B"))), License("C")))
        self.assertEqual(map_leaves(tree), tree)

    def test_converts_license_and_with_leaves(self):
        tree = Or((License("a"), With(License("b"), "x")))
        self.assertEqual(
            map_leaves(tree, convert_id=str.upper, convert_exception=str.upper),
            Or((License("A"), With(License("B"), "X"))),
        )


class TestSpdxRoundTrip(unittest.TestCase):
    """SPDX is unambiguous: parse(render(ast)) == ast for the fallback trees."""

    def test_round_trip(self):
        trees = (
            Or((License("A"), And((Or((License("B"), License("C"))), License("D"))))),
            Or(
                (
                    And((Or((License("A"), License("B"))), License("C"))),
                    And((Or((License("D"), License("E"))), License("F"))),
                )
            ),
        )
        for tree in trees:
            with self.subTest(tree=tree):
                self.assertEqual(
                    parse_spdx_expression(render_spdx_expression(tree)), tree
                )


if __name__ == "__main__":
    unittest.main()
