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

"""Explain-mode staleness detection.

These functions produce the DeltaEntry records the explain renderer reports:
relicensed files, copyright drift, stale paragraphs and stale patterns. They are
the *producers* that pair with delta.py's data types, kept out of
copyright_processor so that class stays focused on matching files to paragraphs.

Each takes the caller's DeltaReport sink and appends to it; on a generation run
the sink is the no-op NullDelta, so the calls are unconditional. This module must
not import copyright_processor (the dependency runs the other way).
"""

from __future__ import annotations

import fnmatch
import typing as t

import debian.copyright as dc

from decopy.dep5doc.delta import (
    COPYRIGHT_DRIFT,
    DRIFT_ADDED,
    DRIFT_BOTH,
    DRIFT_REMOVED,
    RELICENSED,
    STALE_PARAGRAPH,
    STALE_PATTERN,
    DeltaEntry,
    DeltaReport,
    freeze_holder_sources,
    relicense_sub_kind,
)
from decopy.fstree.nodes import FileInfo
from decopy.grouping import file_policy
from decopy.grouping.group_contracts import GroupProtocol
from decopy.grouping.grouping import GroupKey
from decopy.grouping.grouping_strategy import GroupingStrategy
from decopy.licensing.holders import CopyrightHolders


def record_relicensed(
    delta: DeltaReport,
    fileinfo: FileInfo,
    keys: tuple[GroupKey, GroupKey],
) -> None:
    """Record a relicensed file when its key no longer matches its group key.

    No entry is recorded for a path-only difference (relicense_sub_kind None);
    the NullDelta sink absorbs the call on generation runs. Packaging files
    under debian/ are skipped: debian/copyright is their source of truth, so a
    detected license mismatch there is not a staleness signal.
    """
    if fileinfo.is_packaging():
        return
    file_key, group_key = keys
    sub_kind = relicense_sub_kind(file_key, group_key)
    if sub_kind is None:
        return
    delta.add(
        DeltaEntry(
            kind=RELICENSED,
            detail=fileinfo.fullname,
            path=fileinfo.fullname,
            was=group_key.license,
            now=file_key.license,
            sub_kind=sub_kind,
            was_copyright=group_key.copyright,
            now_copyright=file_key.copyright,
            holder_sources=freeze_holder_sources(
                file_policy.holder_sources([fileinfo])
            ),
        )
    )


def _drift_sub_kind(added: set[str], removed: set[str]) -> str:
    """Classify a copyright drift by which directions changed."""
    if added and removed:
        return DRIFT_BOTH
    if added:
        return DRIFT_ADDED
    return DRIFT_REMOVED


def _upstream_files(group: GroupProtocol) -> list[FileInfo]:
    """The group's non-packaging kept files.

    Packaging files (under debian/) are excluded: debian/copyright is their
    source of truth, so their detected holders must not drive drift or its
    holder-source provenance.
    """
    return [fileinfo for fileinfo in group.files if not fileinfo.is_packaging()]


def _upstream_holder_key(files: list[FileInfo]) -> tuple[str, ...] | None:
    """Holder key over the given files, or None when there are none (a
    pure-packaging group, for which drift is not reported at all)."""
    if not files:
        return None
    file_union = CopyrightHolders()
    for fileinfo in files:
        file_union.merge(fileinfo.copyrights)
    return file_union.key()


def record_copyright_drift(
    delta: DeltaReport,
    group: GroupProtocol,
    strategy: GroupingStrategy,
) -> None:
    """Record a kept group whose Copyright field no longer matches its files.

    Compares the paragraph's declared holders (snapshotted before any merge)
    against the union of its kept non-packaging files' holders. Only strategies
    that mask the copyright slot report drift (otherwise the relicensed path
    already covers it). An added holder is staleness; a removed-only holder is
    advisory.
    """
    if not strategy.reports_copyright_drift():
        return
    files = _upstream_files(group)
    actual_key = _upstream_holder_key(files)
    if actual_key is None:
        return
    declared = set(group.declared_copyright_key)
    actual = set(actual_key)
    added = actual - declared
    removed = declared - actual
    if not added and not removed:
        return
    dump = (group.stored.dump() or "") if group.stored else ""
    delta.add(
        DeltaEntry(
            kind=COPYRIGHT_DRIFT,
            detail=dump,
            paragraph=dump,
            was_copyright=group.declared_copyright_key,
            now_copyright=actual_key,
            sub_kind=_drift_sub_kind(added, removed),
            holder_sources=freeze_holder_sources(file_policy.holder_sources(files)),
        )
    )


def _deletion_culprits(
    paragraph: dc.FilesParagraph, deleted: tuple[str, ...]
) -> tuple[str, ...]:
    """The deleted paths that would have matched this paragraph's patterns.

    Pure fnmatch against the declared patterns, so it needs neither the tree nor
    git; empty when no deleted path matches (nothing to attribute).
    """
    return tuple(
        sorted(
            path
            for path in deleted
            if any(fnmatch.fnmatch(path, pattern) for pattern in paragraph.files)
        )
    )


def record_stale_paragraph(
    delta: DeltaReport, paragraph: dc.FilesParagraph, deleted: tuple[str, ...] = ()
) -> None:
    """Record a Files paragraph whose declared patterns matched no files.

    In changed mode the deleted paths that would have matched are attached as
    provenance; None when no deleted path matches or when the deleted set is
    empty (full/partial runs pass none).
    """
    dump = paragraph.dump() or ""
    license_name = t.cast(dc.License, paragraph.license).synopsis
    culprits = _deletion_culprits(paragraph, deleted)
    delta.add(
        DeltaEntry(
            kind=STALE_PARAGRAPH,
            detail=dump,
            paragraph=dump,
            paragraph_license=license_name,
            deleted_files=culprits or None,
        )
    )


def record_stale_patterns(
    delta: DeltaReport,
    paragraph: dc.FilesParagraph,
    matched_patterns: list[str],
) -> None:
    """Record each declared pattern that matched nothing, deduped per pattern.

    `set(declared) - set(matched)` yields each dead pattern once, so the
    paragraph-level and pattern-level views never double-count the same pattern.
    """
    dump = paragraph.dump() or ""
    license_name = t.cast(dc.License, paragraph.license).synopsis
    dead = set(paragraph.files) - set(matched_patterns)
    for pattern in paragraph.files:
        if pattern in dead:
            delta.add(
                DeltaEntry(
                    kind=STALE_PATTERN,
                    detail=dump,
                    pattern=pattern,
                    paragraph_license=license_name,
                )
            )
