#!/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 dataclasses
import tempfile
import unittest
from concurrent import futures
from pathlib import Path
from unittest.mock import patch

from decopy import cmdoptions
from decopy.common.core_types import (
    UNKNOWN,
    UNKNOWN_COPYRIGHTED,
    CopyrightHolder,
    License,
    YearRange,
)
from decopy.dep5doc import dep5
from decopy.fstree import nodes, tree
from decopy.grouping import group
from decopy.grouping.file_policy import group_key, license_key, splitting_path
from decopy.licensing import holders


class TestFileInfo(unittest.TestCase):
    def setUp(self):
        # Create one root with one dir with one file
        self.r = tree.RootInfo(root="root")
        self.d = nodes.DirInfo(name="dir", parent=self.r)
        self.f = nodes.FileInfo(name="file", parent=self.d)

    def test_fullname(self):
        self.assertEqual(self.r.fullname, "")
        self.assertEqual(self.d.fullname, "dir")
        self.assertEqual(self.f.fullname, "dir/file")

    def test_add_licenses_stores_full_filename(self):
        self.d.add_licenses({"GPL"}, {"COPYING"})
        self.assertIn("COPYING", self.d.license_filenames)
        self.assertNotIn("C", self.d.license_filenames)

    def test_get_licenses(self):
        # Set a license for the directory
        self.d.add_licenses({"GPL"}, {"COPYING"})
        # The license for the file should be the one from the directory
        self.assertEqual(self.f.get_licenses(), {"GPL"})

        # Set a different license for the file
        self.f.add_licenses({"BSD"})
        # Now the license is the one for the file
        self.assertEqual(self.f.get_licenses(), {"BSD"})

        # Add a second license to the file
        self.f.add_licenses({"PHP"})
        # Now it should have both licenses
        self.assertEqual(self.f.get_licenses(), {"BSD", "PHP"})

    def test_splitting_path(self):
        # Set a license for the directory
        self.d.add_licenses({"GPL"}, {"COPYING"})
        # Create a child directory with a different license
        self.o = nodes.DirInfo(name="other", parent=self.d)
        self.o.add_licenses({"BSD"}, {"COPYING"})

        # First test with splitting on license
        options = cmdoptions.process_options(["--no-progress", "--split-on-license"])
        self.assertEqual(splitting_path(self.d, options), "dir")
        self.assertEqual(splitting_path(self.o, options), "dir/other")

        # Then test without splitting on license
        options = cmdoptions.process_options(["--no-progress", "--no-split-on-license"])
        self.assertEqual(splitting_path(self.d, options), "")
        self.assertEqual(splitting_path(self.o, options), "")

        # Create a debian directory, with another license
        self.deb = nodes.DirInfo(name="debian", parent=self.r)
        self.deb.add_licenses({"LGPL"}, {"debian/copyright"})
        options = cmdoptions.process_options(
            ["--no-progress", "--split-debian", "--no-split-on-license"],
        )
        self.assertEqual(splitting_path(self.deb, options), "debian")

        options = cmdoptions.process_options(
            ["--no-progress", "--split-debian", "--split-on-license"],
        )
        self.assertEqual(splitting_path(self.deb, options), "debian")

        options = cmdoptions.process_options(
            ["--no-progress", "--no-split-debian", "--split-on-license"],
        )
        self.assertEqual(splitting_path(self.deb, options), "debian")

        options = cmdoptions.process_options(
            ["--no-progress", "--no-split-debian", "--no-split-on-license"],
        )
        self.assertEqual(splitting_path(self.deb, options), "")

    def test_splitting_path_file_delegates_to_parent(self):
        # A FileInfo has no splitting path of its own; it uses its parent dir's.
        self.d.add_licenses({"GPL"}, {"COPYING"})
        options = cmdoptions.process_options(["--no-progress", "--split-on-license"])
        self.assertEqual(
            splitting_path(self.f, options),
            splitting_path(self.d, options),
        )
        self.assertEqual(splitting_path(self.f, options), "dir")

    def test_splitting_path_file_no_parent_returns_none(self):
        orphan = nodes.FileInfo(parent=None, name="orphan")
        options = cmdoptions.process_options(["--no-progress"])
        self.assertIsNone(splitting_path(orphan, options))

    def test_group_key_license_mode_has_no_copyright_slot(self):
        self.d.add_licenses({"GPL"}, {"COPYING"})
        self.f.add_licenses({"MIT"})
        options = cmdoptions.process_options(["--no-progress", "--group-by", "license"])
        # (license_key, copyright slot is None in license mode, splitting path)
        self.assertEqual(group_key(self.f, options), ("MIT", None, "dir"))

    def test_group_key_copyright_mode_fills_copyright_slot(self):
        self.d.add_licenses({"GPL"}, {"COPYING"})
        self.f.add_licenses({"MIT"})
        self.f.add_copyrights([CopyrightHolder("Alice", "alice@example.com")])
        options = cmdoptions.process_options(
            ["--no-progress", "--group-by", "copyright"],
        )
        self.assertEqual(
            group_key(self.f, options),
            ("MIT", ("alice@example.com",), "dir"),
        )

    def test_included(self):
        self.assertEqual(self.f.included, False)
        # Remove the cache
        self.f.included = None
        self.d.included = True
        self.assertEqual(self.f.included, True)


class TestRootInfo(unittest.TestCase):
    def _check_expected_files(self, filetree, expected_files):
        """Check that the parsed filetree has the expected files."""
        self.longMessage = False
        for filename in expected_files:
            with self.subTest(msg=f'Checking "{filename}"'):
                try:
                    name = filetree[filename].fullname
                except IndexError:
                    name = ""
                self.assertEqual(
                    name,
                    filename,
                    msg=f'missing file: "{filename}"',
                )

        for fileinfo in filetree:
            with self.subTest(msg=f'Checking "{fileinfo.fullname}"'):
                self.assertTrue(
                    fileinfo.fullname in expected_files,
                    msg=f'unexpected file: "{fileinfo.fullname}"',
                )
        self.longMessage = True

    def test_simple_tree(self):
        """Parses a tree with licensed files (testdata/one)."""

        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/one"],
        )
        expected_files = {
            "": "Inherited(GPL-2+)",
            "COPYING": "GPL-2+",
            "art.txt": "Artistic-1",
            "bsd.txt": "BSD-3-clause",
            "empty.txt": "Inherited(GPL-2+)",
            "no.txt": "Inherited(GPL-2+)",
            "paren.txt": "GPL-3+",
        }

        # First build the tree and check that it has the files and nothing else
        filetree = tree.RootInfo.build(options)
        self._check_expected_files(filetree, expected_files)

        # Now process the licenses
        filetree.process(options)

        # Check that the licenses match our expectations
        for filename, license_ in expected_files.items():
            with self.subTest(msg=f'Checking "{filename}"'):
                self.assertEqual(
                    license_key(filetree[filename]),
                    license_,
                    msg=f'license mismatch for: "{filename}"',
                )

    def test_copyrighted_tree(self):
        """Parses a tree with licensed & copyrighted files (testdata/two)."""

        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/two"],
        )
        expected_files = {
            "": ["Inherited(GPL-2+)", ()],
            "COPYING": ["GPL-2+", ()],
            "art.txt": ["Artistic-1", ("Jane Doe",)],
            "bsd.txt": ["BSD-3-clause", ("John Doe",)],
            "empty.txt": ["Inherited(GPL-2+)", ()],
            "no.txt": ["Inherited(GPL-2+)", ("Juan Pérez", "Juana López")],
            "docbook.txt": ["Inherited(GPL-2+)", ()],
        }

        # First build the tree and check that it has the files and nothing else
        filetree = tree.RootInfo.build(options)
        self._check_expected_files(filetree, expected_files)

        # Now process the licenses
        filetree.process(options)

        # Check that the licenses and copyrights match our expectations
        for filename, values in expected_files.items():
            license_, expected_copyright = values
            with self.subTest(msg=f'Checking "{filename}"'):
                self.assertEqual(
                    license_key(filetree[filename]),
                    license_,
                    msg=f'license mismatch for: "{filename}"',
                )

                self.assertEqual(
                    filetree[filename].copyrights.key(),
                    tuple(sorted(expected_copyright)),
                    msg=f'copyright mismatch for: "{filename}"',
                )

    def test_debianized_tree(self):
        """Parses a tree that contains a debian/copyright (testdata/three)."""

        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/three"],
        )
        expected_files = {
            "": ["Inherited(GPL-2+)", ()],
            "COPYING": ["GPL-2+", ()],
            "src": ["Inherited(GPL-2+)", ()],
            "src/do-something.m": ["MIT/X11", ("clara@example.com",)],
            "src/do-something.py": ["MIT/X11", ("clara@example.com",)],
            "src/main.py": ["GPL-2+", ("janedoe@example.com",)],
            "src/module.py": ["GPL-2+", ("janedoe@example.com",)],
            "acinclude.m4": ["GPL-2+", ("Free Software Foundation, Inc",)],
            "data": ["Inherited(GPL-2+)", ()],
            "data/file.txt": ["MIT/X11", ("clara@example.com",)],
            "debian": ["Inherited(GPL-2+)", ()],
            "debian/rules": ["LGPL-2+", ()],
            "debian/compat": ["LGPL-2+", ()],
            "debian/copyright": ["LGPL-2+", ()],
        }

        # First build the tree and check that it has the files and nothing else
        filetree = tree.RootInfo.build(options)
        self._check_expected_files(filetree, expected_files)

        # Instantiate and process the copyright
        copyright_ = dep5.read_copyright(options)
        copyright_.process(filetree)

        # Now process the licenses in the filetree
        filetree.process(options)

        # Check that the licenses and copyrights match our expectations
        for filename, values in expected_files.items():
            license_, expected_copyright = values
            with self.subTest(msg=f'Checking "{filename}"'):
                self.assertEqual(
                    license_key(filetree[filename]),
                    license_,
                    msg=f'license mismatch for: "{filename}"',
                )

                self.assertEqual(
                    filetree[filename].copyrights.key(),
                    tuple(sorted(expected_copyright)),
                    msg=f'copyright mismatch for: "{filename}"',
                )

    def test_tree_exclude(self):
        """Parse a tree with an exclusion expresion (testdata/three)."""

        options = cmdoptions.process_options(
            ["--no-progress", "--root", "tests/testdata/three", "-X", "src.*"],
        )
        expected_files = {
            "": ["Inherited(GPL-2+)", ()],
            "COPYING": ["GPL-2+", ()],
            "src": ["Inherited(GPL-2+)", ()],
            "src/do-something.m": ["Inherited(GPL-2+)", ()],
            "src/do-something.py": ["Inherited(GPL-2+)", ()],
            "src/main.py": ["Inherited(GPL-2+)", ()],
            "src/module.py": ["Inherited(GPL-2+)", ()],
            "acinclude.m4": ["GPL-2+", ("Free Software Foundation, Inc",)],
            "data": ["Inherited(GPL-2+)", ()],
            "data/file.txt": ["MIT/X11", ("clara@example.com",)],
            "debian": ["Inherited(GPL-2+)", ()],
            "debian/rules": ["Inherited(GPL-2+)", ()],
            "debian/compat": ["Inherited(GPL-2+)", ()],
            "debian/copyright": ["Inherited(GPL-2+)", ()],
        }

        # First build the tree and check that it has the files and nothing else
        filetree = tree.RootInfo.build(options)
        self._check_expected_files(filetree, expected_files)

        # Don't process the debian/copyright, so that we can test just the
        # parsed tree.

        # Now process the licenses in the filetree. Excluded files won't get
        # processed, so they get inherited values.
        filetree.process(options)

        # Check that the licenses and copyrights match our expectations
        for filename, values in expected_files.items():
            license_, expected_copyright = values
            with self.subTest(msg=f'Checking "{filename}"'):
                self.assertEqual(
                    license_key(filetree[filename]),
                    license_,
                    msg=f'license mismatch for: "{filename}"',
                )

                self.assertEqual(
                    filetree[filename].copyrights.key(),
                    tuple(sorted(expected_copyright)),
                    msg=f'copyright mismatch for: "{filename}"',
                )


class TestWildcardPattern(unittest.TestCase):
    def test_file_pattern_is_its_fullname(self):
        self.assertEqual(nodes.FileInfo(None, "foo").wildcard_pattern(), "foo")

    def test_nested_file_pattern_is_its_fullname(self):
        dirinfo = nodes.DirInfo(None, "dir")
        fileinfo = nodes.FileInfo(dirinfo, "foo")
        self.assertEqual(fileinfo.wildcard_pattern(), "dir/foo")

    def test_dir_pattern_appends_slash_star(self):
        self.assertEqual(nodes.DirInfo(None, "dir").wildcard_pattern(), "dir/*")

    def test_nested_dir_pattern_appends_slash_star(self):
        dirinfo = nodes.DirInfo(nodes.DirInfo(None, "a"), "b")
        self.assertEqual(dirinfo.wildcard_pattern(), "a/b/*")

    def test_root_dir_pattern_is_star(self):
        self.assertEqual(tree.RootInfo("root").wildcard_pattern(), "*")


class TestNodeTraversal(unittest.TestCase):
    def _small_tree(self):
        root = nodes.DirInfo(None, "")
        sub = nodes.DirInfo(root, "sub")
        root.file_list["sub"] = sub
        top = nodes.FileInfo(root, "a")
        root.file_list["a"] = top
        nested = nodes.FileInfo(sub, "b")
        sub.file_list["b"] = nested
        return root, sub, top, nested

    def test_file_has_no_children(self):
        self.assertEqual(list(nodes.FileInfo(None, "foo").children), [])

    def test_dir_children_are_its_members(self):
        root, sub, top, _nested = self._small_tree()
        self.assertEqual(list(root.children), [sub, top])

    def test_file_walk_yields_only_itself(self):
        fileinfo = nodes.FileInfo(None, "foo")
        self.assertEqual(list(fileinfo.walk()), [fileinfo])

    def test_dir_walk_yields_self_then_descendants(self):
        root, sub, top, nested = self._small_tree()
        self.assertEqual(list(root.walk()), [root, sub, nested, top])

    def test_walk_matches_iter(self):
        root, *_ = self._small_tree()
        self.assertEqual(list(root.walk()), list(root))

    def test_walk_files_yields_only_leaves(self):
        root, _sub, top, nested = self._small_tree()
        self.assertEqual(list(root.walk_files()), [nested, top])

    def test_file_walk_files_yields_itself(self):
        fileinfo = nodes.FileInfo(None, "foo")
        self.assertEqual(list(fileinfo.walk_files()), [fileinfo])

    def test_file_recompute_total_is_one(self):
        self.assertEqual(nodes.FileInfo(None, "foo").recompute_total(), 1)

    def test_empty_dir_recompute_total_is_zero(self):
        self.assertEqual(nodes.DirInfo(None, "dir").recompute_total(), 0)

    def test_dir_recompute_total_counts_leaves_and_sets_total(self):
        root, *_ = self._small_tree()
        self.assertEqual(root.recompute_total(), 2)
        self.assertEqual(root.total, 2)


class TestIsContainer(unittest.TestCase):
    def test_file_is_not_a_container(self):
        self.assertFalse(nodes.FileInfo(None, "foo").is_container)

    def test_dir_is_a_container(self):
        self.assertTrue(nodes.DirInfo(None, "dir").is_container)

    def test_archive_dir_is_a_container(self):
        archive = nodes.ArchiveDirInfo(
            parent=nodes.DirInfo(None, ""), name="a.tar", archive_path="/x"
        )
        self.assertTrue(archive.is_container)


class TestAsContainer(unittest.TestCase):
    def test_file_as_container_is_none(self):
        self.assertIsNone(nodes.FileInfo(None, "foo").as_container())

    def test_dir_as_container_is_itself(self):
        dirinfo = nodes.DirInfo(None, "dir")
        self.assertIs(dirinfo.as_container(), dirinfo)

    def test_archive_dir_as_container_is_itself(self):
        archive = nodes.ArchiveDirInfo(
            parent=nodes.DirInfo(None, ""), name="a.tar", archive_path="/x"
        )
        self.assertIs(archive.as_container(), archive)


class TestGetChild(unittest.TestCase):
    def test_file_has_no_child(self):
        self.assertIsNone(nodes.FileInfo(None, "foo").get_child("x"))

    def test_dir_returns_member(self):
        root = nodes.DirInfo(None, "")
        leaf = nodes.FileInfo(root, "a")
        root.file_list["a"] = leaf
        self.assertIs(root.get_child("a"), leaf)

    def test_dir_returns_none_for_missing(self):
        self.assertIsNone(nodes.DirInfo(None, "").get_child("nope"))


class TestParallelProcessing(unittest.TestCase):
    """RootInfo.process: the process-pool path matches the serial path."""

    @staticmethod
    def _run(root: str, jobs: int) -> dict[str, tuple[list[str], list[str]]]:
        opts = cmdoptions.process_options(
            [
                "--no-progress",
                "--no-expand-archives",
                "--root",
                root,
                "--jobs",
                str(jobs),
            ]
        )
        # Force the parallel path for this tiny tree by lowering the threshold.
        with patch.object(tree, "_PARALLEL_MIN", 1):
            filetree = tree.RootInfo.build(opts)
            filetree.process(opts)
        return {
            item.fullname: (
                sorted(item.licenses),
                sorted(str(h) for h in item.copyrights),
            )
            for item in filetree.walk_files()
        }

    def test_parallel_matches_serial(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "a.py").write_text(
                "# Copyright 2020 Alice <alice@example.com>\n"
                "# SPDX-License-Identifier: MIT\n"
            )
            (root / "b.txt").write_text("Copyright 2019 Bob <bob@example.com>\n")
            (root / "COPYING").write_text(
                "Permission is hereby granted, free of charge, to any person\n"
            )
            serial = self._run(str(root), jobs=1)  # workers==1 -> serial path
            parallel = self._run(str(root), jobs=2)  # workers>1 -> process pool
            self.assertEqual(serial, parallel)
            # Sanity: the run actually detected something to compare.
            self.assertTrue(any(lic for lic, _ in parallel.values()))
            self.assertTrue(any(cr for _, cr in parallel.values()))


class TestCollectChildLicenses(unittest.TestCase):
    @staticmethod
    def _completed(fileinfo):
        fut: futures.Future[nodes.FileInfo | None] = futures.Future()
        fut.set_result(fileinfo)
        return fut

    def test_aggregates_licenses_from_completed_tasks(self):
        root = nodes.DirInfo(None, "")
        leaf = nodes.FileInfo(root, "COPYING")
        leaf.add_licenses({"GPL-2+"})
        root.tasks = {self._completed(leaf)}

        result = root.collect_child_licenses()

        self.assertIs(result, root)
        self.assertIn("GPL-2+", root.licenses)
        self.assertIn("COPYING", root.license_filenames)
        self.assertEqual(root.tasks, set())

    def test_skips_children_without_licenses(self):
        root = nodes.DirInfo(None, "")
        leaf = nodes.FileInfo(root, "README")
        root.tasks = {self._completed(leaf)}

        root.collect_child_licenses()

        self.assertEqual(root.licenses, set())


class TestSubdirHelpers(unittest.TestCase):
    def test_get_subdir_returns_dir(self):
        root = nodes.DirInfo(None, "")
        sub = nodes.DirInfo(root, "sub")
        root.file_list["sub"] = sub
        self.assertIs(root.get_subdir("sub"), sub)

    def test_get_subdir_none_for_file(self):
        root = nodes.DirInfo(None, "")
        root.file_list["f"] = nodes.FileInfo(root, "f")
        self.assertIsNone(root.get_subdir("f"))

    def test_get_subdir_none_for_missing(self):
        self.assertIsNone(nodes.DirInfo(None, "").get_subdir("nope"))

    def test_get_or_create_subdir_returns_existing(self):
        root = nodes.DirInfo(None, "")
        sub = nodes.DirInfo(root, "sub")
        root.file_list["sub"] = sub
        self.assertIs(root.get_or_create_subdir("sub"), sub)

    def test_get_or_create_subdir_creates_missing(self):
        root = nodes.DirInfo(None, "")
        created = root.get_or_create_subdir("new")
        assert isinstance(created, nodes.DirInfo)
        self.assertIs(root.file_list["new"], created)
        self.assertIs(created.parent, root)

    def test_get_or_create_subdir_none_on_file_collision(self):
        root = nodes.DirInfo(None, "")
        root.file_list["f"] = nodes.FileInfo(root, "f")
        self.assertIsNone(root.get_or_create_subdir("f"))


class TestDirAdd(unittest.TestCase):
    def test_add_creates_nested_dirs(self):
        root = nodes.DirInfo(None, "")
        root.add(["a", "b"], [], ["leaf"])
        a = root.get_subdir("a")
        assert a is not None
        b = a.get_subdir("b")
        assert b is not None
        self.assertIn("leaf", b.file_list)

    def test_add_does_not_descend_into_a_file(self):
        root = nodes.DirInfo(None, "")
        leaf = nodes.FileInfo(root, "x")
        root.file_list["x"] = leaf
        # "x" is a file; descending into it as a dir is a no-op, not a crash.
        root.add(["x"], [], ["child"])
        self.assertIs(root.file_list["x"], leaf)


class TestRootIndexing(unittest.TestCase):
    def setUp(self):
        self.root = tree.RootInfo("root")
        self.root.add([], ["dir"], ["top"])
        self.root.add(["dir"], [], ["leaf"])

    def test_empty_key_returns_root(self):
        self.assertIs(self.root[""], self.root)
        self.assertIs(self.root["."], self.root)

    def test_nested_lookup(self):
        self.assertEqual(self.root["dir/leaf"].fullname, "dir/leaf")

    def test_missing_key_raises(self):
        with self.assertRaises(IndexError):
            self.root["missing"]

    def test_descend_through_file_raises(self):
        with self.assertRaises(IndexError):
            self.root["top/x"]


class TestTallyTracker(unittest.TestCase):
    def test_first_untallied_is_topmost_ancestor(self):
        r = tree.RootInfo("root")
        d = nodes.DirInfo(name="dir", parent=r)
        f = nodes.FileInfo(name="file", parent=d)
        tracker = group.TallyTracker()
        self.assertIs(tracker.first_untallied(f), r)
        tracker.tally(d)
        self.assertIs(tracker.first_untallied(f), f)

    def test_tally_is_shared_across_lookups(self):
        d = nodes.DirInfo(None, "dir")
        foo = nodes.FileInfo(d, "foo")
        bar = nodes.FileInfo(d, "bar")
        tracker = group.TallyTracker()
        # foo's topmost untallied ancestor is the dir.
        self.assertIs(tracker.first_untallied(foo), d)
        # Tallying foo marks foo and the dir, so a sibling now resolves to itself.
        tracker.tally(foo)
        self.assertIs(tracker.first_untallied(bar), bar)


class TestFileGroup(unittest.TestCase):
    def setUp(self):
        self.group = group.FileGroup()

    def test_get_patterns_empty_group(self):
        self.assertEqual(self.group.get_patterns(group.TallyTracker()), [])

    def test_get_patterns_one_file(self):
        fileinfo = nodes.FileInfo(None, "foo")
        self.group.add_file(fileinfo)
        self.assertEqual(self.group.get_patterns(group.TallyTracker()), ["foo"])

    def test_get_patterns_one_dir_one_file(self):
        dirinfo = nodes.DirInfo(None, "dir")
        fileinfo = nodes.FileInfo(dirinfo, "foo")
        self.group.add_file(fileinfo)
        self.assertEqual(self.group.get_patterns(group.TallyTracker()), ["dir/*"])

    def test_get_patterns_separate_groups(self):
        dirinfo = nodes.DirInfo(None, "dir")
        foo = nodes.FileInfo(dirinfo, "foo")
        bar = nodes.FileInfo(dirinfo, "bar")
        self.group.add_file(foo)
        other = group.FileGroup()
        other.add_file(bar)
        tracker = group.TallyTracker()
        self.assertEqual(self.group.get_patterns(tracker), ["dir/*"])
        self.assertEqual(other.get_patterns(tracker), ["dir/bar"])

    def test_get_patterns_root(self):
        dirinfo = tree.RootInfo("root")
        fileinfo = nodes.FileInfo(dirinfo, "foo")
        self.group.add_file(fileinfo)
        self.assertEqual(self.group.get_patterns(group.TallyTracker()), ["*"])

    def test_get_patterns_separate_groups_more_files(self):
        dirinfo = nodes.DirInfo(None, "dir")
        foo = nodes.FileInfo(dirinfo, "foo")
        bar = nodes.FileInfo(dirinfo, "bar")
        baz = nodes.FileInfo(dirinfo, "baz")
        self.group.add_file(foo)
        other = group.FileGroup()
        other.add_file(bar)
        other.add_file(baz)
        tracker = group.TallyTracker()
        self.assertEqual(self.group.get_patterns(tracker), ["dir/*"])
        self.assertEqual(other.get_patterns(tracker), ["dir/bar", "dir/baz"])

    def test_common_path_empty(self):
        self.assertEqual(self.group.commonpath(), "")

    def test_common_path_one_file(self):
        fileinfo = nodes.FileInfo(None, "foo")
        self.group.add_file(fileinfo)
        self.assertEqual(self.group.commonpath(), "foo")

    def test_common_path_one_dir_two_files(self):
        dirinfo = nodes.DirInfo(None, "dir")
        foo = nodes.FileInfo(dirinfo, "foo")
        bar = nodes.FileInfo(dirinfo, "bar")
        self.group.add_file(foo)
        self.group.add_file(bar)
        self.assertEqual(self.group.commonpath(), "dir")


class TestCopyrightGroup(unittest.TestCase):
    def setUp(self):
        self.group = holders.CopyrightHolders()

    def test_add_two_people(self):
        self.group.add(
            CopyrightHolder("Jane Doe", "jane@example.com", YearRange(2015, 2015)),
        )
        self.group.add(
            CopyrightHolder("John Doe", "john@example.com", YearRange(2016, 2016)),
        )

        self.assertEqual(
            list(self.group.sorted_members()),
            ["2015, Jane Doe <jane@example.com>", "2016, John Doe <john@example.com>"],
        )

    def test_merge_names(self):
        self.group.add(CopyrightHolder("Jane", "jane@example.com", YearRange()))
        self.group.add(CopyrightHolder("Jane Doe", "jane@example.com", YearRange()))

        self.assertEqual(list(self.group.sorted_members()), ["Jane <jane@example.com>"])

    def test_merge_names_case_insensitive(self):
        # "the Wine project" (from source file) and "The Wine project" (from
        # debian/copyright) must be treated as the same holder.
        self.group.add(CopyrightHolder("the Wine project", "", YearRange()))
        self.group.add(CopyrightHolder("The Wine project", "", YearRange()))

        self.assertEqual(len(self.group), 1)

    def test_merge_emails(self):
        self.group.add(CopyrightHolder("Jane Doe", "jane@example.com", YearRange()))
        self.group.add(CopyrightHolder("Jane Doe", "jane@mailinator.com", YearRange()))

        self.assertEqual(
            list(self.group.sorted_members()),
            ["Jane Doe <jane@example.com>", "Jane Doe <jane@mailinator.com>"],
        )

    def test_merge_years(self):
        self.group.add(
            CopyrightHolder("Jane Doe", "jane@example.com", YearRange(2010, 2011)),
        )
        self.group.add(
            CopyrightHolder("Jane Doe", "jane@example.com", YearRange(2015, 2016)),
        )

        self.assertEqual(
            list(self.group.sorted_members()),
            ["2010-2016, Jane Doe <jane@example.com>"],
        )

    def test_merge_names_with_years(self):
        self.group.add(
            CopyrightHolder("Jane Doe", "jane@example.com", YearRange(1999, 1999)),
        )
        self.group.add(
            CopyrightHolder("Janine Doe", "jane@example.com", YearRange(2016, 2016)),
        )

        self.assertEqual(
            list(self.group.sorted_members()),
            ["1999-2016, Janine Doe <jane@example.com>"],
        )

    def test_key_empty_group(self):
        self.assertEqual(self.group.key(), ())

    def test_key_uses_email_when_present(self):
        self.group.add(CopyrightHolder("Alice", "alice@example.com"))
        self.group.add(CopyrightHolder("Bob", "bob@example.com"))
        self.assertEqual(self.group.key(), ("alice@example.com", "bob@example.com"))

    def test_key_falls_back_to_person_when_no_email(self):
        self.group.add(CopyrightHolder("Alice Smith", ""))
        self.assertEqual(self.group.key(), ("Alice Smith",))

    def test_key_mixed_email_and_no_email(self):
        self.group.add(CopyrightHolder("Alice", "alice@example.com"))
        self.group.add(CopyrightHolder("Bob NoEmail", ""))
        key = self.group.key()
        self.assertIn("alice@example.com", key)
        self.assertIn("Bob NoEmail", key)
        self.assertEqual(len(key), 2)

    def test_key_is_sorted(self):
        self.group.add(CopyrightHolder("Z Person", "zzz@example.com"))
        self.group.add(CopyrightHolder("A Person", "aaa@example.com"))
        self.assertEqual(self.group.key(), ("aaa@example.com", "zzz@example.com"))

    def test_merge_legal_suffix_with_email_then_bare(self):
        # Holder with email + bare name without email → should merge
        self.group.add(
            CopyrightHolder("Aptible, Inc", "legal@aptible.com", YearRange(2017, 2017))
        )
        self.group.add(CopyrightHolder("Aptible", "", YearRange(2019, 2019)))
        self.assertEqual(len(self.group), 1)
        members = list(self.group.sorted_members())
        # We keep the new name form
        self.assertIn("Aptible", members[0])
        self.assertIn("2017-2019", members[0])

    def test_merge_legal_suffix_bare_then_with_email(self):
        # Bare name without email + holder with email → should merge, adopting the email
        self.group.add(CopyrightHolder("Aptible", "", YearRange(2019, 2019)))
        self.group.add(
            CopyrightHolder("Aptible, Inc", "legal@aptible.com", YearRange(2017, 2017))
        )
        self.assertEqual(len(self.group), 1)
        members = list(self.group.sorted_members())
        # We keep the new name form (copyright 2019 > 2017)
        self.assertIn("Aptible", members[0])
        self.assertIn("legal@aptible.com", members[0])
        self.assertIn("2017-2019", members[0])

    def test_merge_legal_suffix_new_then_old(self):
        # "Aptible, Inc" added first, then bare "Aptible" → one holder with long name
        self.group.add(CopyrightHolder("Aptible, Inc", "", YearRange(2017, 2019)))
        self.group.add(CopyrightHolder("Aptible", "", YearRange(2018, 2018)))
        self.assertEqual(len(self.group), 1)
        self.assertEqual(list(self.group.sorted_members()), ["2017-2019, Aptible, Inc"])

    def test_merge_legal_suffix_old_then_new(self):
        # Bare "Aptible" added first, then "Aptible, Inc" → one holder with long name
        self.group.add(CopyrightHolder("Aptible", "", YearRange(2018, 2018)))
        self.group.add(CopyrightHolder("Aptible, Inc", "", YearRange(2017, 2019)))
        self.assertEqual(len(self.group), 1)
        self.assertEqual(list(self.group.sorted_members()), ["2017-2019, Aptible, Inc"])

    def test_merge_two_different_suffixes(self):
        # Both "Foo LLC" and "Foo Inc" normalise to "foo", so they merge.
        # In practice a single copyright file listing both is essentially
        # unheard-of, and keeping one dict keyed by the normalised name is
        # simpler than tracking suffix variants separately.
        self.group.add(CopyrightHolder("Foo LLC", "", YearRange(2020, 2020)))
        self.group.add(CopyrightHolder("Foo Inc", "", YearRange(2021, 2021)))
        self.assertEqual(len(self.group), 1)

    def test_no_merge_unrelated_names(self):
        # "Foo" and "Foobar Inc" share no suffix relationship - must not be merged
        self.group.add(CopyrightHolder("Foo", "", YearRange(2020, 2020)))
        self.group.add(CopyrightHolder("Foobar Inc", "", YearRange(2021, 2021)))
        self.assertEqual(len(self.group), 2)


class TestEffectiveLicense(unittest.TestCase):
    def test_file_parsed_license_is_raw(self):
        fi = nodes.FileInfo(None, "foo")
        fi.add_licenses({"GPL-2+"})
        self.assertEqual(fi.parsed_license, "GPL-2+")

    def test_file_effective_license_is_raw(self):
        fi = nodes.FileInfo(None, "foo")
        fi.add_licenses({"GPL-2+"})
        self.assertEqual(fi.effective_license, "GPL-2+")

    def test_dir_parsed_license_is_raw_not_inherited(self):
        dirinfo = nodes.DirInfo(None, "dir")
        dirinfo.add_licenses({"GPL-2+"})
        self.assertEqual(dirinfo.parsed_license, "GPL-2+")

    def test_dir_effective_license_is_inherited(self):
        dirinfo = nodes.DirInfo(None, "dir")
        dirinfo.add_licenses({"GPL-2+"})
        self.assertEqual(dirinfo.effective_license, "Inherited(GPL-2+)")

    def test_dir_effective_license_joins_multiple_sorted(self):
        dirinfo = nodes.DirInfo(None, "dir")
        dirinfo.add_licenses({"MIT", "GPL-2+"})
        self.assertEqual(dirinfo.effective_license, "Inherited(GPL-2+ or MIT)")

    def test_dir_effective_license_empty_without_licenses(self):
        self.assertEqual(nodes.DirInfo(None, "dir").effective_license, "")


class TestGetLicenseKeyPriority(unittest.TestCase):
    """Unit tests for license_key(FileInfo) priority order.

    Documented priority (highest first):
     1. Explicit stored-group entry where fullname == matching_pattern
     2. License detected by parsing the file (parsed_license)
     3. Wildcarded stored-group entry (fullname != matching_pattern)
     4. Parent directory's get_license_key()
     fallback: UNKNOWN_COPYRIGHTED if copyrights set; UNKNOWN otherwise

    These tests use parent=None or minimal DirInfo parents so that the level
    under test is isolated from other levels.
    """

    def _group(self, license_name):
        """Return a Group whose only license is license_name."""
        g = dep5.Group()
        g.licenses[license_name] = License(license_name)
        return g

    # fallback baseline

    def test_no_license_no_parent_returns_unknown(self):
        fi = nodes.FileInfo(parent=None, name="foo.c")
        self.assertEqual(license_key(fi), UNKNOWN)

    def test_copyrights_no_license_no_parent_returns_unknown_copyrighted(self):
        fi = nodes.FileInfo(parent=None, name="foo.c")
        fi.copyrights.add(CopyrightHolder("Alice", "alice@example.com"))
        self.assertEqual(license_key(fi), UNKNOWN_COPYRIGHTED)

    # level 2: parsed_license

    def test_parsed_license_returned(self):
        fi = nodes.FileInfo(parent=None, name="foo.c")
        fi.add_licenses({"MIT"})
        self.assertEqual(license_key(fi), "MIT")

    def test_parsed_multiple_licenses_joined_and_sorted(self):
        fi = nodes.FileInfo(parent=None, name="foo.c")
        fi.add_licenses({"MIT", "GPL-2+"})
        self.assertEqual(license_key(fi), "GPL-2+ or MIT")

    # level 3: wildcarded existing_group

    def test_wildcarded_existing_group_returned_when_no_parsed_license(self):
        fi = nodes.FileInfo(parent=None, name="src/foo.c")
        fi.existing_group = self._group("GPL-2+")
        fi.matching_pattern = "src/*"
        self.assertEqual(license_key(fi), "GPL-2+")

    def test_parsed_license_beats_wildcarded_existing_group(self):
        """Level 2 (parsed_license) takes priority over level 3 (wildcard match)."""
        fi = nodes.FileInfo(parent=None, name="src/foo.c")
        fi.existing_group = self._group("GPL-2+")
        fi.matching_pattern = "src/*"
        fi.add_licenses({"MIT"})
        self.assertEqual(license_key(fi), "MIT")

    # level 1: exact existing_group

    def test_exact_match_existing_group_returned(self):
        fi = nodes.FileInfo(parent=None, name="src/foo.c")
        fi.existing_group = self._group("GPL-2+")
        fi.matching_pattern = "src/foo.c"
        self.assertEqual(license_key(fi), "GPL-2+")

    def test_exact_match_existing_group_beats_parsed_license(self):
        """Level 1 (exact match) takes priority over level 2 (parsed_license).

        This is the critical ordering: an explicit debian/copyright entry for
        this exact filename overrides whatever license was detected in the file.
        """
        fi = nodes.FileInfo(parent=None, name="src/foo.c")
        fi.existing_group = self._group("GPL-2+")
        fi.matching_pattern = "src/foo.c"
        fi.add_licenses({"MIT"})
        self.assertEqual(license_key(fi), "GPL-2+")

    # level 4: parent

    def test_parent_license_inherited_when_file_has_none(self):
        src = nodes.DirInfo(parent=None, name="src")
        src.add_licenses({"GPL-2+"})
        fi = nodes.FileInfo(parent=src, name="foo.c")
        self.assertEqual(license_key(fi), "Inherited(GPL-2+)")

    def test_parsed_license_beats_parent_license(self):
        src = nodes.DirInfo(parent=None, name="src")
        src.add_licenses({"GPL-2+"})
        fi = nodes.FileInfo(parent=src, name="foo.c")
        fi.add_licenses({"MIT"})
        self.assertEqual(license_key(fi), "MIT")

    def test_grandparent_license_inherited_through_unlicensed_dir(self):
        root = nodes.DirInfo(parent=None, name="")
        root.add_licenses({"Apache-2"})
        lib = nodes.DirInfo(parent=root, name="lib")  # no license on lib itself
        fi = nodes.FileInfo(parent=lib, name="util.c")
        self.assertEqual(license_key(fi), "Inherited(Apache-2)")


class TestIgnoreLists(unittest.TestCase):
    """The build walk prunes .gitignore / .git/info/exclude / Files-Excluded
    paths before they enter the tree."""

    def _build(self, files, *, files_excluded=(), use_gitignore=True):
        tmp = tempfile.mkdtemp()
        for rel, content in files.items():
            path = Path(tmp) / rel
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(content)
        args = ["--no-progress", "--root", tmp]
        if not use_gitignore:
            args.append("--no-gitignore")
        options = cmdoptions.process_options(args)
        options = dataclasses.replace(options, files_excluded=tuple(files_excluded))
        filetree = tree.RootInfo.build(options)
        return {f.fullname for f in filetree.walk_files()}

    def test_gitignore_prunes_files_and_dirs(self):
        names = self._build(
            {
                "keep.c": "x",
                "skip.log": "x",
                "build/out.o": "x",
                "src/main.c": "x",
                ".gitignore": "*.log\nbuild/\n",
            }
        )
        self.assertIn("keep.c", names)
        self.assertIn("src/main.c", names)
        self.assertNotIn("skip.log", names)
        self.assertNotIn("build/out.o", names)

    def test_no_gitignore_keeps_gitignored_but_drops_files_excluded(self):
        names = self._build(
            {"skip.log": "x", "drop.txt": "x", "keep.c": "x", ".gitignore": "*.log\n"},
            files_excluded=("drop.txt",),
            use_gitignore=False,
        )
        self.assertIn("skip.log", names)
        self.assertIn("keep.c", names)
        self.assertNotIn("drop.txt", names)

    def test_nested_gitignore_scopes_to_subtree(self):
        names = self._build(
            {
                "tmp/a.c": "x",
                "src/tmp/b.c": "x",
                "src/keep.c": "x",
                "src/.gitignore": "tmp/\n",
            }
        )
        self.assertIn("tmp/a.c", names)
        self.assertIn("src/keep.c", names)
        self.assertNotIn("src/tmp/b.c", names)

    def test_files_excluded_directory_excludes_subtree(self):
        names = self._build(
            {"vendor/lib/x.c": "x", "keep.c": "x"},
            files_excluded=("vendor",),
        )
        self.assertIn("keep.c", names)
        self.assertNotIn("vendor/lib/x.c", names)

    def test_git_info_exclude_honoured(self):
        names = self._build(
            {
                "notes.bak": "x",
                "keep.c": "x",
                ".git/info/exclude": "*.bak\n",
            }
        )
        self.assertIn("keep.c", names)
        self.assertNotIn("notes.bak", names)


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