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

"""Unit tests for ExplainRenderer (pure DeltaReport -> str rendering).

Reports are built by hand and the rendered string is asserted, so these tests
never touch the pipeline or stdout. They lock the section order, counts,
singular/plural agreement, per-kind line formats, --spdx rendering and the
holders-under-copyright-grouping rule from the interactions spec.
"""

from decopy import cmdoptions
from decopy.dep5doc.delta import (
    COPYRIGHT_DRIFT,
    DRIFT_ADDED,
    DRIFT_BOTH,
    DRIFT_REMOVED,
    NEW_PARAGRAPH,
    RELICENSED,
    STALE_PARAGRAPH,
    STALE_PATTERN,
    SUB_BOTH,
    SUB_COPYRIGHT,
    SUB_LICENSE,
    DeltaEntry,
    DeltaReport,
)
from decopy.dep5doc.explain_renderer import ExplainRenderer


def _options(args):
    base = ["--no-progress", "--root", "tests/testdata/one"]
    return cmdoptions.process_options([*base, *args])


_PARAGRAPH_DUMP = "Files: legacy/*\nLicense: GPL-2+\n"


def _new_paragraph(license_name, files=("src/a.c",), source=None, holders=None):
    return DeltaEntry(
        kind=NEW_PARAGRAPH,
        detail=license_name,
        now=license_name,
        files=tuple(files),
        license_source=source or f"detected in {files[0]}",
        holder_sources=holders,
    )


def _relicensed(path, sub_kind, licenses, copyrights=(None, None)):
    was, now = licenses
    was_copyright, now_copyright = copyrights
    return DeltaEntry(
        kind=RELICENSED,
        detail=path,
        path=path,
        sub_kind=sub_kind,
        was=was,
        now=now,
        was_copyright=was_copyright,
        now_copyright=now_copyright,
    )


_DRIFT_DUMP = "Files: u*.c\nCopyright: 2010, Someone Else\nLicense: Apache-2.0\n"


def _drift(sub_kind, was, now, dump=_DRIFT_DUMP):
    return DeltaEntry(
        kind=COPYRIGHT_DRIFT,
        detail=dump,
        paragraph=dump,
        sub_kind=sub_kind,
        was_copyright=was,
        now_copyright=now,
    )


def _stale_paragraph(
    dump=_PARAGRAPH_DUMP, paragraph_license="GPL-2+", deleted_files=None
):
    return DeltaEntry(
        kind=STALE_PARAGRAPH,
        detail=dump,
        paragraph=dump,
        paragraph_license=paragraph_license,
        deleted_files=deleted_files,
    )


def _stale_pattern(pattern, dump=_PARAGRAPH_DUMP, paragraph_license="GPL-2+"):
    return DeltaEntry(
        kind=STALE_PATTERN,
        detail=dump,
        pattern=pattern,
        paragraph_license=paragraph_license,
    )


def _explain(report, args=()):
    return ExplainRenderer().explain(report, _options(list(args)))


def test_lead_in_is_first_non_empty_line():
    report = DeltaReport()
    report.add(_new_paragraph("Expat"))
    text = _explain(report)
    first = next(line for line in text.splitlines() if line.strip())
    assert first == "debian/copyright is out of date:"


def test_empty_report_is_clean_message():
    text = _explain(DeltaReport())
    assert text == "debian/copyright is up to date.\n"


def test_section_order_with_all_kinds():
    report = DeltaReport()
    report.add(_new_paragraph("Expat"))
    report.add(_relicensed("src/util.c", SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    report.add(_stale_paragraph())
    report.add(_stale_pattern("legacy/old_*.c"))
    text = _explain(report)

    new_idx = text.index("new paragraph")
    reli_idx = text.index("no longer match")
    para_idx = text.index("matches no files")
    pat_idx = text.index("stale pattern")
    assert reli_idx < new_idx < para_idx < pat_idx


def test_section_skipped_when_empty():
    report = DeltaReport()
    report.add(_new_paragraph("Expat"))
    text = _explain(report)
    assert "no longer match" not in text
    assert "matches no files" not in text
    assert "stale pattern" not in text


def test_count_singular():
    report = DeltaReport()
    report.add(_new_paragraph("Expat"))
    text = _explain(report)
    assert "1 new paragraph would be added:" in text


def test_count_plural():
    report = DeltaReport()
    report.add(_new_paragraph("Expat", ("src/a.c",)))
    report.add(_new_paragraph("Expat", ("src/b.c",)))
    report.add(_new_paragraph("Expat", ("src/c.c",)))
    text = _explain(report)
    assert "3 new paragraphs would be added:" in text


def test_relicensed_header_singular_and_plural():
    one = DeltaReport()
    one.add(_relicensed("src/util.c", SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    assert "1 file no longer matches its paragraph:" in _explain(one)

    many = DeltaReport()
    many.add(_relicensed("src/util.c", SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    many.add(_relicensed("src/io.c", SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    assert "2 files no longer match their paragraph:" in _explain(many)


def test_stale_paragraph_header_singular_and_plural():
    one = DeltaReport()
    one.add(_stale_paragraph())
    assert "1 paragraph matches no files:" in _explain(one)

    many = DeltaReport()
    many.add(_stale_paragraph())
    many.add(_stale_paragraph())
    assert "2 paragraphs match no files:" in _explain(many)


def test_stale_pattern_header_singular_and_plural():
    one = DeltaReport()
    one.add(_stale_pattern("legacy/old_*.c"))
    assert "1 stale pattern:" in _explain(one)

    many = DeltaReport()
    many.add(_stale_pattern("legacy/old_*.c"))
    many.add(_stale_pattern("legacy/gone_*.c"))
    assert "2 stale patterns:" in _explain(many)


def test_new_paragraph_block_format():
    report = DeltaReport()
    report.add(
        _new_paragraph("Expat", ("docs/guide.rst",), "detected in docs/guide.rst")
    )
    text = _explain(report)
    # The would-be paragraph lists its file and names where the license came from.
    assert "    Files:     docs/guide.rst" in text
    assert "    License:   Expat  (detected in docs/guide.rst)" in text
    # A new paragraph has no prior state, so there is no "was: (none)" line.
    assert "was: (none)" not in text


def test_new_paragraph_license_honours_spdx():
    report = DeltaReport()
    report.add(_new_paragraph("Expat", ("src/a.c",)))
    text = _explain(report, ["--spdx"])
    assert "License:   MIT  (detected in src/a.c)" in text
    # --spdx maps the license, not the source phrase (which names a file).
    assert "License:   Expat" not in text


def test_new_paragraph_lists_holders_with_sources():
    report = DeltaReport()
    report.add(
        _new_paragraph(
            "Expat",
            ("src/a.c", "src/b.c"),
            "detected in src/a.c",
            holders=(("Acme Inc", ("src/a.c", "src/b.c")),),
        )
    )
    text = _explain(report)
    assert "    Files:     src/a.c" in text
    # Continuation file aligns under the first.
    assert "               src/b.c" in text
    assert "    Copyright: Acme Inc  (in src/a.c, src/b.c)" in text


def test_was_column_aligned_across_relicensed_rows():
    paths = ["a.c", "long/path/file.c"]
    report = DeltaReport()
    for path in paths:
        report.add(_relicensed(path, SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    rows = [line for line in _explain(report).splitlines() if "was:" in line]
    assert len(rows) == len(paths)
    assert rows[0].index("was:") == rows[1].index("was:")


def test_relicensed_line_format():
    report = DeltaReport()
    report.add(_relicensed("src/parse.c", SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    line = next(line for line in _explain(report).splitlines() if "src/parse.c" in line)
    assert "src/parse.c" in line
    assert "was: GPL-2+ -> now: Apache-2.0" in line
    # The sub-kind token and tab columns are gone from the visible line.
    assert "\t" not in line
    assert "license-changed" not in line


def test_relicensed_license_honours_spdx():
    report = DeltaReport()
    report.add(_relicensed("src/parse.c", SUB_LICENSE, ("Expat", "GPL-2")))
    line = next(
        line
        for line in _explain(report, ["--spdx"]).splitlines()
        if "src/parse.c" in line
    )
    assert "was: MIT -> now: GPL-2.0-only" in line


def test_holders_shown_under_copyright_grouping():
    report = DeltaReport()
    report.add(
        _relicensed(
            "src/util.c",
            SUB_BOTH,
            ("GPL-2+", "Apache-2.0"),
            (("Foo",), ("Bar", "Baz")),
        )
    )
    text = _explain(report, ["--group-by", "copyright"])
    # License stays on the item line; holders move to +/- delta lines below it.
    assert "src/util.c  was: GPL-2+ -> now: Apache-2.0" in text
    assert "      + Bar" in text
    assert "      + Baz" in text
    assert "      - Foo" in text


def test_drift_and_relicensed_share_holder_delta_syntax():
    drift = DeltaReport()
    drift.add(_drift(DRIFT_ADDED, ("X",), ("X", "Y")))
    reli = DeltaReport()
    reli.add(
        _relicensed(
            "src/a.c", SUB_COPYRIGHT, ("GPL-2+", "GPL-2+"), (("X",), ("X", "Y"))
        )
    )
    assert "      + Y" in _explain(drift)
    assert "      + Y" in _explain(reli, ["--group-by", "copyright"])


def test_holders_omitted_under_license_grouping():
    report = DeltaReport()
    report.add(
        _relicensed(
            "src/util.c",
            SUB_LICENSE,
            ("GPL-2+", "Apache-2.0"),
            (("Foo",), ("Bar",)),
        )
    )
    line = next(
        line
        for line in _explain(report, ["--group-by", "license"]).splitlines()
        if "src/util.c" in line
    )
    assert "(" not in line
    assert "was: GPL-2+ -> now: Apache-2.0" in line


def test_copyright_changed_sub_kind_renders():
    report = DeltaReport()
    report.add(
        _relicensed(
            "src/io.c",
            SUB_COPYRIGHT,
            ("GPL-2+", "GPL-2+"),
            (("Foo",), ("Bar",)),
        )
    )
    text = _explain(report, ["--group-by", "copyright"])
    # A copyright-only change: identical-sided license line plus holder deltas.
    assert "src/io.c  was: GPL-2+ -> now: GPL-2+" in text
    assert "      + Bar" in text
    assert "      - Foo" in text


def test_drift_header_singular_and_plural():
    one = DeltaReport()
    one.add(_drift(DRIFT_ADDED, ("Someone Else",), ("Foo Bar", "Someone Else")))
    assert "1 paragraph has copyright drift:" in _explain(one)

    many = DeltaReport()
    many.add(_drift(DRIFT_ADDED, ("Someone Else",), ("Foo Bar", "Someone Else")))
    many.add(_drift(DRIFT_REMOVED, ("A", "B"), ("A",)))
    assert "2 paragraphs have copyright drift:" in _explain(many)


def test_drift_added_line():
    report = DeltaReport()
    report.add(_drift(DRIFT_ADDED, ("Someone Else",), ("Foo Bar", "Someone Else")))
    text = _explain(report)
    assert "    Files: u*.c" in text
    assert "+ Foo Bar" in text
    assert "- " not in text


def test_drift_removed_line_is_advisory():
    report = DeltaReport()
    report.add(_drift(DRIFT_REMOVED, ("Foo Bar", "Someone Else"), ("Foo Bar",)))
    text = _explain(report)
    assert "- Someone Else  [advisory]" in text
    assert "+ " not in text
    # The terse tag is explained once, by a footnote under the section header.
    assert "[advisory] holders are kept on regeneration" in text


def test_drift_both_shows_added_and_removed():
    report = DeltaReport()
    report.add(_drift(DRIFT_BOTH, ("Someone Else",), ("Foo Bar",)))
    text = _explain(report)
    assert "+ Foo Bar" in text
    assert "- Someone Else  [advisory]" in text


def test_drift_added_holder_shows_source_removed_none():
    # An added holder names the files it was found in; a removed holder is
    # absent from every file, so it carries no source.
    report = DeltaReport()
    report.add(
        DeltaEntry(
            kind=COPYRIGHT_DRIFT,
            detail=_DRIFT_DUMP,
            paragraph=_DRIFT_DUMP,
            sub_kind=DRIFT_BOTH,
            was_copyright=("Old",),
            now_copyright=("New",),
            holder_sources=(("New", ("a.c", "b.c")),),
        )
    )
    text = _explain(report)
    assert "      + New  (in a.c, b.c)" in text
    assert "      - Old  [advisory]" in text
    assert "- Old  (in" not in text


def test_relicensed_added_holder_shows_source():
    # The relicensed +/- lines share the holder grammar, so they get sources too.
    report = DeltaReport()
    report.add(
        DeltaEntry(
            kind=RELICENSED,
            detail="src/util.c",
            path="src/util.c",
            sub_kind=SUB_COPYRIGHT,
            was="GPL-2+",
            now="GPL-2+",
            was_copyright=("Foo",),
            now_copyright=("Foo", "Bar"),
            holder_sources=(("Bar", ("src/util.c",)),),
        )
    )
    text = _explain(report, ["--group-by", "copyright"])
    assert "      + Bar  (in src/util.c)" in text


def test_holder_source_truncates_past_cap():
    report = DeltaReport()
    files = ("a.c", "b.c", "c.c", "d.c", "e.c")
    report.add(
        DeltaEntry(
            kind=COPYRIGHT_DRIFT,
            detail=_DRIFT_DUMP,
            paragraph=_DRIFT_DUMP,
            sub_kind=DRIFT_ADDED,
            was_copyright=(),
            now_copyright=("New",),
            holder_sources=(("New", files),),
        )
    )
    text = _explain(report)
    assert "+ New  (in a.c, b.c, c.c +2 more)" in text


def test_added_holder_without_sources_has_no_suffix():
    # No holder_sources (older entries / license grouping) -> + line unchanged.
    report = DeltaReport()
    report.add(_drift(DRIFT_ADDED, ("X",), ("X", "Y")))
    text = _explain(report)
    assert "      + Y" in text
    assert "(in" not in text


def test_advisory_tag_is_drift_only():
    # A relicensed-copyright removed holder is a real move (the file regrouped),
    # not advisory, so it must not carry the [advisory] tag or the footnote.
    report = DeltaReport()
    report.add(
        _relicensed(
            "src/a.c", SUB_COPYRIGHT, ("GPL-2+", "GPL-2+"), (("Foo",), ("Bar",))
        )
    )
    text = _explain(report, ["--group-by", "copyright"])
    assert "- Foo" in text
    assert "[advisory]" not in text


def test_drift_added_only_has_no_advisory_footnote():
    report = DeltaReport()
    report.add(_drift(DRIFT_ADDED, ("Someone Else",), ("Foo Bar", "Someone Else")))
    text = _explain(report)
    assert "[advisory]" not in text


def test_drift_section_placed_between_relicensed_and_new_paragraph():
    report = DeltaReport()
    report.add(_new_paragraph("Expat"))
    report.add(_relicensed("src/util.c", SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    report.add(_drift(DRIFT_ADDED, ("Someone Else",), ("Foo Bar", "Someone Else")))
    report.add(_stale_paragraph())
    report.add(_stale_pattern("legacy/old_*.c"))
    text = _explain(report)

    new_idx = text.index("new paragraph")
    reli_idx = text.index("no longer match")
    drift_idx = text.index("copyright drift")
    para_idx = text.index("matches no files")
    pat_idx = text.index("stale pattern")
    assert reli_idx < drift_idx < new_idx < para_idx < pat_idx


def test_new_paragraph_without_files_is_skipped():
    # Defensive: a new-paragraph entry with no files must not render an empty
    # block nor inflate the count; the valid entry alone drives the section.
    report = DeltaReport()
    report.add(_new_paragraph("Expat", ("src/good.c",), "detected in src/good.c"))
    report.add(DeltaEntry(kind=NEW_PARAGRAPH, detail="Expat", now="Expat", files=None))
    text = _explain(report)
    assert "1 new paragraph would be added:" in text
    assert "    Files:     src/good.c" in text


def test_stale_paragraph_without_dump_is_skipped():
    # Defensive: a stale-paragraph entry missing `paragraph` must not render an
    # empty block nor inflate the count.
    report = DeltaReport()
    report.add(_stale_paragraph("Files: legacy/*\nLicense: GPL-2+\n"))
    report.add(DeltaEntry(kind=STALE_PARAGRAPH, detail="", paragraph=None))
    text = _explain(report)
    assert "1 paragraph matches no files:" in text
    assert "Files: legacy/*" in text


def test_stale_pattern_line_format():
    report = DeltaReport()
    report.add(
        _stale_pattern("legacy/old_*.c", "Files: legacy/*\nLicense: Expat\n", "Expat")
    )
    line = next(
        line for line in _explain(report).splitlines() if "legacy/old_*.c" in line
    )
    assert "legacy/old_*.c" in line
    assert "matches nothing" in line
    assert "[License: Expat]" in line
    assert "(in the" not in line


def test_stale_pattern_uses_paragraph_license_field_not_dump():
    # The renderer reads the explicit paragraph_license field, not a re-parse of
    # the serialised dump: a dump with no License line still renders the license.
    report = DeltaReport()
    report.add(
        DeltaEntry(
            kind=STALE_PATTERN,
            detail="Files: legacy/*\n",
            pattern="legacy/old_*.c",
            paragraph_license="Expat",
        )
    )
    line = next(
        line for line in _explain(report).splitlines() if "legacy/old_*.c" in line
    )
    assert "[License: Expat]" in line


def test_stale_paragraph_shows_files_with_license_tag():
    report = DeltaReport()
    report.add(_stale_paragraph("Files: legacy/*\nLicense: GPL-2+\n"))
    text = _explain(report)
    # The license rides as a tag on the Files line; no separate License: line.
    assert "Files: legacy/*  [License: GPL-2+]" in text
    assert not any(line.strip().startswith("License:") for line in text.splitlines())


def test_stale_paragraph_names_deleted_culprits():
    report = DeltaReport()
    report.add(
        _stale_paragraph(
            "Files: legacy/*\nLicense: GPL-2+\n", deleted_files=("legacy/old.c",)
        )
    )
    text = _explain(report)
    assert "Files: legacy/*  [License: GPL-2+]" in text
    assert "      legacy/old.c (deleted)" in text


def test_stale_paragraph_without_deletions_has_no_provenance_line():
    report = DeltaReport()
    report.add(_stale_paragraph("Files: legacy/*\nLicense: GPL-2+\n"))
    text = _explain(report)
    assert "(deleted)" not in text


def test_stale_paragraph_lists_each_culprit_on_its_own_line():
    report = DeltaReport()
    report.add(
        _stale_paragraph(
            "Files: legacy/*\n", deleted_files=("legacy/a.c", "legacy/b.c")
        )
    )
    text = _explain(report)
    assert "      legacy/a.c (deleted)" in text
    assert "      legacy/b.c (deleted)" in text


def test_stale_license_tags_honour_spdx():
    report = DeltaReport()
    report.add(_stale_pattern("legacy/old_*.c", "Files: legacy/*\n", "GPL-2+"))
    report.add(_stale_paragraph("Files: gone/*\n", "GPL-2+"))
    text = _explain(report, ["--spdx"])
    assert "[License: GPL-2.0-or-later]" in text
    assert "[License: GPL-2+]" not in text


def test_deterministic_across_calls():
    report = DeltaReport()
    report.add(_new_paragraph("Expat"))
    report.add(_relicensed("src/util.c", SUB_LICENSE, ("GPL-2+", "Apache-2.0")))
    report.add(_stale_paragraph())
    report.add(_stale_pattern("legacy/old_*.c"))
    renderer = ExplainRenderer()
    options = _options([])
    assert renderer.explain(report, options) == renderer.explain(report, options)
