#!/usr/bin/env python3

# Characterization tests for the grouping/splitting policy.
# Copyright: 2026, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Pin the current grouping policy before the GroupingStrategy refactor.

These tests lock the observable behaviour of the four scattered
group_by/split_* conditionals that we will move behind a strategy:

  * grouping.make_group_key          - the copyright slot decision
  * file_policy.splitting_path       - the split_on_license/split_debian rules
  * copyright_processor.remove_misplaced_files - the group_by copyright fallback

The fourth site, Group.sort_key's split_debian band, is already pinned by
TestGroupSortKey in dep5_test.py, so it is not duplicated here.
"""

import unittest

from decopy import cmdoptions
from decopy.common.core_types import CopyrightHolder
from decopy.dep5doc import dep5
from decopy.fstree import nodes
from decopy.grouping import file_policy
from decopy.grouping.grouping import make_group_key


def _options(*, group_by="license", split_on_license=True, split_debian=True):
    flags = ["--no-progress", "--root", "tests/testdata/one", "--group-by", group_by]
    if not split_on_license:
        flags.append("--no-split-on-license")
    if not split_debian:
        flags.append("--no-split-debian")
    return cmdoptions.process_options(flags)


class MakeGroupKeyTest(unittest.TestCase):
    """grouping.make_group_key fills the copyright slot only under copyright."""

    def test_copyright_slot_is_none_when_grouping_by_license(self):
        key = make_group_key("MIT", ("alice@x",), "path", _options(group_by="license"))
        self.assertEqual(key, ("MIT", None, "path"))

    def test_copyright_slot_is_kept_when_grouping_by_copyright(self):
        key = make_group_key(
            "MIT", ("alice@x",), "path", _options(group_by="copyright")
        )
        self.assertEqual(key, ("MIT", ("alice@x",), "path"))

    def test_license_and_path_pass_through_unchanged(self):
        key = make_group_key("GPL-2", (), None, _options(group_by="copyright"))
        self.assertEqual(key, ("GPL-2", (), None))

    def test_key_exposes_named_slots_and_still_equals_the_tuple_form(self):
        # The key is a NamedTuple: named-slot access reads clearer than key[0..2]
        # while staying tuple-compatible (equality, unpacking, hashing) so it
        # remains a drop-in dict key.
        key = make_group_key(
            "MIT", ("alice@x",), "path", _options(group_by="copyright")
        )
        self.assertEqual(key.license, "MIT")
        self.assertEqual(key.copyright, ("alice@x",))
        self.assertEqual(key.path, "path")
        # Tuple-compatible: equals the plain tuple and unpacks positionally.
        self.assertEqual(key, ("MIT", ("alice@x",), "path"))
        license_, copyright_, path = key
        self.assertEqual((license_, copyright_, path), ("MIT", ("alice@x",), "path"))


class SplittingPathTest(unittest.TestCase):
    """file_policy.splitting_path resolves the per-node split boundary."""

    def setUp(self):
        # root/
        #   src/lic/   (lic holds a license)
        #   debian/
        self.root = nodes.DirInfo(parent=None, name="")
        self.src = nodes.DirInfo(parent=self.root, name="src")
        self.root.file_list["src"] = self.src
        self.debian = nodes.DirInfo(parent=self.root, name="debian")
        self.root.file_list["debian"] = self.debian
        self.lic = nodes.DirInfo(parent=self.src, name="lic")
        self.lic.add_licenses(["MIT"])
        self.src.file_list["lic"] = self.lic

    def test_root_returns_its_own_name_regardless_of_flags(self):
        self.assertEqual(file_policy.splitting_path(self.root, _options()), "")

    def test_license_dir_returns_itself_with_split_on_license(self):
        opts = _options(split_on_license=True)
        self.assertEqual(file_policy.splitting_path(self.lic, opts), "src/lic")

    def test_license_dir_recurses_to_root_without_split_on_license(self):
        opts = _options(split_on_license=False)
        self.assertEqual(file_policy.splitting_path(self.lic, opts), "")

    def test_debian_dir_is_special_with_split_debian(self):
        opts = _options(split_debian=True)
        self.assertEqual(file_policy.splitting_path(self.debian, opts), "debian")

    def test_debian_dir_recurses_to_root_without_split_debian(self):
        opts = _options(split_debian=False)
        self.assertEqual(file_policy.splitting_path(self.debian, opts), "")

    def test_file_delegates_to_its_parent_directory(self):
        opts = _options(split_on_license=True)
        f = nodes.FileInfo(parent=self.lic, name="a.txt")
        self.assertEqual(file_policy.splitting_path(f, opts), "src/lic")

    def test_parentless_file_has_no_splitting_path(self):
        orphan = nodes.FileInfo(parent=None, name="o.txt")
        self.assertIsNone(file_policy.splitting_path(orphan, _options()))

    # node subclasses follow their base kind (ArchiveDirInfo -> DirInfo,
    # VirtualFileInfo -> FileInfo); this is the distinction Item 5 makes
    # polymorphic, so pin it before the refactor.

    def test_archive_dir_is_split_boundary_like_a_directory(self):
        arch = nodes.ArchiveDirInfo(
            parent=self.src, name="pkg.tar", archive_path="/x/pkg.tar"
        )
        arch.add_licenses(["MIT"])
        self.assertEqual(
            file_policy.splitting_path(arch, _options(split_on_license=True)),
            "src/pkg.tar",
        )

    def test_archive_dir_recurses_when_not_a_boundary(self):
        arch = nodes.ArchiveDirInfo(
            parent=self.src, name="pkg.tar", archive_path="/x/pkg.tar"
        )
        arch.add_licenses(["MIT"])
        self.assertEqual(
            file_policy.splitting_path(arch, _options(split_on_license=False)),
            "",
        )

    def test_virtual_file_delegates_to_parent_like_a_file(self):
        vf = nodes.VirtualFileInfo(parent=self.lic, name="a.py", content=b"x")
        self.assertEqual(
            file_policy.splitting_path(vf, _options(split_on_license=True)),
            "src/lic",
        )


class RemoveMisplacedFilesTest(unittest.TestCase):
    """remove_misplaced_files: the group_by copyright fallback vs license."""

    def _file(self, name, holder=None):
        f = nodes.FileInfo(parent=self.root, name=name)
        f.add_licenses(["MIT"])
        if holder is not None:
            f.add_copyrights([holder])
        return f

    def _copyright_with_group(self, files):
        copyright_ = dep5.Copyright()
        copyright_.groups = []
        group = dep5.Group(position=0)
        for f in files:
            group.add_file(f)
        copyright_.groups.append(group)
        return copyright_, group

    def setUp(self):
        self.root = nodes.DirInfo(parent=None, name="")
        self.alice = CopyrightHolder("Alice", "alice@x")
        self.bob = CopyrightHolder("Bob", "bob@x")

    def test_uncopyrighted_file_is_kept_in_copyrighted_group(self):
        # Same-holder files plus an uncopyrighted one: under copyright grouping
        # the uncopyrighted file is rescued by the no-copyright fallback.
        files = [
            self._file("a.txt", self.alice),
            self._file("b.txt", self.alice),
            self._file("c.txt", None),
        ]
        copyright_, group = self._copyright_with_group(files)
        copyright_.remove_misplaced_files(_options(group_by="copyright"))
        self.assertEqual(sorted(group.files.files), ["a.txt", "b.txt", "c.txt"])

    def test_same_files_all_kept_under_license_grouping(self):
        files = [
            self._file("a.txt", self.alice),
            self._file("b.txt", self.alice),
            self._file("c.txt", None),
        ]
        copyright_, group = self._copyright_with_group(files)
        copyright_.remove_misplaced_files(_options(group_by="license"))
        self.assertEqual(sorted(group.files.files), ["a.txt", "b.txt", "c.txt"])

    def test_union_copyright_files_kept_under_copyright_grouping(self):
        # Distinct holders sharing a group: the group's Copyright is the union of
        # its files' holders, so each file carries a subset and both are kept.
        # (Splitting distinct holders is a fresh-grouping concern, not a reason
        # to tear apart an existing union paragraph.)
        files = [self._file("a.txt", self.alice), self._file("b.txt", self.bob)]
        copyright_, group = self._copyright_with_group(files)
        copyright_.remove_misplaced_files(_options(group_by="copyright"))
        self.assertEqual(sorted(group.files.files), ["a.txt", "b.txt"])

    def test_file_with_undeclared_holder_dropped_under_copyright_grouping(self):
        # A file whose holder the group does not declare is not a subset, so it
        # is removed. The group key is pinned to Alice before the Bob file is
        # added, mirroring the pipeline (the key reflects the declared holders,
        # not the kept-file merge).
        copyright_, group = self._copyright_with_group(
            [self._file("a.txt", self.alice)]
        )
        group.get_key(_options(group_by="copyright"))
        group.add_file(self._file("b.txt", self.bob))
        copyright_.remove_misplaced_files(_options(group_by="copyright"))
        self.assertEqual(sorted(group.files.files), ["a.txt"])

    def test_mismatched_copyright_files_kept_under_license_grouping(self):
        # Under license grouping copyright is irrelevant, so both survive.
        files = [self._file("a.txt", self.alice), self._file("b.txt", self.bob)]
        copyright_, group = self._copyright_with_group(files)
        copyright_.remove_misplaced_files(_options(group_by="license"))
        self.assertEqual(sorted(group.files.files), ["a.txt", "b.txt"])


class LicenseSourceTest(unittest.TestCase):
    """file_policy.license_source: provenance phrase mirroring license_key."""

    def setUp(self):
        self.root = nodes.DirInfo(parent=None, name="")
        self.src = nodes.DirInfo(parent=self.root, name="src")
        self.root.file_list["src"] = self.src

    def _file(self, name, parent=None):
        return nodes.FileInfo(parent=parent or self.root, name=name)

    def test_own_detected_license_names_the_file(self):
        f = self._file("a.txt")
        f.add_licenses(["MIT"])
        self.assertEqual(file_policy.license_source(f), "detected in a.txt")

    def test_own_license_from_separate_file_names_that_file(self):
        f = self._file("a.txt")
        f.add_licenses(["MIT"], ["LICENSE"])
        self.assertEqual(file_policy.license_source(f), "from LICENSE")

    def test_inherited_from_parent_directory_names_the_dir(self):
        self.src.add_licenses(["MIT"])
        f = self._file("a.txt", parent=self.src)
        self.assertEqual(file_policy.license_source(f), "inherited from src/")

    def test_inherited_from_dir_license_file_names_that_file(self):
        # A dir whose license came from a LICENSE child names the file, not the
        # dir: the file path is the more precise evidence.
        self.src.add_licenses(["MIT"], ["src/LICENSE"])
        f = self._file("a.txt", parent=self.src)
        self.assertEqual(file_policy.license_source(f), "from src/LICENSE")

    def test_multiple_license_files_are_sorted(self):
        f = self._file("a.txt")
        f.add_licenses(["MIT"], ["COPYING", "LICENSE"])
        self.assertEqual(file_policy.license_source(f), "from COPYING, LICENSE")

    def test_no_license_and_no_copyright(self):
        f = self._file("a.txt")
        self.assertEqual(file_policy.license_source(f), "no license detected")

    def test_copyright_present_but_no_license(self):
        f = self._file("a.txt")
        f.add_copyrights([CopyrightHolder("Alice", "alice@x")])
        self.assertEqual(file_policy.license_source(f), "copyright present, no license")


class HolderSourcesTest(unittest.TestCase):
    """file_policy.holder_sources: holder key -> sorted files it was found in."""

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

    def _file(self, name, holders=()):
        f = nodes.FileInfo(parent=self.root, name=name)
        f.add_copyrights(list(holders))
        return f

    def test_holder_in_two_files_maps_to_both(self):
        a = self._file("a.txt", [CopyrightHolder("Alice", "alice@x")])
        b = self._file("b.txt", [CopyrightHolder("Alice", "alice@x")])
        self.assertEqual(
            file_policy.holder_sources([a, b]), {"alice@x": ("a.txt", "b.txt")}
        )

    def test_holder_in_one_file_maps_to_one(self):
        a = self._file("a.txt", [CopyrightHolder("Alice", "alice@x")])
        self.assertEqual(file_policy.holder_sources([a]), {"alice@x": ("a.txt",)})

    def test_distinct_holders_map_separately(self):
        a = self._file("a.txt", [CopyrightHolder("Alice", "alice@x")])
        b = self._file("b.txt", [CopyrightHolder("Bob", "bob@x")])
        self.assertEqual(
            file_policy.holder_sources([a, b]),
            {"alice@x": ("a.txt",), "bob@x": ("b.txt",)},
        )

    def test_key_is_person_when_no_email(self):
        # Matches CopyrightHolders.key() (email or person), so the map lines up
        # with the holder strings the explain +/- lines render.
        a = self._file("a.txt", [CopyrightHolder("Acme Inc", "")])
        self.assertEqual(file_policy.holder_sources([a]), {"Acme Inc": ("a.txt",)})

    def test_files_without_copyright_contribute_nothing(self):
        a = self._file("a.txt")
        b = self._file("b.txt", [CopyrightHolder("Alice", "alice@x")])
        self.assertEqual(file_policy.holder_sources([a, b]), {"alice@x": ("b.txt",)})

    def test_empty_input_is_empty_map(self):
        self.assertEqual(file_policy.holder_sources([]), {})

    def test_source_files_are_sorted_regardless_of_input_order(self):
        b = self._file("b.txt", [CopyrightHolder("Alice", "alice@x")])
        a = self._file("a.txt", [CopyrightHolder("Alice", "alice@x")])
        self.assertEqual(
            file_policy.holder_sources([b, a]), {"alice@x": ("a.txt", "b.txt")}
        )


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