#!/usr/bin/env python3
# Copyright: 2016, 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.

import io
import sys
import typing
import unittest
from unittest.mock import patch

from decopy import cmdoptions, core_types, dep5, nodes, output, tree
from decopy.core_types import Options
from decopy.group import TallyTracker
from decopy.renderer import Dep5Renderer
from decopy.spdx import dep5_to_spdx


class TestDep5ToSpdx(unittest.TestCase):
    # --- Name aliases ---

    def test_mit_x11_alias(self):
        self.assertEqual(dep5_to_spdx("MIT/X11"), "MIT")

    def test_expat_alias(self):
        # Expat is Debian's DEP-5 name for the MIT licence
        self.assertEqual(dep5_to_spdx("Expat"), "MIT")

    def test_cc0_alias(self):
        self.assertEqual(dep5_to_spdx("CC0"), "CC0-1.0")

    # --- Non-copyleft version restoration (bare integer → add .0) ---

    def test_apache_version_restoration(self):
        self.assertEqual(dep5_to_spdx("Apache-2"), "Apache-2.0")
        # Apache-1.1 already has a minor version - no .0 appended
        self.assertEqual(dep5_to_spdx("Apache-1.1"), "Apache-1.1")

    def test_mpl_version_restoration(self):
        self.assertEqual(dep5_to_spdx("MPL-2"), "MPL-2.0")
        self.assertEqual(dep5_to_spdx("MPL-1"), "MPL-1.0")

    def test_epl_version_restoration(self):
        self.assertEqual(dep5_to_spdx("EPL-1"), "EPL-1.0")
        self.assertEqual(dep5_to_spdx("EPL-2"), "EPL-2.0")

    def test_cddl_version_restoration(self):
        self.assertEqual(dep5_to_spdx("CDDL-1"), "CDDL-1.0")

    def test_artistic_version_restoration(self):
        self.assertEqual(dep5_to_spdx("Artistic-1"), "Artistic-1.0")
        self.assertEqual(dep5_to_spdx("Artistic-2"), "Artistic-2.0")

    def test_ofl_version_restoration(self):
        self.assertEqual(dep5_to_spdx("OFL-1"), "OFL-1.0")
        # OFL-1.1 already has minor version - unchanged
        self.assertEqual(dep5_to_spdx("OFL-1.1"), "OFL-1.1")

    def test_eupl_version_restoration(self):
        # EUPL uses -only/-or-later in practice but SPDX does not - treat as non-copyleft  # noqa: E501
        self.assertEqual(dep5_to_spdx("EUPL-1"), "EUPL-1.0")
        self.assertEqual(dep5_to_spdx("EUPL-1.1"), "EUPL-1.1")

    # --- Copyleft -only (GPL / LGPL / AGPL / GFDL) ---

    def test_gpl_only(self):
        self.assertEqual(dep5_to_spdx("GPL-1"), "GPL-1.0-only")
        self.assertEqual(dep5_to_spdx("GPL-2"), "GPL-2.0-only")
        self.assertEqual(dep5_to_spdx("GPL-3"), "GPL-3.0-only")

    def test_lgpl_only(self):
        self.assertEqual(dep5_to_spdx("LGPL-2"), "LGPL-2.0-only")
        # LGPL-2.1 already has a minor version - just add -only
        self.assertEqual(dep5_to_spdx("LGPL-2.1"), "LGPL-2.1-only")
        self.assertEqual(dep5_to_spdx("LGPL-3"), "LGPL-3.0-only")

    def test_agpl_only(self):
        self.assertEqual(dep5_to_spdx("AGPL-3"), "AGPL-3.0-only")
        self.assertEqual(dep5_to_spdx("AGPL-1"), "AGPL-1.0-only")

    def test_gfdl_only(self):
        self.assertEqual(dep5_to_spdx("GFDL-1.3"), "GFDL-1.3-only")
        self.assertEqual(dep5_to_spdx("GFDL-1.2"), "GFDL-1.2-only")

    # --- Copyleft -or-later (GPL / LGPL / AGPL / GFDL with + suffix) ---

    def test_gpl_or_later(self):
        self.assertEqual(dep5_to_spdx("GPL-1+"), "GPL-1.0-or-later")
        self.assertEqual(dep5_to_spdx("GPL-2+"), "GPL-2.0-or-later")
        self.assertEqual(dep5_to_spdx("GPL-3+"), "GPL-3.0-or-later")

    def test_lgpl_or_later(self):
        self.assertEqual(dep5_to_spdx("LGPL-2+"), "LGPL-2.0-or-later")
        self.assertEqual(dep5_to_spdx("LGPL-2.1+"), "LGPL-2.1-or-later")
        self.assertEqual(dep5_to_spdx("LGPL-3+"), "LGPL-3.0-or-later")

    def test_agpl_or_later(self):
        self.assertEqual(dep5_to_spdx("AGPL-3+"), "AGPL-3.0-or-later")

    def test_gfdl_or_later(self):
        self.assertEqual(dep5_to_spdx("GFDL-1.2+"), "GFDL-1.2-or-later")
        self.assertEqual(dep5_to_spdx("GFDL-1.3+"), "GFDL-1.3-or-later")

    # --- BSD clause capitalisation ---

    def test_bsd_2_clause(self):
        self.assertEqual(dep5_to_spdx("BSD-2-clause"), "BSD-2-Clause")

    def test_bsd_3_clause(self):
        self.assertEqual(dep5_to_spdx("BSD-3-clause"), "BSD-3-Clause")

    def test_bsd_clause_with_suffix(self):
        # Best-effort: capitalise Clause; SPDX may use a different full name
        self.assertEqual(
            dep5_to_spdx("BSD-3-clause-no-nuclear"), "BSD-3-Clause-no-nuclear"
        )
        self.assertEqual(dep5_to_spdx("BSD-2-clause-FreeBSD"), "BSD-2-Clause-FreeBSD")

    # --- Pass-through: already SPDX-compatible or no mapping available ---

    def test_passthrough_common(self):
        for name in (
            "ISC",
            "Zlib",
            "Unlicense",
            "X11",
            "WTFPL",
            "Libpng",
            "JSON",
            "curl",
        ):
            with self.subTest(name=name):
                self.assertEqual(dep5_to_spdx(name), name)

    def test_passthrough_exceptions_and_custom(self):
        # Exception labels with no SPDX mapping pass through unchanged (the
        # uppercase "QT-1.x" forms are not decopy's labels - Qt1.1 etc. are).
        self.assertEqual(
            dep5_to_spdx("GPL-2+3 with QT-1.3 exception"),
            "GPL-2+3 with QT-1.3 exception",
        )
        self.assertEqual(
            dep5_to_spdx("LGPL-2.1 with QT-1.1 exception"),
            "LGPL-2.1 with QT-1.1 exception",
        )

    def test_passthrough_unknown(self):
        self.assertEqual(dep5_to_spdx("Unknown"), "Unknown")

    # --- Compound DEP-5 'A or B' → SPDX 'A OR B' ---

    def test_compound_two_gpl(self):
        self.assertEqual(dep5_to_spdx("GPL-2 or GPL-3"), "GPL-2.0-only OR GPL-3.0-only")

    def test_compound_gpl_or_later_and_apache(self):
        self.assertEqual(
            dep5_to_spdx("GPL-2+ or Apache-2"), "GPL-2.0-or-later OR Apache-2.0"
        )

    def test_compound_with_alias(self):
        self.assertEqual(dep5_to_spdx("Apache-2 or MIT/X11"), "Apache-2.0 OR MIT")

    def test_compound_three_way(self):
        self.assertEqual(
            dep5_to_spdx("GPL-2 or GPL-3 or Apache-2"),
            "GPL-2.0-only OR GPL-3.0-only OR Apache-2.0",
        )


class TestSpdxOption(unittest.TestCase):
    """Tests for the --spdx / --no-spdx CLI option and Options.spdx field."""

    _BASE_ARGS: typing.ClassVar[list[str]] = ["--no-progress", "--root", "."]

    def test_spdx_defaults_to_false(self):
        opts = cmdoptions.process_options(self._BASE_ARGS)
        self.assertFalse(opts.spdx)

    def test_spdx_flag_enables_spdx(self):
        opts = cmdoptions.process_options([*self._BASE_ARGS, "--spdx"])
        self.assertTrue(opts.spdx)

    def test_no_spdx_flag_disables_spdx(self):
        opts = cmdoptions.process_options([*self._BASE_ARGS, "--no-spdx"])
        self.assertFalse(opts.spdx)

    def test_spdx_last_flag_wins(self):
        opts = cmdoptions.process_options([*self._BASE_ARGS, "--spdx", "--no-spdx"])
        self.assertFalse(opts.spdx)

        opts = cmdoptions.process_options([*self._BASE_ARGS, "--no-spdx", "--spdx"])
        self.assertTrue(opts.spdx)

    def test_options_dataclass_has_spdx_field(self):
        # Verify the field exists and defaults to False when not supplied
        opts = cmdoptions.process_options(self._BASE_ARGS)
        self.assertIsInstance(opts, Options)
        self.assertIs(type(opts.spdx), bool)


class TestCopyrightBlockSpdx(unittest.TestCase):
    """Unit tests for Dep5Renderer.file_block() with options.spdx."""

    def setUp(self):
        self._saved_licenses = core_types.License.licenses
        core_types.License.licenses = {}

    def tearDown(self):
        core_types.License.licenses = self._saved_licenses

    def _opts(self, *, spdx: bool):
        flag = "--spdx" if spdx else "--no-spdx"
        return cmdoptions.process_options(["--no-progress", flag])

    def _block(self, group, *, spdx: bool = False):
        opts = self._opts(spdx=spdx)
        if opts.glob:
            file_patterns = group.files.get_patterns(TallyTracker())
        else:
            file_patterns = group.files.sorted_members()
        return Dep5Renderer().file_block(group, opts, file_patterns)

    def _group(self, filename: str, *license_names: str):
        group = dep5.Group()
        group.files.add_file(nodes.FileInfo(parent=None, name=filename))
        for name in license_names:
            group.licenses[name] = core_types.License.get(name)
        return group

    def test_no_options_leaves_dep5_name(self):
        # options=None (default) must not change anything
        group = self._group("src/foo.c", "GPL-2")
        block = self._block(group, spdx=False)
        self.assertIn("License: GPL-2\n", block + "\n")
        self.assertNotIn("GPL-2.0-only", block)

    def test_no_spdx_option_leaves_dep5_name(self):
        group = self._group("src/foo.c", "GPL-2")
        block = self._block(group, spdx=False)
        self.assertIn("License: GPL-2\n", block + "\n")
        self.assertNotIn("GPL-2.0-only", block)

    def test_spdx_converts_gpl_only(self):
        group = self._group("src/foo.c", "GPL-2")
        block = self._block(group, spdx=True)
        self.assertIn("License: GPL-2.0-only", block)

    def test_spdx_converts_gpl_or_later(self):
        group = self._group("src/foo.c", "GPL-2+")
        block = self._block(group, spdx=True)
        self.assertIn("License: GPL-2.0-or-later", block)

    def test_spdx_converts_apache(self):
        group = self._group("src/foo.c", "Apache-2")
        block = self._block(group, spdx=True)
        self.assertIn("License: Apache-2.0", block)

    def test_spdx_converts_mit_x11(self):
        group = self._group("src/foo.c", "MIT/X11")
        block = self._block(group, spdx=True)
        self.assertIn("License: MIT", block)

    def test_spdx_compound_or_becomes_uppercase_or(self):
        # "Apache-2 or GPL-2" (sorted) → "Apache-2.0 OR GPL-2.0-only"
        group = self._group("src/foo.c", "GPL-2", "Apache-2")
        block = self._block(group, spdx=True)
        self.assertIn("License: Apache-2.0 OR GPL-2.0-only", block)


class TestOutputSpdx(unittest.TestCase):
    """Integration tests: full pipeline output with/without --spdx."""

    def setUp(self):
        self._saved_licenses = core_types.License.licenses
        core_types.License.licenses = {}

    def tearDown(self):
        core_types.License.licenses = self._saved_licenses

    def _run(self, path: str, extra_flags: list[str] | None = None) -> str:
        if extra_flags is None:
            extra_flags = []
        flags = ["--no-progress", "--root", path, *extra_flags]
        options = cmdoptions.process_options(flags)
        filetree = tree.RootInfo.build(options)
        copyright_ = dep5.Copyright.build(filetree, options)
        copyright_.process(filetree)
        filetree.process(options)
        copyright_.remove_misplaced_files(options)
        groups = copyright_.get_group_dict(options)

        buf = io.StringIO()
        with patch.object(sys, "stdout", buf):
            output.generate_output(groups, filetree, copyright_, options)
        return buf.getvalue()

    def test_no_spdx_keeps_dep5_names_in_files_paragraphs(self):
        out = self._run("tests/testdata/four", ["--no-spdx"])
        self.assertIn("License: GPL-2+", out)
        self.assertIn("License: LGPL-2+", out)
        self.assertNotIn("GPL-2.0-or-later", out)
        self.assertNotIn("LGPL-2.0-or-later", out)

    def test_spdx_converts_files_paragraphs(self):
        out = self._run("tests/testdata/four", ["--spdx"])
        # Files paragraph License: field uses SPDX identifiers
        self.assertIn("License: GPL-2.0-or-later", out)
        self.assertIn("License: LGPL-2.0-or-later", out)
        # No raw DEP-5 names in the output
        self.assertNotIn("License: GPL-2+", out)
        self.assertNotIn("License: LGPL-2+", out)

    def test_spdx_converts_standalone_stanza_headers(self):
        out = self._run("tests/testdata/four", ["--spdx"])
        # The standalone License: stanza headers must use SPDX names too.
        # Each stanza occupies a blank-line-separated block; the header line
        # starts at the beginning of its block.
        stanza_headers = [
            line for line in out.splitlines() if line.startswith("License:")
        ]
        self.assertIn("License: GPL-2.0-or-later", stanza_headers)
        self.assertIn("License: LGPL-2.0-or-later", stanza_headers)

    def test_spdx_preserves_license_body_text(self):
        # Switching to SPDX must not discard the stored licence body text.
        out = self._run("tests/testdata/four", ["--spdx"])
        self.assertIn("This program is free software", out)


class TestThreeFormatNormalisation(unittest.TestCase):
    """dep5_to_spdx() accepts DEP-5, old/deprecated SPDX, and new canonical SPDX."""

    def test_agpl_all_formats(self):
        self.assertEqual(dep5_to_spdx("AGPL-3"), "AGPL-3.0-only")  # DEP-5
        self.assertEqual(dep5_to_spdx("AGPL-3.0"), "AGPL-3.0-only")  # old SPDX
        self.assertEqual(dep5_to_spdx("AGPL-3.0-only"), "AGPL-3.0-only")  # canonical

    def test_gpl_all_formats(self):
        self.assertEqual(dep5_to_spdx("GPL-2"), "GPL-2.0-only")
        self.assertEqual(dep5_to_spdx("GPL-2.0"), "GPL-2.0-only")
        self.assertEqual(dep5_to_spdx("GPL-2.0-only"), "GPL-2.0-only")
        self.assertEqual(dep5_to_spdx("GPL-2+"), "GPL-2.0-or-later")
        self.assertEqual(dep5_to_spdx("GPL-2.0+"), "GPL-2.0-or-later")
        self.assertEqual(dep5_to_spdx("GPL-2.0-or-later"), "GPL-2.0-or-later")

    def test_lgpl_all_formats(self):
        self.assertEqual(dep5_to_spdx("LGPL-2.1"), "LGPL-2.1-only")
        self.assertEqual(dep5_to_spdx("LGPL-2.1-only"), "LGPL-2.1-only")

    def test_gfdl_all_formats(self):
        self.assertEqual(dep5_to_spdx("GFDL-1.3"), "GFDL-1.3-only")
        self.assertEqual(dep5_to_spdx("GFDL-1.3-only"), "GFDL-1.3-only")


class TestReplaceSpdx(unittest.TestCase):
    """replace_spdx() converts SPDX identifiers → DEP-5 names."""

    def test_only_suffix_removed(self):
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("GPL-2.0-only"), "GPL-2")
        self.assertEqual(replace_spdx("AGPL-3.0-only"), "AGPL-3")

    def test_or_later_becomes_plus(self):
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("GPL-2.0-or-later"), "GPL-2+")

    def test_mit_becomes_expat(self):
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("MIT"), "Expat")

    def test_mit_x11_unchanged(self):
        # MIT/X11 is a valid DEP-5 compound name; the MIT -> Expat rule must
        # not fire on it.
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("MIT/X11"), "MIT/X11")
        self.assertEqual(replace_spdx("MIT-CMU"), "MIT-CMU")

    def test_dep5_license_names_unchanged(self):
        # DEP-5 names produced by decopy's matchers must pass through unmodified.
        from decopy.res import replace_spdx

        for name in (
            "Expat",
            "CC0",
            "GPL-2",
            "GPL-2+",
            "GPL-3",
            "GPL-3+",
            "LGPL-2",
            "LGPL-2+",
            "LGPL-2.1",
            "LGPL-2.1+",
            "LGPL-3",
            "LGPL-3+",
            "AGPL-3",
            "AGPL-3+",
            "GFDL-1.2",
            "GFDL-1.2+",
            "GFDL-1.3",
            "GFDL-1.3+",
            "Apache-2",
            "BSD-2-clause",
            "BSD-3-clause",
            "CeCILL-2",
            "CC-BY-4",
        ):
            with self.subTest(name=name):
                self.assertEqual(replace_spdx(name), name)

    def test_dep5_exception_strings_unchanged(self):
        # DEP-5 "… with … exception" strings must pass through unmodified.
        from decopy.res import replace_spdx

        for name in (
            "GPL-2+ with Font exception",
            "GPL-2+ with ClassPath exception",
            "LGPL-2.1 with Qt1.1 exception",
            "GPL-2 with OpenSSL exception",
        ):
            with self.subTest(name=name):
                self.assertEqual(replace_spdx(name), name)

    def test_lgpl_spdx_forms(self):
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("LGPL-2.0-only"), "LGPL-2")
        self.assertEqual(replace_spdx("LGPL-2.1-only"), "LGPL-2.1")
        self.assertEqual(replace_spdx("LGPL-2.1-or-later"), "LGPL-2.1+")
        self.assertEqual(replace_spdx("LGPL-3.0-only"), "LGPL-3")
        self.assertEqual(replace_spdx("LGPL-3.0-or-later"), "LGPL-3+")

    def test_gfdl_spdx_forms(self):
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("GFDL-1.3-only"), "GFDL-1.3")
        self.assertEqual(replace_spdx("GFDL-1.3-or-later"), "GFDL-1.3+")

    def test_cc0_and_apache_spdx_forms(self):
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("CC0-1.0"), "CC0")
        self.assertEqual(replace_spdx("Apache-2.0"), "Apache-2")

    def test_with_expression_font(self):
        from decopy.res import replace_spdx

        self.assertEqual(
            replace_spdx("GPL-2.0-or-later WITH Font-exception-2.0"),
            "GPL-2+ with Font exception",
        )

    def test_with_expression_classpath(self):
        from decopy.res import replace_spdx

        self.assertEqual(
            replace_spdx("GPL-2.0-only WITH Classpath-exception-2.0"),
            "GPL-2 with ClassPath exception",
        )

    def test_with_expression_gcc_version_specific(self):
        from decopy.res import replace_spdx

        self.assertEqual(
            replace_spdx("GPL-3.0-only WITH GCC-exception-3.1"),
            "GPL-3 with GCC-3.1 exception",
        )

    def test_with_expression_unmapped_passes_through(self):
        from decopy.res import replace_spdx

        self.assertEqual(
            replace_spdx("GPL-2.0-or-later WITH Some-unknown-exception"),
            "GPL-2.0-or-later WITH Some-unknown-exception",
        )

    def test_deprecated_non_copyleft_ids(self):
        # Deprecated SPDX IDs whose current spelling decopy renames; the
        # deprecated form must reach the same DEP-5 name.
        from decopy.res import replace_spdx

        self.assertEqual(replace_spdx("StandardML-NJ"), "MIT-like-SMLNJ")
        self.assertEqual(replace_spdx("bzip2-1.0.5"), "BSD-like-bzip2")
        # Current forms already map (regression guard):
        self.assertEqual(replace_spdx("SMLNJ"), "MIT-like-SMLNJ")
        self.assertEqual(replace_spdx("bzip2-1.0.6"), "BSD-like-bzip2")

    def test_deprecated_with_exception_ids(self):
        # Deprecated combined GPL-with-exception license IDs convert to DEP-5
        # "with … exception" form (version-aware: GPL-3.0 selects the v3 variant).
        from decopy.res import replace_spdx

        self.assertEqual(
            replace_spdx("GPL-2.0-with-GCC-exception"), "GPL-2 with GCC exception"
        )
        self.assertEqual(
            replace_spdx("GPL-2.0-with-autoconf-exception"),
            "GPL-2 with AutoConf exception",
        )
        self.assertEqual(
            replace_spdx("GPL-2.0-with-bison-exception"), "GPL-2 with Bison exception"
        )
        self.assertEqual(
            replace_spdx("GPL-2.0-with-classpath-exception"),
            "GPL-2 with ClassPath exception",
        )
        self.assertEqual(
            replace_spdx("GPL-2.0-with-font-exception"), "GPL-2 with Font exception"
        )
        self.assertEqual(
            replace_spdx("GPL-3.0-with-GCC-exception"), "GPL-3 with GCC-3.1 exception"
        )
        self.assertEqual(
            replace_spdx("GPL-3.0-with-autoconf-exception"),
            "GPL-3 with AutoConf-3 exception",
        )


class TestExceptionForward(unittest.TestCase):
    """exception_to_spdx(): DEP-5 exception label → SPDX exception ID."""

    def test_known_labels(self):
        from decopy.spdx import exception_to_spdx

        self.assertEqual(exception_to_spdx("Font"), "Font-exception-2.0")
        self.assertEqual(exception_to_spdx("ClassPath"), "Classpath-exception-2.0")
        self.assertEqual(exception_to_spdx("GCC"), "GCC-exception-2.0")
        self.assertEqual(exception_to_spdx("GCC-3.1"), "GCC-exception-3.1")
        self.assertEqual(exception_to_spdx("AutoConf"), "Autoconf-exception-2.0")
        self.assertEqual(exception_to_spdx("AutoConf-3"), "Autoconf-exception-3.0")
        self.assertEqual(exception_to_spdx("LLVM"), "LLVM-exception")

    def test_unknown_label(self):
        from decopy.spdx import exception_to_spdx

        self.assertIsNone(exception_to_spdx("NoSuchException"))

    def test_round_trip_via_public_api(self):
        # Each label converts to an SPDX id (forward) that converts back to the
        # same label (reverse, via replace_spdx). Covers every collision label
        # whose representative id is pinned. The exhaustive forward/reverse
        # consistency invariant is enforced inside spdx.py at import time.
        from decopy.spdx import exception_to_spdx, replace_spdx

        labels = [
            "Font",
            "ClassPath",
            "GCC",
            "GCC-3.1",
            "AutoConf",
            "AutoConf-3",
            "GStreamer",
            "LLVM",
            "Universal-FOSS",
        ]
        for label in labels:
            with self.subTest(label=label):
                spdx_id = exception_to_spdx(label)
                self.assertIsNotNone(spdx_id)
                self.assertEqual(
                    replace_spdx(f"GPL-2.0-only WITH {spdx_id}"),
                    f"GPL-2 with {label} exception",
                )


class TestDep5ToSpdxWithExpressions(unittest.TestCase):
    """dep5_to_spdx() emits SPDX WITH expressions for 'with … exception' names."""

    def test_font_exception(self):
        self.assertEqual(
            dep5_to_spdx("GPL-2+ with Font exception"),
            "GPL-2.0-or-later WITH Font-exception-2.0",
        )

    def test_classpath_exception(self):
        self.assertEqual(
            dep5_to_spdx("GPL-2 with ClassPath exception"),
            "GPL-2.0-only WITH Classpath-exception-2.0",
        )

    def test_gcc_version_in_label(self):
        # decopy's label encodes the variant: GCC -> 2.0, GCC-3.1 -> 3.1.
        self.assertEqual(
            dep5_to_spdx("GPL-2+ with GCC exception"),
            "GPL-2.0-or-later WITH GCC-exception-2.0",
        )
        self.assertEqual(
            dep5_to_spdx("GPL-3 with GCC-3.1 exception"),
            "GPL-3.0-only WITH GCC-exception-3.1",
        )

    def test_autoconf_version_in_label(self):
        self.assertEqual(
            dep5_to_spdx("GPL-2+ with AutoConf exception"),
            "GPL-2.0-or-later WITH Autoconf-exception-2.0",
        )
        self.assertEqual(
            dep5_to_spdx("GPL-3 with AutoConf-3 exception"),
            "GPL-3.0-only WITH Autoconf-exception-3.0",
        )

    def test_exception_within_disjunction(self):
        # "with … exception" handled per term inside an 'or' expression.
        self.assertEqual(
            dep5_to_spdx("GPL-2+ with Font exception or Expat"),
            "GPL-2.0-or-later WITH Font-exception-2.0 OR MIT",
        )

    def test_unmapped_exception_passes_through(self):
        self.assertEqual(
            dep5_to_spdx("GPL-2+ with UnknownWidget exception"),
            "GPL-2+ with UnknownWidget exception",
        )


class TestSpdxLicenseIdentifierTag(unittest.TestCase):
    """find_licenses() converts compound SPDX-License-Identifier tags to DEP-5."""

    @staticmethod
    def _detect(tag: str) -> list[str]:
        from decopy import licenses

        return list(licenses.find_licenses(licenses.clean_comments(tag)))

    def test_single(self):
        self.assertEqual(
            self._detect("SPDX-License-Identifier: GPL-2.0-only"), ["GPL-2"]
        )

    def test_disjunction(self):
        # Previously truncated to just the first licence; now fully converted.
        self.assertEqual(
            self._detect("SPDX-License-Identifier: Apache-2.0 OR MIT"),
            ["Apache-2 or Expat"],
        )

    def test_with_clause(self):
        self.assertEqual(
            self._detect(
                "SPDX-License-Identifier: GPL-2.0-or-later WITH Classpath-exception-2.0"
            ),
            ["GPL-2+ with ClassPath exception"],
        )

    def test_grouped_expression_uses_comma(self):
        self.assertEqual(
            self._detect(
                "SPDX-License-Identifier: (GPL-2.0-only OR MIT) AND Apache-2.0"
            ),
            ["GPL-2 or Expat, and Apache-2"],
        )

    def test_trailing_comment_text_is_not_consumed(self):
        # clean_comments() flattens lines, so prose can follow on the same line;
        # the operator-shaped capture stops at the first non-operator token.
        self.assertEqual(
            self._detect("SPDX-License-Identifier: Apache-2.0 OR MIT see LICENSE file"),
            ["Apache-2 or Expat"],
        )

    def test_single_letter_capture_is_rejected(self):
        # ruff fixture pycodestyle/E501.py:89 has
        # "# SPDX-License-Identifier: a very long license identifier ...";
        # the lone "a" is not an SPDX id and must not be reported.
        self.assertNotIn("a", self._detect("SPDX-License-Identifier: a very long id"))

    def test_marker_like_capture_is_rejected(self):
        # ruff mentions these markers in code/fixtures (it handles SPDX itself);
        # a token with a trailing colon is never an SPDX id.
        self.assertNotIn("fmt:", self._detect("SPDX-License-Identifier: fmt:"))
        self.assertNotIn(
            "SPDX-FileCopyrightText:",
            self._detect("SPDX-License-Identifier: SPDX-FileCopyrightText:"),
        )


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