#!/usr/bin/env python3

# Unit tests for decopy
# Copyright: 2016, Margarita Manterola <marga@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 tempfile
import unittest
from pathlib import Path

import debian.copyright as dc

from decopy import cmdoptions
from decopy import decopy as decopy_main
from decopy.common import core_types
from decopy.dep5doc import copyright_processor, dep5
from decopy.dep5doc.renderer import Dep5Renderer
from decopy.fstree import nodes, tree
from decopy.grouping.group import Group, TallyTracker
from decopy.grouping.group_contracts import GroupProtocol
from decopy.licensing import holders
from decopy.licensing.license_expr import And, License, Or


class TestGroupLicenseField(unittest.TestCase):
    """Group.license_field: the rendered License field."""

    def test_none_falls_back_to_canonical_join(self):
        # A synthesised group (no expression) renders the canonical set join.
        group = Group()
        group.licenses = {"GPL-2+": core_types.License("GPL-2+")}
        self.assertIsNone(group.license_expr)
        self.assertEqual(group.license_field, "GPL-2+")
        self.assertEqual(group.license_field, group.license)

    def test_expression_round_trips_to_dep5(self):
        group = Group()
        group.license_expr = And((License("GPL-2+"), License("BSD-3-clause")))
        self.assertEqual(group.license_field, "GPL-2+ and BSD-3-clause")

    def test_inexpressible_tree_falls_back_to_spdx(self):
        # A tree DEP-5 cannot hold without changing meaning -> SPDX form.
        group = Group()
        group.license_expr = Or(
            (License("A"), And((Or((License("B"), License("C"))), License("D"))))
        )
        self.assertEqual(group.license_field, "A OR ((B OR C) AND D)")

    def test_spdx_fallback_field_reads_back_to_same_tree(self):
        """A group's SPDX-fallback output parses back to the same expression.

        Guards the round-trip: decopy must read the SPDX License field it emits
        for trees DEP-5 cannot express.
        """
        from decopy.licensing.spdx import parse_license_synopsis

        expr = Or(
            (
                And(
                    (Or((License("Expat"), License("BSD-3-clause"))), License("GPL-2+"))
                ),
                And((Or((License("Apache-2"), License("ISC"))), License("LGPL-2+"))),
            )
        )
        group = Group()
        group.license_expr = expr
        field = group.license_field
        self.assertIn("(", field)  # it is the SPDX fallback form
        self.assertEqual(parse_license_synopsis(field), expr)


class TestLoadFilesExcluded(unittest.TestCase):
    """read_files_excluded reads the repack list from the header."""

    def _options_for(self, copyright_text):
        tmp = tempfile.mkdtemp()
        if copyright_text is not None:
            debian = Path(tmp) / "debian"
            debian.mkdir()
            (debian / "copyright").write_text(copyright_text, encoding="utf-8")
        return cmdoptions.process_options(["--no-progress", "--root", tmp])

    def test_whitespace_and_continuation_split_into_patterns(self):
        text = (
            "Format: https://www.debian.org/doc/packaging-manuals/"
            "copyright-format/1.0/\n"
            "Files-Excluded: a/* b.c\n third/*\n\n"
            "Files: *\nCopyright: 2020 X\nLicense: ISC\n"
        )
        options = self._options_for(text)
        self.assertEqual(dep5.read_files_excluded(options), ("a/*", "b.c", "third/*"))

    def test_missing_copyright_file_is_empty(self):
        options = self._options_for(None)
        self.assertEqual(dep5.read_files_excluded(options), ())

    def test_unparseable_copyright_file_is_empty(self):
        options = self._options_for("garbage not a paragraph\n")
        self.assertEqual(dep5.read_files_excluded(options), ())

    def test_no_files_excluded_field_is_empty(self):
        text = "Format: x\n\nFiles: *\nCopyright: a\nLicense: ISC\n"
        options = self._options_for(text)
        self.assertEqual(dep5.read_files_excluded(options), ())


class TestCopyright(unittest.TestCase):
    def _process_tree(self, path):
        """Process the received tree and parse the copyright file."""
        options = cmdoptions.process_options(["--no-progress", "--root", path])
        filetree = tree.RootInfo.build(options)
        copyright_ = dep5.read_copyright(options)
        copyright_.process(filetree)
        return copyright_, options

    def test_process_empty(self):
        """Parses a tree with no debian/copyright file."""

        copyright_, _ = self._process_tree("tests/testdata/two")

        # An empty copyright file should yield no groups after processing
        self.assertEqual(copyright_.groups, [])

    def test_process_malformed(self):
        """Parses a tree with a malformed debian/copyright file."""

        copyright_, _ = self._process_tree("tests/testdata/malformed")

        # An empty copyright file should yield no groups after processing
        self.assertEqual(copyright_.groups, [])

    def test_build_uses_supplied_registry(self):
        """read_copyright threads a caller-supplied registry onto the instance."""
        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/four"]
        )
        registry = core_types.LicenseRegistry()
        copyright_ = dep5.read_copyright(options, registry)
        self.assertIs(copyright_.registry, registry)

    def test_build_defaults_to_isolated_registry(self):
        """Each build with no registry gets its own, not a shared global."""
        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/four"]
        )
        first = dep5.read_copyright(options)
        second = dep5.read_copyright(options)
        self.assertIsNot(first.registry, second.registry)

    def test_process_stores_license_text_in_own_registry(self):
        """Processing populates the instance registry's stored text, not a global."""
        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/four"]
        )
        filetree = tree.RootInfo.build(options)
        copyright_ = dep5.read_copyright(options)
        copyright_.process(filetree)
        self.assertIsNotNone(copyright_.registry.get("GPL-2+").stored)
        # A separate registry does not see that stored text.
        self.assertIsNone(core_types.LicenseRegistry().get("GPL-2+").stored)

    def test_groups_are_group_protocol_instances(self):
        """After processing, every element of groups implements GroupProtocol."""
        copyright_, _ = self._process_tree("tests/testdata/incomplete")
        assert copyright_.groups is not None
        for group in copyright_.groups:
            self.assertIsInstance(group, GroupProtocol)

    def test_accepts_any_document_exposing_the_reader_api(self):
        """Composition seam: Copyright wraps any doc with the reader API, not
        only a dc.Copyright it inherits from. A subclass could not do this."""

        class FakeDoc:
            header = "HEADER-SENTINEL"

            def all_files_paragraphs(self):
                return iter(())

            def all_license_paragraphs(self):
                return iter(())

        copyright_ = dep5.Copyright(FakeDoc())  # type: ignore[arg-type]  # ty: ignore[invalid-argument-type]
        self.assertEqual(copyright_.header, "HEADER-SENTINEL")
        self.assertEqual(list(copyright_.all_files_paragraphs()), [])

    def test_process_incomplete(self):
        """Parses a tree with an incomplete debian/copyright file."""

        copyright_, options = self._process_tree("tests/testdata/incomplete")

        # An incomplete copyright file should yield the incomplete groups after
        # processing
        expected_groups = [
            {
                "license": "Unknown",
                "holders": [],
                "files": ["debian/copyright"],
                "comment": "",
            },
            {
                "license": "Unknown",
                "holders": [],
                "files": ["test"],
                "comment": "",
            },
        ]
        self._check_sorted_groups(expected_groups, copyright_.groups, options)

    def test_process_and_license(self):
        """A 'License: A and B' field is read as both atoms, not one token.

        Regression for the DEP-5 sec 7.2 conjunction handling: the synopsis is
        parsed into an expression, so the group carries both licences and keeps
        the parsed expression for faithful re-emission.
        """
        from decopy.licensing.license_expr import And, License, parse_dep5_expression

        copyright_, _ = self._process_tree("tests/testdata/andlicense")

        assert copyright_.groups
        self.assertEqual(len(copyright_.groups), 1)
        group = copyright_.groups[0]
        self.assertEqual(set(group.licenses), {"GPL-2+", "BSD-3-clause"})
        self.assertEqual(
            group.license_expr,
            And((License("GPL-2+"), License("BSD-3-clause"))),
        )
        self.assertEqual(
            group.license_expr, parse_dep5_expression("GPL-2+ and BSD-3-clause")
        )

    def test_render_and_license(self):
        """A read 'License: A and B' re-emits with 'and', not 'or'.

        The grouping key stays the canonical set join (get_license_key), but the
        rendered License field comes from the preserved expression.
        """
        copyright_, options = self._process_tree("tests/testdata/andlicense")
        group = copyright_.groups[0]

        # Output field keeps the conjunction ...
        self.assertEqual(group.license_field, "GPL-2+ and BSD-3-clause")
        # ... while the grouping key remains the connector-agnostic set join.
        self.assertEqual(group.get_license_key(), "BSD-3-clause or GPL-2+")

        # And it shows through the renderer's Files paragraph.
        block = Dep5Renderer().file_block(group, options, ["*"])
        self.assertIn("License: GPL-2+ and BSD-3-clause", block)

    def test_render_and_license_spdx(self):
        """--spdx output of a read 'A and B' renders structured SPDX.

        The old string path (dep5_to_spdx) only split on ' or ', so an 'and'
        field came out mangled; rendering from the expression fixes it.
        """
        options = cmdoptions.process_options(
            ["--no-progress", "--spdx", "--root", "tests/testdata/andlicense"]
        )
        filetree = tree.RootInfo.build(options)
        copyright_ = dep5.read_copyright(options)
        copyright_.process(filetree)
        assert copyright_.groups
        group = copyright_.groups[0]

        block = Dep5Renderer().file_block(group, options, ["*"])
        self.assertIn("License: GPL-2.0-or-later AND BSD-3-Clause", block)

    def test_process_spdx_in_copyright(self):
        """A debian/copyright License field in SPDX syntax is read correctly.

        Parentheses mark the SPDX fallback form; it must parse (not yield
        paren-glued garbage atoms) and normalise to DEP-5 names.
        """
        copyright_, _ = self._process_tree("tests/testdata/spdxincopyright")
        group = copyright_.groups[0]
        self.assertEqual(set(group.licenses), {"Expat", "Apache-2", "GPL-2+"})
        # And/Or grouping that DEP-5 can express with one comma comes back as DEP-5.
        self.assertEqual(group.license_field, "Expat or Apache-2, and GPL-2+")

    def test_process_spdx_names_in_dep5_field(self):
        """Paren-free SPDX/.0 licence spellings in a License field canonicalise.

        Without parentheses the field takes the DEP-5 read path. Its leaves must
        still normalise to decopy's canonical names so the group keys match
        freshly detected files (GPL-2.0-only -> GPL-2, Apache-2.0 -> Apache-2,
        MIT -> Expat) instead of fragmenting into SPDX-spelled singletons that a
        detected GPL-2 file would never match.
        """
        copyright_, _ = self._process_tree("tests/testdata/spdxnamesincopyright")
        group = copyright_.groups[0]
        self.assertEqual(set(group.licenses), {"GPL-2", "Apache-2", "Expat"})
        # The field re-emits in canonical spelling and the round-trip gate holds
        # (canonical DEP-5 leaves re-parse to the same expression).
        self.assertEqual(group.license_field, "GPL-2 or Apache-2 or Expat")

    def test_process_simple(self):
        """Parses a debian/copyright with two entries (testdata/four)."""

        copyright_, options = self._process_tree("tests/testdata/four")

        expected_groups = [
            {
                "license": "LGPL-2+",
                "holders": ["2014-2016, Maximiliano Curia <maxy@debian.org>"],
                "files": ["debian/compat", "debian/copyright"],
                "comment": "",
            },
            {
                "license": "GPL-2+",
                "holders": ["2016, Jane Doe <janedoe@example.com>"],
                "files": ["test"],
                "comment": "",
            },
        ]

        self._check_sorted_groups(expected_groups, copyright_.groups, options)

    def test_process_complex(self):
        """Parses a debian/copyright with many entries (testdata/three)."""

        copyright_, options = self._process_tree("tests/testdata/three")

        expected_groups = [
            {
                "license": "LGPL-2+",
                "holders": ["2014-2016, Maximiliano Curia <maxy@debian.org>"],
                "files": ["debian/compat", "debian/copyright", "debian/rules"],
                "comment": "No real packaging, just empty files.",
            },
            {
                "license": "GPL-2+",
                "holders": ["1989-2003, Free Software Foundation, Inc"],
                "files": ["acinclude.m4"],
                "comment": "",
            },
            {
                "license": "MIT/X11",
                "holders": ["1993-2004, Clara Oswald <clara@example.com>"],
                "files": ["src/do-something.m", "src/do-something.py"],
                "comment": "",
            },
            {
                "license": "GPL-2+",
                "holders": ["2016, Jane Doe <janedoe@example.com>"],
                "files": ["COPYING", "data/file.txt", "src/main.py", "src/module.py"],
                "comment": "",
            },
        ]

        self._check_sorted_groups(expected_groups, copyright_.groups, options)

    def _check_sorted_groups(self, expected_groups, groups, options):
        for i, group in enumerate(groups):
            key = group.get_key(options)
            expected = expected_groups[i]
            with self.subTest(msg=f'Checking "{key}"'):
                self.assertEqual(
                    list(group.copyrights.sorted_members()),
                    expected["holders"],
                )
                self.assertEqual(group.license, expected["license"])
                self.assertEqual(group.files.sorted_members(), expected["files"])
                self.assertEqual(group.get_comments(), expected["comment"])

    def _process_and_remove(self, path, opts):
        """Lists the tree, parses the copyright, reads all files, removes
        misplaced."""
        options = cmdoptions.process_options(["--no-progress", "--root", path, *opts])
        filetree = tree.RootInfo.build(options)
        copyright_ = dep5.read_copyright(options)
        copyright_.process(filetree)
        filetree.process(options)
        # This function removes files from the groups when they should no longer
        # be there.  It does not add them to new or existing groups.  So the
        # effect that we will see is that some files or groups go "missing".
        copyright_.remove_misplaced_files(options)
        return copyright_, options

    def _check_expected_groups(self, expected_groups, groups):
        """Checks that the received and expected groups match."""
        # Check that we got all expected groups
        self.longMessage = False
        for key in expected_groups:
            with self.subTest(f'Checking "{key}"'):
                self.assertIn(key, groups, msg=f"missing expected group: {key}")
        for key in groups:
            with self.subTest(f'Checking "{key}"'):
                self.assertIn(
                    key,
                    expected_groups,
                    msg=f"unexpected group: {key}",
                )
        self.longMessage = True

        # Check that the groups are what we expected
        for key, group in groups.items():
            with self.subTest(msg=f'Checking "{key}"'):
                expected = expected_groups[key]
                self.assertEqual(
                    list(group.copyrights.sorted_members()),
                    expected["holders"],
                )
                self.assertEqual(group.license, expected["license"])
                self.assertEqual(group.files.sorted_members(), expected["files"])
                self.assertEqual(group.get_comments(), expected["comment"])

    def test_remove_misplaced(self):
        """Removes files from copyright if they were wrong (testdata/three)."""

        copyright_, options = self._process_and_remove("tests/testdata/three", [])

        expected_groups = {
            ("LGPL-2+", None, "debian"): {
                "license": "LGPL-2+",
                "holders": ["2014-2016, Maximiliano Curia <maxy@debian.org>"],
                "files": ["debian/compat", "debian/copyright", "debian/rules"],
                "comment": "No real packaging, just empty files.",
            },
            ("GPL-2+", None, ""): {
                "license": "GPL-2+",
                "holders": [
                    "1989-2003, Free Software Foundation, Inc",
                    "2016, Jane Doe <janedoe@example.com>",
                ],
                "files": ["COPYING", "acinclude.m4", "src/main.py", "src/module.py"],
                "comment": "",
            },
            ("MIT/X11", None, ""): {
                "license": "MIT/X11",
                "holders": ["1993-2004, Clara Oswald <clara@example.com>"],
                "files": ["src/do-something.m", "src/do-something.py"],
                "comment": "",
            },
        }
        groups = copyright_.get_group_dict(options)
        self._check_expected_groups(expected_groups, groups)

    def test_remove_misplaced_copyright(self):
        """Removes files if they were wrong, grouping by copyright."""

        copyright_, options = self._process_and_remove(
            "tests/testdata/three",
            ["--group-by", "copyright"],
        )

        expected_groups = {
            ("LGPL-2+", ("maxy@debian.org",), "debian"): {
                "license": "LGPL-2+",
                "holders": ["2014-2016, Maximiliano Curia <maxy@debian.org>"],
                "files": ["debian/compat", "debian/copyright", "debian/rules"],
                "comment": "No real packaging, just empty files.",
            },
            ("GPL-2+", ("janedoe@example.com",), ""): {
                "license": "GPL-2+",
                "holders": ["2016, Jane Doe <janedoe@example.com>"],
                "files": ["COPYING", "src/main.py", "src/module.py"],
                "comment": "",
            },
            ("MIT/X11", ("clara@example.com",), ""): {
                "license": "MIT/X11",
                "holders": ["1993-2004, Clara Oswald <clara@example.com>"],
                "files": ["src/do-something.m", "src/do-something.py"],
                "comment": "",
            },
            ("GPL-2+", ("Free Software Foundation, Inc",), ""): {
                "license": "GPL-2+",
                "holders": ["1989-2003, Free Software Foundation, Inc"],
                "files": ["acinclude.m4"],
                "comment": "",
            },
        }
        groups = copyright_.get_group_dict(options)
        self._check_expected_groups(expected_groups, groups)

    def test_remove_misplaced_partial(self):
        """Removes files if they were wrong, grouping by copyright."""

        copyright_, options = self._process_and_remove(
            "tests/testdata/three",
            ["--mode", "partial", "tests/testdata/three/debian"],
        )

        expected_groups = {
            ("LGPL-2+", None, "debian"): {
                "license": "LGPL-2+",
                "holders": ["2014-2016, Maximiliano Curia <maxy@debian.org>"],
                "files": ["debian/compat", "debian/copyright", "debian/rules"],
                "comment": "No real packaging, just empty files.",
            },
            ("GPL-2+", None, ""): {
                "license": "GPL-2+",
                "holders": ["1989-2003, Free Software Foundation, Inc"],
                "files": [],
                "comment": "",
            },
            ("MIT/X11", None, ""): {
                "license": "MIT/X11",
                "holders": ["1993-2004, Clara Oswald <clara@example.com>"],
                "files": [],
                "comment": "",
            },
        }

        groups = copyright_.get_group_dict(options)
        self._check_expected_groups(expected_groups, groups)

    def test_get_group_dict_returns_dict_of_groups(self):
        """get_group_dict returns dict with Group values and tuple keys."""
        copyright_, options = self._process_tree("tests/testdata/four")
        groups = copyright_.get_group_dict(options)
        self.assertIsInstance(groups, dict)
        for key, value in groups.items():
            self.assertIsInstance(key, tuple)
            self.assertIsInstance(value, dep5.Group)


class TestStandaloneLicenseCanonicalisation(unittest.TestCase):
    """A standalone License paragraph is stored under its canonical key.

    The build owns its own LicenseRegistry, so each test reads stored text from
    that instance, with no shared state to reset.
    """

    def test_standalone_spdx_named_license_text_found_under_canonical_key(self):
        """A standalone `License: GPL-2.0-only` stanza's text reaches the GPL-2 group.

        The Files paragraph reads as the canonical GPL-2, so the group
        renders its stanza under that key. The standalone licence text must be
        stored under the same canonical key, or it is silently dropped and the
        output shows the "add the text here" placeholder instead.
        """
        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/spdxstandalonelicense"]
        )
        filetree = tree.RootInfo.build(options)
        copyright_ = dep5.read_copyright(options)
        copyright_.process(filetree)

        block = Dep5Renderer().license_block(copyright_.registry.get("GPL-2"), options)
        self.assertIn("DISTINCTIVE-MARKER-A4", block)
        self.assertNotIn("Add the corresponding license text here", block)


class TestGroup(unittest.TestCase):
    def test_unknown_license(self):
        g = dep5.Group()
        self.assertEqual(g.license, core_types.UNKNOWN)
        cg = holders.CopyrightHolders()
        cg.add(core_types.CopyrightHolder("", "foo@example.com"))
        g.add_copyrights(cg)
        self.assertEqual(g.license, core_types.UNKNOWN_COPYRIGHTED)

    def test_add_file_records_licenses_without_shared_registry(self):
        """add_file builds its own License per name, not via a shared global."""
        root = tree.RootInfo(root=".")
        first, second = dep5.Group(), dep5.Group()
        for group in (first, second):
            fileinfo = nodes.FileInfo(root, "f.c")
            fileinfo.add_licenses(["MIT"])
            group.add_file(fileinfo)
        self.assertEqual(first.licenses["MIT"].name, "MIT")
        self.assertIsNot(first.licenses["MIT"], second.licenses["MIT"])

    def test_add_copyrights_accepts_copyright_holder_list(self):
        g = dep5.Group()
        holders = [
            core_types.CopyrightHolder("Alice", "alice@example.com"),
            core_types.CopyrightHolder("Bob", "bob@example.com"),
        ]
        g.add_copyrights(holders)
        self.assertEqual(len(g.copyrights), 2)

    def test_add_copyrights_accepts_copyright_group(self):
        g = dep5.Group()
        cg = holders.CopyrightHolders()
        cg.add(core_types.CopyrightHolder("Carol", "carol@example.com"))
        g.add_copyrights(cg)
        self.assertEqual(len(g.copyrights), 1)

    def test_sort_key_returns_five_tuple(self):
        g = dep5.Group(position=1)
        g.licenses["MIT"] = core_types.License("MIT")
        root = tree.RootInfo(root=".")
        fi = nodes.FileInfo(root, "foo.c")
        g.files.add_file(fi)
        options = cmdoptions.process_options(["--no-progress", "--root", "."])
        key = g.sort_key(options)
        self.assertIsInstance(key, tuple)
        self.assertEqual(len(key), 5)
        self.assertIsInstance(key[0], int)
        self.assertIsInstance(key[1], int)


class TestGroupCopyrightBlock(unittest.TestCase):
    """Unit tests for the DEP-5 Files paragraph (Dep5Renderer.file_block).

    These tests construct Group objects directly (no file I/O) so that each
    test exercises exactly one aspect of the generated DEP-5 block string.
    group.files.add_file() is used instead of group.add_file() intentionally:
    the latter merges licenses and copyrights from the FileInfo, which would
    interfere with the explicit values set by the tests.
    """

    def _block(self, group, *, glob=True):
        flag = "--glob" if glob else "--no-glob"
        opts = cmdoptions.process_options(["--no-progress", flag])
        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 _file(self, name, parent=None):
        return nodes.FileInfo(parent=parent, name=name)

    def _group_with_file(self, filename, license_name=None):
        group = dep5.Group()
        group.files.add_file(self._file(filename))
        if license_name:
            group.licenses[license_name] = core_types.License(license_name)
        return group

    # valid

    def test_valid_empty_group(self):
        self.assertFalse(dep5.Group().copyright_block_valid())

    def test_valid_single_unnamed_file(self):
        group = dep5.Group()
        group.files.add_file(self._file(""))
        self.assertFalse(group.copyright_block_valid())

    def test_valid_single_named_file(self):
        group = dep5.Group()
        group.files.add_file(self._file("src/foo.c"))
        self.assertTrue(group.copyright_block_valid())

    def test_valid_multiple_files(self):
        group = dep5.Group()
        group.files.add_file(self._file("a.c"))
        group.files.add_file(self._file("b.c"))
        self.assertTrue(group.copyright_block_valid())

    # returns "" for invalid

    def test_block_empty_group_returns_empty(self):
        self.assertEqual(self._block(dep5.Group()), "")

    def test_block_single_unnamed_file_returns_empty(self):
        group = dep5.Group()
        group.files.add_file(self._file(""))
        self.assertEqual(self._block(group), "")

    # Files + License, no Copyright

    def test_block_omits_copyright_line_when_no_holders(self):
        block = self._block(self._group_with_file("src/foo.c", "MIT"))
        self.assertEqual(block, "Files: src/foo.c\nLicense: MIT")
        self.assertNotIn("Copyright:", block)

    # full block, all fields

    def test_block_copyright_line_present_with_one_holder(self):
        group = self._group_with_file("src/foo.c", "MIT")
        group.add_copyrights(
            [
                core_types.CopyrightHolder(
                    "Alice",
                    "alice@example.com",
                    core_types.YearRange(2020, 2020),
                ),
            ],
        )
        self.assertEqual(
            self._block(group),
            "Files: src/foo.c\n"
            "Copyright: 2020, Alice <alice@example.com>\n"
            "License: MIT",
        )

    def test_block_comment_line_present_when_set(self):
        group = self._group_with_file("src/foo.c", "MIT")
        group.add_copyrights(
            [
                core_types.CopyrightHolder(
                    "Alice",
                    "alice@example.com",
                    core_types.YearRange(2020, 2020),
                ),
            ],
        )
        group.comments.append("see also COPYING")
        self.assertEqual(
            self._block(group),
            "Files: src/foo.c\n"
            "Copyright: 2020, Alice <alice@example.com>\n"
            "License: MIT\n"
            "Comment: see also COPYING",
        )

    def test_block_omits_comment_line_when_empty(self):
        block = self._block(self._group_with_file("src/foo.c", "MIT"))
        self.assertNotIn("Comment:", block)

    # continuation indentation

    def test_block_multiple_files_indented_by_7_spaces(self):
        group = dep5.Group()
        for name in ["src/bar.c", "src/foo.c"]:
            group.files.add_file(self._file(name))
        group.licenses["MIT"] = core_types.License("MIT")
        self.assertEqual(
            self._block(group, glob=False),
            "Files: src/bar.c\n       src/foo.c\nLicense: MIT",
        )

    def test_block_multiple_holders_indented_by_11_spaces(self):
        group = self._group_with_file("src/foo.c", "MIT")
        group.add_copyrights(
            [
                core_types.CopyrightHolder(
                    "Alice",
                    "alice@example.com",
                    core_types.YearRange(2020, 2020),
                ),
                core_types.CopyrightHolder(
                    "Bob",
                    "bob@example.com",
                    core_types.YearRange(2021, 2021),
                ),
            ],
        )
        self.assertEqual(
            self._block(group),
            "Files: src/foo.c\n"
            "Copyright: 2020, Alice <alice@example.com>\n"
            "           2021, Bob <bob@example.com>\n"
            "License: MIT",
        )

    # glob_=True vs glob_=False routing

    def test_block_glob_produces_directory_wildcard(self):
        src = nodes.DirInfo(parent=None, name="src")
        group = dep5.Group()
        for name in ["a.c", "b.c"]:
            group.files.add_file(self._file(name, parent=src))
        group.licenses["MIT"] = core_types.License("MIT")
        self.assertEqual(
            self._block(group),
            "Files: src/*\nLicense: MIT",
        )

    def test_block_no_glob_lists_individual_filenames(self):
        src = nodes.DirInfo(parent=None, name="src")
        group = dep5.Group()
        for name in ["b.c", "a.c"]:
            group.files.add_file(self._file(name, parent=src))
        group.licenses["MIT"] = core_types.License("MIT")
        self.assertEqual(
            self._block(group, glob=False),
            "Files: src/a.c\n       src/b.c\nLicense: MIT",
        )

    # get_inherited_comment

    def test_inherited_comment_no_filenames_returns_none(self):
        group = dep5.Group()
        self.assertIsNone(group.get_inherited_comment())

    def test_inherited_comment_with_stored_returns_none(self):
        group = dep5.Group()
        group.license_filenames.add("LICENSE")
        group.stored = dc.FilesParagraph.create(["*"], "2020 Alice", dc.License("MIT"))
        self.assertIsNone(group.get_inherited_comment())

    def test_stored_accepts_files_paragraph(self):
        group = dep5.Group()
        paragraph = dc.FilesParagraph.create(["*"], "2020 Alice", dc.License("MIT"))
        group.stored = paragraph
        self.assertIsInstance(group.stored, dc.FilesParagraph)

    # extra_fields

    def test_extra_fields_empty_without_stored(self):
        group = dep5.Group()
        self.assertEqual(list(group.extra_fields()), [])

    def test_extra_fields_yields_non_standard_stored_fields(self):
        group = dep5.Group()
        paragraph = dc.FilesParagraph.create(["*"], "2020 Alice", dc.License("MIT"))
        paragraph["X-Foo"] = "bar"
        group.stored = paragraph
        # The standard Files/Copyright/License/Comment fields are rendered by the
        # renderer itself; extra_fields exposes only the rest.
        self.assertEqual(list(group.extra_fields()), [("X-Foo", "bar")])

    def test_inherited_comment_with_filenames_no_stored(self):
        group = dep5.Group()
        group.license_filenames.add("LICENSE")
        comment = group.get_inherited_comment()
        assert comment is not None
        self.assertIn("LICENSE", comment)
        self.assertIn("No explicit license found", comment)

    def test_inherited_comment_multiple_filenames_sorted(self):
        group = dep5.Group()
        group.license_filenames.update(["COPYING", "LICENSE", "AUTHORS"])
        comment = group.get_inherited_comment()
        assert comment is not None
        lines = comment.splitlines()
        # First line is the header; remaining lines contain sorted filenames
        self.assertIn("No explicit license found", lines[0])
        filenames_in_comment = [line.strip() for line in lines[1:]]
        self.assertEqual(filenames_in_comment, ["AUTHORS", "COPYING", "LICENSE"])


class TestGroupSortKey(unittest.TestCase):
    """Unit tests for Group.sort_key() output ordering.

    sort_key() returns a tuple used by an ascending sort in output.py.
    A smaller sort_key means the group appears earlier (higher) in the output.

    Structure: (debian_group, -position, -n_files, -n_copyrights, group_key)
      debian_group=0  normal groups         → first in output
      debian_group=1  UNKNOWN* licenses     → middle
      debian_group=2  debian/ when split    → last in output
    """

    def setUp(self):
        self.root = nodes.DirInfo(parent=None, name="")
        self.debian_dir = nodes.DirInfo(parent=self.root, name="debian")
        self.src_dir = nodes.DirInfo(parent=self.root, name="src")

    def _opts(self, *, split_debian=True):
        flags = ["--no-progress", "--root", "tests/testdata/one"]
        if not split_debian:
            flags.append("--no-split-debian")
        return cmdoptions.process_options(flags)

    def _group(self, license_name=None, position=0, parent=None, n_files=1):
        """Create a Group with n_files under the given DirInfo parent."""
        group = dep5.Group(position=position)
        parent = parent if parent is not None else self.root
        for i in range(n_files):
            group.files.add_file(nodes.FileInfo(parent=parent, name=f"f{i}.txt"))
        if license_name:
            group.licenses[license_name] = core_types.License(license_name)
        return group

    # UNKNOWN/UNKNOWN_COPYRIGHTED

    def test_unknown_license_sorts_after_real_license(self):
        opts = self._opts()
        real = self._group("MIT")
        unknown = self._group()
        self.assertEqual(unknown.license, core_types.UNKNOWN)
        self.assertGreater(unknown.sort_key(opts), real.sort_key(opts))

    def test_unknown_copyrighted_sorts_after_real_license(self):
        opts = self._opts()
        real = self._group("MIT")
        unk_cop = self._group()
        unk_cop.add_copyrights([core_types.CopyrightHolder("", "x@example.com")])
        self.assertEqual(unk_cop.license, core_types.UNKNOWN_COPYRIGHTED)
        self.assertGreater(unk_cop.sort_key(opts), real.sort_key(opts))

    def test_unknown_and_unknown_copyrighted_share_same_band(self):
        opts = self._opts()
        unknown = self._group()
        unk_cop = self._group()
        unk_cop.add_copyrights([core_types.CopyrightHolder("", "x@example.com")])
        # Both land in debian_group=1
        self.assertEqual(unknown.sort_key(opts)[0], 1)
        self.assertEqual(unk_cop.sort_key(opts)[0], 1)

    # split_debian flag

    def test_debian_group_sorts_last_with_split_debian(self):
        opts = self._opts(split_debian=True)
        debian = self._group("MIT", parent=self.debian_dir)
        normal = self._group("MIT", parent=self.src_dir)
        self.assertGreater(debian.sort_key(opts), normal.sort_key(opts))

    def test_debian_group_sorts_after_unknown_with_split_debian(self):
        opts = self._opts(split_debian=True)
        debian = self._group("MIT", parent=self.debian_dir)
        unknown = self._group(parent=self.src_dir)
        # Full three-way ordering: normal(0) < UNKNOWN(1) < debian(2)
        self.assertGreater(debian.sort_key(opts), unknown.sort_key(opts))

    def test_debian_group_not_special_without_split_debian(self):
        opts = self._opts(split_debian=False)
        debian = self._group("MIT", parent=self.debian_dir)
        normal = self._group("MIT", parent=self.src_dir)
        # Both land in debian_group=0 when the flag is off
        self.assertEqual(debian.sort_key(opts)[0], 0)
        self.assertEqual(normal.sort_key(opts)[0], 0)

    # position-based secondary sort

    def test_higher_position_sorts_before_lower_position(self):
        opts = self._opts()
        low = self._group("MIT", position=0)
        high = self._group("MIT", position=3)
        # -3 < 0 → high-position group has smaller sort_key → appears first
        self.assertLess(high.sort_key(opts), low.sort_key(opts))

    def test_position_ordering_across_multiple_groups(self):
        opts = self._opts()
        groups = [self._group("MIT", position=p) for p in [0, 2, 1]]
        ordered = sorted(groups, key=lambda g: g.sort_key(opts))
        self.assertEqual([g.position for g in ordered], [2, 1, 0])

    # file-count secondary sort

    def test_more_files_sorts_before_fewer_files(self):
        opts = self._opts()
        few = self._group("MIT", n_files=1)
        many = self._group("MIT", n_files=3)
        # -3 < -1 → larger group has smaller sort_key → appears first
        self.assertLess(many.sort_key(opts), few.sort_key(opts))

    # full priority end-to-end sort

    def test_full_sort_order_normal_then_unknown_then_debian(self):
        opts = self._opts(split_debian=True)
        g_real = self._group("MIT", position=0, parent=self.src_dir)
        g_unk = self._group(position=0, parent=self.src_dir)
        g_deb = self._group("MIT", position=0, parent=self.debian_dir)

        ordered = sorted([g_deb, g_unk, g_real], key=lambda g: g.sort_key(opts))

        self.assertIs(ordered[0], g_real)
        self.assertIs(ordered[1], g_unk)
        self.assertIs(ordered[2], g_deb)


class TestGroupKeyIgnoreCopyright(unittest.TestCase):
    """get_key(ignore_copyright=True) blanks only the copyright slot."""

    def test_ignore_copyright_blanks_only_the_copyright_slot(self):
        options = cmdoptions.process_options(
            [
                "--no-progress",
                "--root",
                "tests/testdata/one",
                "--group-by",
                "copyright",
            ]
        )
        root = nodes.DirInfo(parent=None, name="")
        group = Group()
        fileinfo = nodes.FileInfo(parent=root, name="a.txt")
        fileinfo.add_licenses(["MIT"])
        fileinfo.add_copyrights([core_types.CopyrightHolder("Alice", "alice@x")])
        group.add_file(fileinfo)

        full = group.get_key(options)
        masked = group.get_key(options, ignore_copyright=True)

        # The copyright slot is populated under copyright grouping...
        self.assertNotEqual(full[1], ())
        # ...and masking blanks only that slot, leaving licence and path intact.
        self.assertEqual(masked, (full[0], (), full[2]))


class TestResolveCopyrightPath(unittest.TestCase):
    """The single copyright-file path resolver shared by build/_read_header/CLI."""

    @staticmethod
    def _options(root, copyright_file):
        return cmdoptions.process_options(
            ["--no-progress", "--root", root, "--copyright-file", copyright_file]
        )

    def test_relative_is_joined_to_root(self):
        with tempfile.TemporaryDirectory() as tmp:
            options = self._options(tmp, "debian/copyright")
            self.assertEqual(
                copyright_processor.resolve_copyright_path(options),
                Path(options.root) / "debian/copyright",
            )

    def test_absolute_is_returned_unchanged(self):
        with tempfile.TemporaryDirectory() as tmp:
            absolute = str(Path(tmp) / "elsewhere" / "copyright")
            options = self._options(tmp, absolute)
            self.assertEqual(
                copyright_processor.resolve_copyright_path(options),
                Path(absolute),
            )

    def test_relpath_is_relative_to_root(self):
        with tempfile.TemporaryDirectory() as tmp:
            options = self._options(tmp, "debian/copyright")
            self.assertEqual(
                decopy_main._copyright_relpath(options), "debian/copyright"
            )

    def test_relpath_outside_root_falls_back_to_full_path(self):
        with tempfile.TemporaryDirectory() as tmp:
            outside = "/some/other/copyright"
            options = self._options(tmp, outside)
            self.assertEqual(decopy_main._copyright_relpath(options), outside)


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