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

"""Contract of the explain-mode delta data types.

Covers is_empty (fresh vs populated), the NullDelta no-op sink and by_kind
grouping with insertion order preserved within each kind's list.
"""

import dataclasses

from decopy.dep5doc.delta import (
    COPYRIGHT_DRIFT,
    DRIFT_ADDED,
    DRIFT_BOTH,
    DRIFT_REMOVED,
    NEW_FILE,
    NEW_PARAGRAPH,
    RELICENSED,
    STALE_PARAGRAPH,
    STALE_PATTERN,
    SUB_BOTH,
    SUB_COPYRIGHT,
    SUB_LICENSE,
    ChangedDelta,
    DeltaEntry,
    DeltaReport,
    NullDelta,
    PartialDelta,
    is_advisory,
    relicense_sub_kind,
)
from decopy.grouping.grouping import GroupKey


def test_kind_constants_have_spec_values():
    assert STALE_PARAGRAPH == "stale-paragraph"
    assert STALE_PATTERN == "stale-pattern"
    assert RELICENSED == "relicensed"
    assert NEW_FILE == "new-file"
    assert NEW_PARAGRAPH == "new-paragraph"
    assert COPYRIGHT_DRIFT == "copyright-drift"


def test_sub_kind_constants_have_spec_values():
    assert SUB_LICENSE == "license-changed"
    assert SUB_COPYRIGHT == "copyright-changed"
    assert SUB_BOTH == "license-and-copyright-changed"


def test_drift_sub_kind_constants_are_distinct():
    subs = [DRIFT_ADDED, DRIFT_REMOVED, DRIFT_BOTH]
    assert len(set(subs)) == len(subs)


def test_delta_entry_is_frozen():
    entry = DeltaEntry(kind=NEW_FILE, detail="src/a.c")
    assert entry.kind == NEW_FILE
    assert entry.detail == "src/a.c"
    assert entry.paragraph is None
    assert entry.pattern is None
    assert entry.path is None
    assert entry.was is None
    assert entry.now is None
    assert entry.sub_kind is None
    # setattr via a non-literal name avoids both type checkers rejecting a
    # frozen-field write, while still hitting the guard at runtime.
    field_name = "kind"
    try:
        setattr(entry, field_name, STALE_PATTERN)
    except dataclasses.FrozenInstanceError:
        pass
    else:
        msg = "expected FrozenInstanceError"
        raise AssertionError(msg)


def test_provenance_fields_default_to_none():
    entry = DeltaEntry(kind=NEW_FILE, detail="src/a.c")
    assert entry.license_source is None
    assert entry.holder_sources is None
    assert entry.files is None


def test_new_paragraph_carries_provenance_tuples():
    entry = DeltaEntry(
        kind=NEW_PARAGRAPH,
        detail="GPL-2+",
        now="GPL-2+",
        files=("src/a.c", "src/b.c"),
        license_source="detected in src/a.c",
        holder_sources=(("alice@x", ("src/a.c", "src/b.c")),),
    )
    assert entry.files == ("src/a.c", "src/b.c")
    assert entry.license_source == "detected in src/a.c"
    assert entry.holder_sources == (("alice@x", ("src/a.c", "src/b.c")),)


def test_new_paragraph_is_staleness_not_advisory():
    # NEW_PARAGRAPH behaves like new-file -- it fails the gate.
    entry = DeltaEntry(kind=NEW_PARAGRAPH, detail="GPL-2+", now="GPL-2+")
    assert is_advisory(entry) is False
    report = DeltaReport()
    report.add(entry)
    assert report.has_staleness() is True


def test_partial_delta_keeps_new_paragraph():
    # It concerns uncovered included files, so the subset keeps it.
    sink = PartialDelta()
    sink.add(DeltaEntry(kind=NEW_PARAGRAPH, detail="GPL-2+", now="GPL-2+"))
    assert set(sink.by_kind()) == {NEW_PARAGRAPH}


def test_changed_delta_keeps_stale_and_file_signals_drops_drift():
    # Changed mode keeps the whole-tree-sound stale-* signals (unlike a plain
    # subset) and the scoped file-level signals, but still drops the unsound
    # copyright drift.
    sink = ChangedDelta()
    for kind in (STALE_PARAGRAPH, STALE_PATTERN, RELICENSED, NEW_PARAGRAPH):
        sink.add(DeltaEntry(kind=kind, detail="x"))
    sink.add(DeltaEntry(kind=COPYRIGHT_DRIFT, detail="x", sub_kind=DRIFT_ADDED))
    assert set(sink.by_kind()) == {
        STALE_PARAGRAPH,
        STALE_PATTERN,
        RELICENSED,
        NEW_PARAGRAPH,
    }


def test_changed_delta_stale_paragraph_is_staleness():
    # A kept stale paragraph fails the gate (exit 1), like in full explain.
    sink = ChangedDelta()
    sink.add(DeltaEntry(kind=STALE_PARAGRAPH, detail="Files: legacy/*"))
    assert sink.has_staleness() is True


def test_fresh_report_is_empty():
    report = DeltaReport()
    assert report.is_empty() is True


def test_report_not_empty_after_add():
    report = DeltaReport()
    report.add(DeltaEntry(kind=NEW_FILE, detail="src/a.c"))
    assert report.is_empty() is False


def test_by_kind_groups_and_preserves_order():
    report = DeltaReport()
    first = DeltaEntry(kind=NEW_FILE, detail="src/a.c")
    second = DeltaEntry(kind=NEW_FILE, detail="src/b.c")
    third = DeltaEntry(kind=RELICENSED, detail="src/util.c")
    report.add(first)
    report.add(second)
    report.add(third)

    grouped = report.by_kind()
    assert set(grouped) == {NEW_FILE, RELICENSED}
    assert grouped[NEW_FILE] == [first, second]
    assert grouped[RELICENSED] == [third]


def test_null_delta_add_stores_nothing():
    sink = NullDelta()
    sink.add(DeltaEntry(kind=NEW_FILE, detail="src/a.c"))
    sink.add(DeltaEntry(kind=RELICENSED, detail="src/util.c"))
    assert sink.is_empty() is True
    assert sink.by_kind() == {}


def test_null_delta_is_empty_without_adds():
    assert NullDelta().is_empty() is True


def test_relicense_sub_kind_license_grouping_license_differs():
    file_key = GroupKey("Apache-2.0", None, "src/util.c")
    group_key = GroupKey("GPL-2+", None, "src/util.c")
    assert relicense_sub_kind(file_key, group_key) == SUB_LICENSE


def test_relicense_sub_kind_license_grouping_license_same():
    file_key = GroupKey("GPL-2+", None, "src/util.c")
    group_key = GroupKey("GPL-2+", None, "src/util.c")
    assert relicense_sub_kind(file_key, group_key) is None


def test_relicense_sub_kind_copyright_grouping_only_license_differs():
    file_key = GroupKey("Apache-2.0", ("Foo",), "src/util.c")
    group_key = GroupKey("GPL-2+", ("Foo",), "src/util.c")
    assert relicense_sub_kind(file_key, group_key) == SUB_LICENSE


def test_relicense_sub_kind_copyright_grouping_only_copyright_differs():
    file_key = GroupKey("GPL-2+", ("Bar",), "src/util.c")
    group_key = GroupKey("GPL-2+", ("Foo",), "src/util.c")
    assert relicense_sub_kind(file_key, group_key) == SUB_COPYRIGHT


def test_relicense_sub_kind_copyright_grouping_both_differ():
    file_key = GroupKey("Apache-2.0", ("Bar",), "src/util.c")
    group_key = GroupKey("GPL-2+", ("Foo",), "src/util.c")
    assert relicense_sub_kind(file_key, group_key) == SUB_BOTH


def test_relicense_sub_kind_copyright_grouping_neither_differs():
    file_key = GroupKey("GPL-2+", ("Foo",), "src/util.c")
    group_key = GroupKey("GPL-2+", ("Foo",), "src/util.c")
    assert relicense_sub_kind(file_key, group_key) is None


def test_relicense_sub_kind_path_only_difference_is_none():
    file_key = GroupKey("GPL-2+", ("Foo",), "src/a.c")
    group_key = GroupKey("GPL-2+", ("Foo",), "src/b.c")
    assert relicense_sub_kind(file_key, group_key) is None


def _drift(sub_kind: str) -> DeltaEntry:
    return DeltaEntry(kind=COPYRIGHT_DRIFT, detail="Files: src/*", sub_kind=sub_kind)


def test_is_advisory_only_for_removed_only_drift():
    assert is_advisory(_drift(DRIFT_REMOVED)) is True
    assert is_advisory(_drift(DRIFT_ADDED)) is False
    assert is_advisory(_drift(DRIFT_BOTH)) is False
    assert is_advisory(DeltaEntry(kind=NEW_FILE, detail="src/a.c")) is False


def test_has_staleness_true_for_added_or_both_drift():
    for sub_kind in (DRIFT_ADDED, DRIFT_BOTH):
        report = DeltaReport()
        report.add(_drift(sub_kind))
        assert report.has_staleness() is True


def test_has_staleness_false_for_removed_only_drift_but_not_empty():
    report = DeltaReport()
    report.add(_drift(DRIFT_REMOVED))
    assert report.has_staleness() is False
    assert report.is_empty() is False


def test_has_staleness_false_for_fresh_report():
    assert DeltaReport().has_staleness() is False


def test_has_staleness_true_for_other_kinds():
    report = DeltaReport()
    report.add(DeltaEntry(kind=NEW_FILE, detail="src/a.c"))
    assert report.has_staleness() is True


def test_partial_delta_keeps_relicensed_and_new_file():
    sink = PartialDelta()
    sink.add(DeltaEntry(kind=RELICENSED, detail="src/util.c"))
    sink.add(DeltaEntry(kind=NEW_FILE, detail="src/a.c"))
    assert set(sink.by_kind()) == {RELICENSED, NEW_FILE}


def test_partial_delta_drops_drift_and_stale_kinds():
    sink = PartialDelta()
    sink.add(_drift(DRIFT_ADDED))
    sink.add(_drift(DRIFT_REMOVED))
    sink.add(DeltaEntry(kind=STALE_PARAGRAPH, detail="Files: legacy/*"))
    sink.add(
        DeltaEntry(kind=STALE_PATTERN, detail="Files: src/*", pattern="src/old_*.c")
    )
    assert sink.is_empty() is True
