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

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


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.Copyright.build(filetree, 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 setUp(self):
        self._saved_licenses = License.licenses
        License.licenses = {}

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

    def _group(self, license_name):
        """Return a Group whose only license is license_name."""
        g = dep5.Group()
        g.licenses[license_name] = License.get(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)")


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