#!/usr/bin/env python3
# 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.

"""Grouping policy as an injected strategy.

How files become copyright paragraphs is an axis decopy is expected to vary
(by license today, by copyright today, perhaps by directory or vendor later).
This module gathers that policy behind a single GroupingStrategy so the
consumers (grouping, file_policy, group, copyright_processor) depend on the
abstraction rather than on scattered group_by/split_* conditionals.

The two concrete strategies differ only in how copyright participates:

  * LicenseGrouping drops the copyright slot from the group key and keeps a
    misplaced file only when its full key matches the group key.
  * CopyrightGrouping keeps the copyright slot and additionally rescues a file
    whose key matches the group key with copyright ignored.

The split_on_license/split_debian rules (splitting_path) and the sort band are
shared, so they live on the base class; a future mode that needs a different
boundary or ordering overrides them.

The strategy is selected once per consumer via grouping_strategy_for(options):
grouping.make_group_key (copyright slot), file_policy.splitting_path (split
rules), group.Group.sort_key (sort band) and copyright_processor's
remove_misplaced_files (keep/drop fallback) all depend on it.
"""

from __future__ import annotations

import abc
import typing as t

from decopy.common.core_types import UNKNOWN, UNKNOWN_COPYRIGHTED

if t.TYPE_CHECKING:
    from decopy.common.core_types import Options
    from decopy.fstree.nodes import FileInfo
    from decopy.grouping.grouping import GroupKey


class GroupingStrategy(abc.ABC):
    """Owns the policy that turns files into copyright-paragraph groups."""

    @abc.abstractmethod
    def key_copyright_slot(
        self, copyright_key: tuple[str, ...]
    ) -> tuple[str, ...] | None:
        """Return the copyright component of a group key, or None to omit it."""

    @abc.abstractmethod
    def keeps_misplaced(
        self,
        file_key: GroupKey,
        group_key: GroupKey,
        group_key_no_copyright: GroupKey,
    ) -> bool:
        """Whether a file already placed in a group still belongs there."""

    def splitting_path(self, node: FileInfo, options: Options) -> str | None:
        """Path identifier used to split files into groups.

        A node that is a split boundary (see FileInfo.is_split_boundary)
        contributes its own path; otherwise the node delegates to its parent. A
        parentless non-boundary node (a file at the root) has no splitting path.
        """
        if node.is_split_boundary(options):
            return node.fullname
        if node.parent:
            return self.splitting_path(node.parent, options)
        return None

    def reports_copyright_drift(self) -> bool:
        """Whether explain mode should report copyright-holder drift for this mode.

        Default False: a strategy that keeps the copyright slot in its key already
        surfaces holder changes as relicensed entries, so reporting drift too would
        double-count. LicenseGrouping (which masks the slot) overrides this.
        """
        return False

    def sort_band(self, license_name: str, path_key: str, *, split_debian: bool) -> int:
        """Primary sort band for a group: 0 normal, 1 unknown, 2 debian.

        Takes just the `split_debian` flag it needs rather than the whole
        Options. Unknown licenses override the debian band (they share band 1),
        matching the historical precedence in Group.sort_key.
        """
        band = 0
        if split_debian and path_key == "debian":
            band = 2
        if license_name in {UNKNOWN, UNKNOWN_COPYRIGHTED}:
            band = 1
        return band


class LicenseGrouping(GroupingStrategy):
    """Group by license: copyright never participates in the key."""

    def reports_copyright_drift(self) -> bool:
        # The copyright slot is masked out of the key, so a holder change is
        # invisible to the relicensed path; drift is the only signal for it.
        return True

    def key_copyright_slot(
        self, copyright_key: tuple[str, ...]
    ) -> tuple[str, ...] | None:
        _ = copyright_key
        return None

    def keeps_misplaced(
        self,
        file_key: GroupKey,
        group_key: GroupKey,
        group_key_no_copyright: GroupKey,
    ) -> bool:
        _ = group_key_no_copyright
        return file_key == group_key


class CopyrightGrouping(GroupingStrategy):
    """Group by copyright: the copyright slot participates as a subset relation.

    A paragraph that groups several files declares the union of their holders in
    its Copyright field, so each file legitimately carries a subset. A file is
    kept when its license and path match the group and its holders are a subset
    of the group's declared holders; the empty-holder case (an uncopyrighted
    file) is the subset relation's trivial member, so no separate rescue is
    needed.
    """

    def key_copyright_slot(
        self, copyright_key: tuple[str, ...]
    ) -> tuple[str, ...] | None:
        return copyright_key

    def keeps_misplaced(
        self,
        file_key: GroupKey,
        group_key: GroupKey,
        group_key_no_copyright: GroupKey,
    ) -> bool:
        _ = group_key_no_copyright
        if file_key.license != group_key.license or file_key.path != group_key.path:
            return False
        return set(file_key.copyright or ()) <= set(group_key.copyright or ())


_LICENSE_GROUPING = LicenseGrouping()
_COPYRIGHT_GROUPING = CopyrightGrouping()


def grouping_strategy_for(group_by: str) -> GroupingStrategy:
    """Select the grouping strategy named by `group_by` ("license"/"copyright")."""
    if group_by == "copyright":
        return _COPYRIGHT_GROUPING
    return _LICENSE_GROUPING
