#!/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
from decopy.common import core_types
from decopy.common.core_types import Options
from decopy.decopy import run_pipeline
from decopy.dep5doc import dep5
from decopy.dep5doc.renderer import Dep5Renderer
from decopy.fstree import nodes
from decopy.grouping.group import TallyTracker
from decopy.licensing.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")

    def test_reverse_aliases_exact(self):
        # decopy renames these SPDX ids to a DEP-5 name on read; --spdx must
        # restore the SPDX id rather than emit decopy's name verbatim. Exact
        # 1:1 renames with no version component. (bzip2/SMLNJ are many-to-one;
        # the canonical/current SPDX id is the chosen representative.)
        cases = {
            "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",
        }
        for dep5_name, spdx_id in cases.items():
            with self.subTest(name=dep5_name):
                self.assertEqual(dep5_to_spdx(dep5_name), spdx_id)

    def test_reverse_aliases_per_licence(self):
        # Per-licence reverses with no shared pattern: multi-component .0.0,
        # a version before a non-version suffix, and mid-string renames. Each
        # DEP-5 name is unique to one SPDX id, so an exact alias is safe.
        cases = {
            "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",
        }
        for dep5_name, spdx_id in cases.items():
            with self.subTest(name=dep5_name):
                self.assertEqual(dep5_to_spdx(dep5_name), spdx_id)

    def test_reverse_aliases_versioned(self):
        # Prefix renames whose version must survive: the family prefix reverts
        # and the existing .0 restoration then runs (Academic-2 -> AFL-2 ->
        # AFL-2.0; CeCILL-1 -> CECILL-1 -> CECILL-1.0).
        cases = {
            "Academic-1.1": "AFL-1.1",
            "Academic-2": "AFL-2.0",
            "Academic-2.1": "AFL-2.1",
            "Academic-3": "AFL-3.0",
            "CeCILL-1": "CECILL-1.0",
            "CeCILL-1.1": "CECILL-1.1",
            "CeCILL-2": "CECILL-2.0",
            "CeCILL-2.1": "CECILL-2.1",
            "CeCILL-B": "CECILL-B",
            "CeCILL-C": "CECILL-C",
        }
        for dep5_name, spdx_id in cases.items():
            with self.subTest(name=dep5_name):
                self.assertEqual(dep5_to_spdx(dep5_name), spdx_id)

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

    # --- Trailing number that is a year/date/zero is NOT a version: no .0 ---

    def test_year_suffix_not_restored(self):
        # A 4-digit year, an 8-digit date, a 2-digit year and a bare 0 are not
        # versions, so dep5_to_spdx must not append .0 (which would yield an
        # invalid SPDX id like Adobe-2006.0). Only a single-digit major version
        # is restored.
        for name in (
            "Adobe-2006",
            "Arphic-1999",
            "HP-1986",
            "NAIST-2003",
            "Sun-PPP-2000",
            "Unicode-DFS-2016",
            "W3C-19980720",
            "Spencer-86",
            "MIT-0",
            "NTP-0",
        ):
            with self.subTest(name=name):
                self.assertEqual(dep5_to_spdx(name), name)
        # ... while a genuine single-digit major version still gains its .0.
        self.assertEqual(dep5_to_spdx("Apache-2"), "Apache-2.0")
        self.assertEqual(dep5_to_spdx("MPL-2"), "MPL-2.0")

    # --- 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):
        # A suffix with no exact alias still gets generic Clause capitalisation.
        # (no-nuclear now has an exact alias -- see test_reverse_aliases_per_licence.)
        self.assertEqual(dep5_to_spdx("BSD-2-clause-FreeBSD"), "BSD-2-Clause-FreeBSD")

    def test_suffix_word_recapitalisation(self):
        # The read path lower-cases Clause/Attribution/Protection/Source-Code;
        # --spdx must restore exactly those words (and not neighbouring words
        # SPDX keeps lower-case: disclaimer, acknowledgment, no-conversion).
        cases = {
            "BSD-3-clause-attribution": "BSD-3-Clause-Attribution",
            "BSD-attribution-HPND-disclaimer": "BSD-Attribution-HPND-disclaimer",
            "BSD-protection": "BSD-Protection",
            "BSD-source-code": "BSD-Source-Code",
            "Brian-Gladman-2-clause": "Brian-Gladman-2-Clause",
            "Brian-Gladman-3-clause": "Brian-Gladman-3-Clause",
            "Brian-Gladman-3-clause-no-conversion": (
                "Brian-Gladman-3-Clause-no-conversion"
            ),
            "DEC-3-clause": "DEC-3-Clause",
            "Mackerras-3-clause": "Mackerras-3-Clause",
            "Mackerras-3-clause-acknowledgment": "Mackerras-3-Clause-acknowledgment",
        }
        for dep5_name, spdx_id in cases.items():
            with self.subTest(name=dep5_name):
                self.assertEqual(dep5_to_spdx(dep5_name), spdx_id)

    def test_gfdl_invariants(self):
        # The -invariants / -no-invariants suffix stops the copyleft rule from
        # re-adding -only / -or-later, so a dedicated rule must (the read path
        # turned -only into "" and -or-later into "+").
        cases = {}
        for ver in ("1.1", "1.2", "1.3"):
            for inv in ("invariants", "no-invariants"):
                cases[f"GFDL-{ver}-{inv}"] = f"GFDL-{ver}-{inv}-only"
                cases[f"GFDL-{ver}-{inv}+"] = f"GFDL-{ver}-{inv}-or-later"
        for dep5_name, spdx_id in cases.items():
            with self.subTest(name=dep5_name):
                self.assertEqual(dep5_to_spdx(dep5_name), spdx_id)
        # A plain GFDL (no invariants suffix) still goes through the copyleft rule.
        self.assertEqual(dep5_to_spdx("GFDL-1.3"), "GFDL-1.3-only")
        self.assertEqual(dep5_to_spdx("GFDL-1.2+"), "GFDL-1.2-or-later")

    def test_cc_by_jurisdiction_ports(self):
        # A jurisdiction port keeps its 2-3 letter suffix, so the major's .0 was
        # stripped mid-string on read; --spdx must restore it (CC-BY-3-AT ->
        # CC-BY-3.0-AT) for every BY / NC / SA / ND modifier combination.
        cases = {
            "CC-BY-3-AT": "CC-BY-3.0-AT",
            "CC-BY-3-IGO": "CC-BY-3.0-IGO",
            "CC-BY-NC-3-DE": "CC-BY-NC-3.0-DE",
            "CC-BY-NC-SA-2-DE": "CC-BY-NC-SA-2.0-DE",
            "CC-BY-NC-SA-2-UK": "CC-BY-NC-SA-2.0-UK",
            "CC-BY-NC-ND-3-IGO": "CC-BY-NC-ND-3.0-IGO",
            "CC-BY-SA-2-UK": "CC-BY-SA-2.0-UK",
            "CC-BY-ND-3-DE": "CC-BY-ND-3.0-DE",
        }
        for dep5_name, spdx_id in cases.items():
            with self.subTest(name=dep5_name):
                self.assertEqual(dep5_to_spdx(dep5_name), spdx_id)
        # A non-port CC-BY (no jurisdiction suffix) still gains .0 the usual way.
        self.assertEqual(dep5_to_spdx("CC-BY-4"), "CC-BY-4.0")
        self.assertEqual(dep5_to_spdx("CC-BY-SA-4"), "CC-BY-SA-4.0")

    # --- 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 _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(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 _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)
        buf = io.StringIO()
        with patch.object(sys, "stdout", buf):
            run_pipeline(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.licensing.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.licensing.res import replace_spdx

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

    def test_mit_becomes_expat(self):
        from decopy.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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.licensing.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 SPDX-License-Identifier tags to DEP-5 atoms.

    A compound tag is canonicalised and split into its atomic licences, so each
    gets its own standalone License stanza; the field's and/or/comma rendering is
    a separate concern (Group.license_field / license_expr).
    """

    @staticmethod
    def _detect(tag: str) -> list[str]:
        from decopy.licensing 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):
        # Canonicalised and split into atoms, one stanza per licence.
        self.assertEqual(
            self._detect("SPDX-License-Identifier: Apache-2.0 OR MIT"),
            ["Apache-2", "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_split_into_atoms(self):
        # A grouped compound splits into every atom it mentions (encounter order).
        self.assertEqual(
            self._detect(
                "SPDX-License-Identifier: (GPL-2.0-only OR MIT) AND Apache-2.0"
            ),
            ["GPL-2", "Expat", "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", "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()
