#!/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 SPDX licence-expression parsing and DEP-5 rendering.

DEP-5 (spec sec 7.2) uses lowercase or/and with `and` binding tighter than
`or`, and a comma -- not parentheses -- to override priority. SPDX uses
uppercase AND/OR/WITH and parentheses, with the same `AND > OR` precedence.
"""

import unittest


class TestSpdxExpressionToDep5(unittest.TestCase):
    """SPDX expression -> DEP-5 string."""

    def test_simple_and_with(self):
        from decopy.licensing.spdx import spdx_expression_to_dep5

        cases = [
            ("GPL-2.0-only", "GPL-2"),
            ("GPL-2.0+", "GPL-2+"),
            (
                "GPL-2.0-or-later WITH Font-exception-2.0",
                "GPL-2+ with Font exception",
            ),
        ]
        for spdx, dep5 in cases:
            with self.subTest(spdx=spdx):
                self.assertEqual(spdx_expression_to_dep5(spdx), dep5)

    def test_disjunction_and_conjunction(self):
        from decopy.licensing.spdx import spdx_expression_to_dep5

        cases = [
            # spec sec 7.2 examples (dual-licence, combined-work)
            ("GPL-1.0-or-later OR Artistic-2.0", "GPL-1+ or Artistic-2"),
            ("GPL-2.0-only AND BSD-3-Clause", "GPL-2 and BSD-3-clause"),
            ("MIT OR Apache-2.0 OR ISC", "Expat or Apache-2 or ISC"),
            # default `and > or`: no comma needed
            (
                "GPL-2.0-only OR Artistic-2.0 AND BSD-3-Clause",
                "GPL-2 or Artistic-2 and BSD-3-clause",
            ),
        ]
        for spdx, dep5 in cases:
            with self.subTest(spdx=spdx):
                self.assertEqual(spdx_expression_to_dep5(spdx), dep5)

    def test_grouping_renders_with_comma(self):
        from decopy.licensing.spdx import spdx_expression_to_dep5

        # An `or` grouped under an `and` is the one case DEP-5 expresses with a
        # comma (a parenthesised SPDX `or` operand of an `and`).
        cases = [
            ("(GPL-2.0-only OR MIT) AND Apache-2.0", "GPL-2 or Expat, and Apache-2"),
            ("Apache-2.0 AND (MIT OR ISC)", "Apache-2, and Expat or ISC"),
            (
                "(GPL-2.0-only OR MIT) AND (Apache-2.0 OR ISC)",
                "GPL-2 or Expat, and Apache-2 or ISC",
            ),
        ]
        for spdx, dep5 in cases:
            with self.subTest(spdx=spdx):
                self.assertEqual(spdx_expression_to_dep5(spdx), dep5)

    def test_with_in_compound_and_deprecated_token(self):
        from decopy.licensing.spdx import spdx_expression_to_dep5

        cases = [
            # WITH binds tighter than OR: no comma
            (
                "GPL-2.0-or-later WITH Classpath-exception-2.0 OR MIT",
                "GPL-2+ with ClassPath exception or Expat",
            ),
            # deprecated combined single token survives as one leaf
            (
                "GPL-2.0-with-classpath-exception",
                "GPL-2 with ClassPath exception",
            ),
        ]
        for spdx, dep5 in cases:
            with self.subTest(spdx=spdx):
                self.assertEqual(spdx_expression_to_dep5(spdx), dep5)


class TestSpdxLowercaseOperators(unittest.TestCase):
    """Operators are accepted case-insensitively.

    The SPDX spec mandates uppercase AND/OR/WITH, but real-world source (e.g.
    ruff's `SPDX-License-Identifier: MIT or Apache-2.0`) uses lowercase. The
    detection regex already matches case-insensitively, so the parser must too;
    otherwise the expression fails to parse and canonicalization is skipped.
    """

    def test_lowercase_operators_canonicalize(self):
        from decopy.licensing.spdx import spdx_expression_to_dep5

        cases = [
            ("MIT or Apache-2.0", "Expat or Apache-2"),
            ("GPL-2.0-only and BSD-3-Clause", "GPL-2 and BSD-3-clause"),
            (
                "GPL-2.0-or-later with Font-exception-2.0",
                "GPL-2+ with Font exception",
            ),
            # mixed case
            ("MIT Or Apache-2.0", "Expat or Apache-2"),
        ]
        for spdx, dep5 in cases:
            with self.subTest(spdx=spdx):
                self.assertEqual(spdx_expression_to_dep5(spdx), dep5)

    def test_lowercase_operators_parse_same_tree(self):
        from decopy.licensing.license_expr import (
            And,
            License,
            Or,
            parse_spdx_expression,
        )

        self.assertEqual(
            parse_spdx_expression("A or B and C"),
            Or((License("A"), And((License("B"), License("C"))))),
        )

    def test_uppercase_still_parses(self):
        from decopy.licensing.spdx import spdx_expression_to_dep5

        # No regression on the conformant uppercase form.
        self.assertEqual(
            spdx_expression_to_dep5("MIT OR Apache-2.0"), "Expat or Apache-2"
        )


class TestSpdxExpressionParse(unittest.TestCase):
    """Parse tree shape and precedence."""

    def test_and_binds_tighter_than_or(self):
        from decopy.licensing.license_expr import (
            And,
            License,
            Or,
            parse_spdx_expression,
        )

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

    def test_with_binds_tighter_than_or(self):
        from decopy.licensing.license_expr import (
            License,
            Or,
            With,
            parse_spdx_expression,
        )

        self.assertEqual(
            parse_spdx_expression("A WITH E OR B"),
            Or((With(License("A"), "E"), License("B"))),
        )

    def test_parentheses_override_precedence(self):
        from decopy.licensing.license_expr import (
            And,
            License,
            Or,
            parse_spdx_expression,
        )

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


class TestParseLicenseSynopsis(unittest.TestCase):
    """parse_license_synopsis(): read a License field that is DEP-5 or SPDX.

    decopy emits SPDX (with parens) as the fallback for expressions DEP-5 cannot
    represent, so the reader must accept both and normalise to DEP-5 vocabulary.
    """

    def test_dep5_field_parsed_as_dep5(self):
        from decopy.licensing.license_expr import And, License
        from decopy.licensing.spdx import parse_license_synopsis

        self.assertEqual(
            parse_license_synopsis("GPL-2+ and BSD-3-clause"),
            And((License("GPL-2+"), License("BSD-3-clause"))),
        )

    def test_spdx_field_parsed_and_converted_to_dep5_names(self):
        from decopy.licensing.license_expr import And, License, Or
        from decopy.licensing.spdx import parse_license_synopsis

        # Parens mark the SPDX fallback form; leaves convert to DEP-5 names.
        self.assertEqual(
            parse_license_synopsis("(MIT OR Apache-2.0) AND GPL-2.0-or-later"),
            And(
                (
                    Or((License("Expat"), License("Apache-2"))),
                    License("GPL-2+"),
                )
            ),
        )


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