#!/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.

"""Delta data types for the explain mode.

A DeltaReport collects the semantic staleness decopy detects while it
regenerates debian/copyright (stale paragraphs, stale patterns, relicensed
files and new files), so the explain renderer can report them instead of
discarding them. NullDelta is the no-op sink used on normal generation runs,
keeping the recording sites branch-free.
"""

from __future__ import annotations

import dataclasses
import typing as t

if t.TYPE_CHECKING:
    from decopy.grouping.grouping import GroupKey

# Kind constants
STALE_PARAGRAPH = "stale-paragraph"
STALE_PATTERN = "stale-pattern"
RELICENSED = "relicensed"
NEW_FILE = "new-file"
NEW_PARAGRAPH = "new-paragraph"
COPYRIGHT_DRIFT = "copyright-drift"

# Relicensed sub-kinds
SUB_LICENSE = "license-changed"
SUB_COPYRIGHT = "copyright-changed"
SUB_BOTH = "license-and-copyright-changed"

# Copyright-drift sub-kinds. A removed-only drift is advisory (regeneration
# unions holders and never drops one), so it reports but does not fail the gate.
DRIFT_ADDED = "holders-added"
DRIFT_REMOVED = "holders-removed"
DRIFT_BOTH = "holders-added-and-removed"


@dataclasses.dataclass(frozen=True)
class DeltaEntry:
    """A single recorded staleness item.

    Only the slots relevant to the kind are populated; the rest stay None.
    """

    kind: str
    detail: str
    paragraph: str | None = None
    pattern: str | None = None
    path: str | None = None
    was: str | None = None
    now: str | None = None
    sub_kind: str | None = None
    was_copyright: tuple[str, ...] | None = None
    now_copyright: tuple[str, ...] | None = None
    paragraph_license: str | None = None
    # Provenance for explain mode. files lists a NEW_PARAGRAPH bucket;
    # license_source phrases where the license was found; holder_sources maps
    # each holder key to the files it was detected in. Tuples keep the frozen
    # dataclass hashable.
    files: tuple[str, ...] | None = None
    license_source: str | None = None
    holder_sources: tuple[tuple[str, tuple[str, ...]], ...] | None = None
    # Changed mode: the deleted paths that would have matched a now-stale
    # paragraph's patterns (the deletions that emptied it); None when nothing is
    # attributable or outside changed mode.
    deleted_files: tuple[str, ...] | None = None


def freeze_holder_sources(
    sources: dict[str, tuple[str, ...]],
) -> tuple[tuple[str, tuple[str, ...]], ...]:
    """A holder -> source-files map in the frozen tuple-of-pairs form a
    DeltaEntry carries (sorted by holder for stable output and equality)."""
    return tuple(sorted(sources.items()))


def is_advisory(entry: DeltaEntry) -> bool:
    """Whether an entry reports without counting as staleness.

    Only a removed-only copyright drift is advisory: it lists a holder no file
    backs, which regeneration would not drop, so it is information, not a gate
    failure.
    """
    return entry.kind == COPYRIGHT_DRIFT and entry.sub_kind == DRIFT_REMOVED


class DeltaReport:
    """Ordered collection of DeltaEntry items grouped on demand by kind."""

    def __init__(self) -> None:
        self._entries: list[DeltaEntry] = []

    def add(self, entry: DeltaEntry) -> None:
        self._entries.append(entry)

    def is_empty(self) -> bool:
        return not self._entries

    def has_staleness(self) -> bool:
        """Whether any recorded entry is a staleness signal (drives the exit code).

        Advisory entries (a removed-only copyright drift) are reported but do not
        count, so a tree clean except for advice still exits 0.
        """
        return any(not is_advisory(entry) for entry in self._entries)

    def by_kind(self) -> dict[str, list[DeltaEntry]]:
        grouped: dict[str, list[DeltaEntry]] = {}
        for entry in self._entries:
            grouped.setdefault(entry.kind, []).append(entry)
        return grouped


class NullDelta(DeltaReport):
    """No-op sink. add() does nothing; is_empty() is always True."""

    def add(self, entry: DeltaEntry) -> None:
        pass


class PartialDelta(DeltaReport):
    """Explain sink for partial mode: drops stale-* and copyright-drift.

    Only new-file, new-paragraph and relicensed entries are sound for a
    requested subset (all concern included files). The stale-* kinds need the
    whole tree, and so does copyright drift (a removed holder may live on a file
    outside the subset), so all three are silently ignored, keeping the
    recording sites branch-free.
    """

    def add(self, entry: DeltaEntry) -> None:
        if entry.kind in (STALE_PARAGRAPH, STALE_PATTERN, COPYRIGHT_DRIFT):
            return
        super().add(entry)


class ChangedDelta(DeltaReport):
    """Explain sink for changed mode: keeps stale-* but drops copyright-drift.

    Unlike a plain subset (PartialDelta), changed mode keeps stale-paragraph and
    stale-pattern: they are whole-tree-sound because Copyright.process matches
    each paragraph's patterns against the complete filetree before any scoping,
    so a paragraph matching nothing is real regardless of what was parsed. Drift
    stays dropped for the same reason PartialDelta drops it: the kept-file holder
    union only reflects the included files under scoped parsing, so a drift there
    would be a false positive.
    """

    def add(self, entry: DeltaEntry) -> None:
        if entry.kind == COPYRIGHT_DRIFT:
            return
        super().add(entry)


def relicense_sub_kind(file_key: GroupKey, group_key: GroupKey) -> str | None:
    """Classify a relicense by comparing the strategy-masked group keys.

    Only the license (slot 0) and copyright (slot 1) slots are compared; the
    path (slot 2) is not a relicense signal. Returns None when neither differs.
    """
    license_changed = file_key.license != group_key.license
    copyright_changed = file_key.copyright != group_key.copyright
    if license_changed and copyright_changed:
        return SUB_BOTH
    if license_changed:
        return SUB_LICENSE
    if copyright_changed:
        return SUB_COPYRIGHT
    return None
