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

"""End-to-end explain mode: rendered stdout and the staleness exit code.

These tests build a small tree with a hand-written debian/copyright that is
stale in various ways and assert the report printed to stdout plus the
run_pipeline exit code. run_pipeline owns its delta sink; tests never inject
one.
"""

from __future__ import annotations

import contextlib
import io
import shutil
import tempfile
import unittest
from pathlib import Path

from decopy import decopy
from decopy.cmdoptions import process_options
from decopy.dep5doc.delta import RELICENSED, SUB_LICENSE, DeltaEntry, DeltaReport
from decopy.dep5doc.explain_renderer import ExplainRenderer

_TESTDATA = Path(__file__).parent / "testdata"


def _write_tree(root: Path, copyright_text: str | None) -> None:
    """Copy known-license fixtures into `root` and write debian/copyright."""
    src = _TESTDATA / "one"
    for name in ("art.txt", "bsd.txt", "paren.txt"):
        shutil.copy(src / name, root / name)
    if copyright_text is not None:
        debian = root / "debian"
        debian.mkdir()
        (debian / "copyright").write_text(copyright_text, encoding="utf-8")


def _run_explain(
    copyright_text: str | None, extra_args: list[str] | None = None
) -> tuple[int, str, str]:
    """Run a non-injected explain pipeline, returning (code, stdout, stderr)."""
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_tree(root, copyright_text)
        argv = [
            "--no-progress",
            "--no-expand-archives",
            "--explain",
            "--root",
            str(root),
        ]
        if extra_args:
            argv.extend(extra_args)
        options = process_options(argv)
        out, err = io.StringIO(), io.StringIO()
        with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
            code = decopy.run_pipeline(options)
        return code, out.getvalue(), err.getvalue()


_STALE_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
License: GPL-3+

Files: art.txt
License: Artistic-1

Files: b*
License: GPL-2+
"""

_CLEAN_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
License: GPL-3+
Comment: catch-all

Files: art.txt
License: Artistic-1

Files: bsd.txt
License: BSD-3-clause

Files: paren.txt
License: GPL-3+
"""


class TestExplainOutput(unittest.TestCase):
    """run_pipeline renders the report to stdout and returns the exit code."""

    def test_clean_tree_prints_up_to_date_exits_zero(self):
        code, out, _ = _run_explain(_CLEAN_COPYRIGHT)
        self.assertEqual(out, "debian/copyright is up to date.\n")
        self.assertEqual(code, 0)

    def test_stale_tree_prints_report_exits_one(self):
        code, out, _ = _run_explain(_STALE_COPYRIGHT)
        self.assertIn("debian/copyright is out of date:", out)
        self.assertIn("bsd.txt", out)
        self.assertEqual(code, 1)

    def test_report_on_stdout_not_stderr(self):
        _, out, err = _run_explain(_STALE_COPYRIGHT)
        self.assertIn("debian/copyright is out of date:", out)
        self.assertNotIn("debian/copyright is out of date:", err)
        self.assertNotIn("no longer match", err)


_UTIL_C = """\
// Copyright 2020 Foo Bar
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
"""

_RELICENSE_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
License: GPL-3+

Files: art.txt
License: Artistic-1

Files: bsd.txt
License: BSD-3-clause

Files: paren.txt
License: GPL-3+

Files: util.c
Copyright: 2010, Someone Else
License: GPL-2+
"""


_APACHE_BODY = """\
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
"""

_FOO_C = "// Copyright 2020 Alice <alice@example.com>\n" + _APACHE_BODY
_BAR_C = "// Copyright 2021 Bob <bob@example.com>\n" + _APACHE_BODY

_UNION_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
Copyright: 2020 Alice <alice@example.com>
 2021 Bob <bob@example.com>
License: Apache-2.0
"""


def _run_copyright_grouping(args: list[str]) -> tuple[int, str]:
    """Run a --group-by copyright pipeline over a union-paragraph tree."""
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        (root / "src").mkdir()
        (root / "src" / "foo.c").write_text(_FOO_C, encoding="utf-8")
        (root / "src" / "bar.c").write_text(_BAR_C, encoding="utf-8")
        debian = root / "debian"
        debian.mkdir()
        (debian / "copyright").write_text(_UNION_COPYRIGHT, encoding="utf-8")
        argv = [
            "--no-progress",
            "--no-expand-archives",
            "--group-by",
            "copyright",
            "--root",
            str(root),
            *args,
        ]
        options = process_options(argv)
        out = io.StringIO()
        with contextlib.redirect_stdout(out):
            code = decopy.run_pipeline(options)
        return code, out.getvalue()


class TestCopyrightGroupingUnionParagraph(unittest.TestCase):
    """A multi-holder paragraph under --group-by copyright is stable.

    Its Copyright field is the union of its files' holders, so each file carries
    a subset. Explain must not report the files as changed, and generation must
    not tear the paragraph into one-paragraph-per-holder.
    """

    def test_explain_reports_up_to_date(self):
        code, out = _run_copyright_grouping(["--explain"])
        self.assertEqual(out, "debian/copyright is up to date.\n")
        self.assertEqual(code, 0)

    def test_generation_preserves_the_union_paragraph(self):
        _, out = _run_copyright_grouping(["-o", "-"])
        # One Files paragraph still covers both files (wildcarded to *), and its
        # Copyright keeps both holders rather than splitting per holder.
        self.assertEqual(out.count("Files: src/"), 0)
        self.assertIn("Files: *", out)
        self.assertIn("Alice <alice@example.com>", out)
        self.assertIn("Bob <bob@example.com>", out)


class TestHolderPopulation(unittest.TestCase):
    """Under --group-by copyright, relicensed lines show holders."""

    def test_holders_rendered_under_copyright_grouping(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _write_tree(root, _RELICENSE_COPYRIGHT)
            (root / "util.c").write_text(_UTIL_C, encoding="utf-8")
            options = process_options(
                [
                    "--no-progress",
                    "--no-expand-archives",
                    "--explain",
                    "--group-by",
                    "copyright",
                    "--root",
                    str(root),
                ]
            )
            out = io.StringIO()
            with contextlib.redirect_stdout(out):
                decopy.run_pipeline(options)
            text = out.getvalue()
        relicensed = [
            line for line in text.splitlines() if line.strip().startswith("util.c")
        ]
        self.assertTrue(relicensed)
        # Holders render as +/- delta lines below the item line, not inline.
        self.assertIn("- Someone Else", text)
        self.assertIn("+ Foo Bar", text)

    def test_added_holder_carries_source_removed_does_not(self):
        # End-to-end: the relicensed +/- lines come from a real pipeline run.
        # util.c detects "Foo Bar" (added); the declared "Someone Else" is removed
        # and, absent from every file, carries no source.
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _write_tree(root, _RELICENSE_COPYRIGHT)
            (root / "util.c").write_text(_UTIL_C, encoding="utf-8")
            options = process_options(
                [
                    "--no-progress",
                    "--no-expand-archives",
                    "--explain",
                    "--group-by",
                    "copyright",
                    "--root",
                    str(root),
                ]
            )
            out = io.StringIO()
            with contextlib.redirect_stdout(out):
                decopy.run_pipeline(options)
            text = out.getvalue()
        self.assertIn("+ Foo Bar  (in util.c)", text)
        self.assertIn("- Someone Else", text)
        self.assertNotIn("- Someone Else  (in", text)

    def test_holders_never_leak_under_license_grouping(self):
        # Under license grouping the copyright slots are masked to None, so even
        # an entry carrying holders must render no parentheses on its line.
        report = DeltaReport()
        report.add(
            DeltaEntry(
                kind=RELICENSED,
                detail="src/util.c",
                path="src/util.c",
                was="GPL-2+",
                now="Apache-2.0",
                sub_kind=SUB_LICENSE,
                was_copyright=("Foo",),
                now_copyright=("Bar",),
            )
        )
        options = process_options(["--group-by", "license"])
        text = ExplainRenderer().explain(report, options)
        line = next(
            ln for ln in text.splitlines() if ln.strip().startswith("src/util.c")
        )
        self.assertNotIn("(", line)
        self.assertIn("was: GPL-2+ -> now: Apache-2.0", line)


def _run_explain_files(files: dict[str, str], copyright_text: str) -> tuple[int, str]:
    """Run an explain pipeline over an explicit {relpath: content} tree."""
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        for relpath, content in files.items():
            target = root / relpath
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(content, encoding="utf-8")
        debian = root / "debian"
        debian.mkdir(exist_ok=True)
        (debian / "copyright").write_text(copyright_text, encoding="utf-8")
        options = process_options(
            [
                "--no-progress",
                "--no-expand-archives",
                "--explain",
                "--root",
                str(root),
            ]
        )
        out = io.StringIO()
        with contextlib.redirect_stdout(out):
            code = decopy.run_pipeline(options)
        return code, out.getvalue()


_RELICENSE_SRC = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: src/*
License: GPL-2+
"""

_RELICENSE_SRC_AND_LIB = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: src/*
License: GPL-2+

Files: lib/*
License: Apache-2.0
"""


class TestRelicensedNewParagraph(unittest.TestCase):
    """A relicensed file's destination paragraph is reported when it is new.

    Under --group-by license the path slot is empty, so a destination key
    matches purely on license: a moved file lands in an existing paragraph when
    one already declares that license, otherwise it seeds a new paragraph.
    """

    def test_moved_file_into_absent_license_seeds_new_paragraph(self):
        # src/util.c is matched by the wildcard src/* (declared GPL-2+) but
        # detects Apache-2, which no paragraph declares: it relicenses AND seeds
        # a new Apache-2 paragraph naming itself.
        code, out = _run_explain_files({"src/util.c": _UTIL_C}, _RELICENSE_SRC)
        self.assertEqual(code, 1)
        self.assertIn("1 file no longer matches its paragraph:", out)
        self.assertIn("src/util.c  was: GPL-2+ -> now: Apache-2", out)
        self.assertIn("1 new paragraph would be added:", out)
        self.assertIn("    Files:     src/util.c", out)
        self.assertIn("    License:   Apache-2  (detected in src/util.c)", out)

    def test_moved_file_into_existing_license_seeds_no_new_paragraph(self):
        # src/util.c relicenses to Apache-2, but the lib/* paragraph already
        # declares Apache-2 (and lib/keep.c keeps it), so the moved file joins
        # that existing paragraph rather than seeding a new one.
        code, out = _run_explain_files(
            {"src/util.c": _UTIL_C, "lib/keep.c": _UTIL_C},
            _RELICENSE_SRC_AND_LIB,
        )
        self.assertEqual(code, 1)
        self.assertIn("src/util.c  was: GPL-2+ -> now: Apache-2", out)
        self.assertNotIn("new paragraph", out)

    def test_moved_and_uncovered_files_share_one_new_paragraph(self):
        # src/util.c (relicensed to Apache-2) and the uncovered new.c (also
        # Apache-2) share a destination key, so they fold into a single new
        # paragraph listing both files, sorted.
        code, out = _run_explain_files(
            {"src/util.c": _UTIL_C, "new.c": _UTIL_C}, _RELICENSE_SRC
        )
        self.assertEqual(code, 1)
        self.assertIn("1 new paragraph would be added:", out)
        self.assertIn("    Files:     new.c", out)
        self.assertIn("               src/util.c", out)


_NO_CATCHALL_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: art.txt
License: Artistic-1

Files: bsd.txt
License: BSD-3-clause

Files: paren.txt
License: GPL-3+
"""


class TestCopyrightFileSuppression(unittest.TestCase):
    """The debian/copyright file is never reported as a new file."""

    def test_only_copyright_uncovered_is_clean(self):
        # Every source file is covered; the only uncovered file is the
        # debian/copyright itself, which must not count as staleness.
        code, out, _ = _run_explain(_NO_CATCHALL_COPYRIGHT)
        self.assertEqual(out, "debian/copyright is up to date.\n")
        self.assertEqual(code, 0)

    def test_copyright_file_absent_from_new_file_section(self):
        code, out, _ = _run_explain(_STALE_COPYRIGHT)
        self.assertEqual(code, 1)
        self.assertNotIn("debian/copyright ->", out)

    def test_non_default_copyright_file_is_suppressed(self):
        # Suppression follows the resolved active copyright path, not a
        # hard-coded debian/copyright; here the active file is COPYING.meta.
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            for name in ("art.txt", "bsd.txt", "paren.txt"):
                shutil.copy(_TESTDATA / "one" / name, root / name)
            (root / "COPYING.meta").write_text(_NO_CATCHALL_COPYRIGHT, encoding="utf-8")
            options = process_options(
                [
                    "--no-progress",
                    "--no-expand-archives",
                    "--explain",
                    "--copyright-file",
                    "COPYING.meta",
                    "--root",
                    str(root),
                ]
            )
            out = io.StringIO()
            with contextlib.redirect_stdout(out):
                code = decopy.run_pipeline(options)
            text = out.getvalue()
        self.assertEqual(text, "debian/copyright is up to date.\n")
        self.assertEqual(code, 0)


_MULTI_DEAD_PATTERN = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
License: GPL-3+

Files: art.txt
License: Artistic-1

Files: bsd.txt old_a.txt old_b.txt
 paren.txt
License: BSD-3-clause
"""

_CROSS_PARAGRAPH_DEAD = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
License: GPL-3+

Files: art.txt gone_*.txt
License: Artistic-1

Files: bsd.txt gone_*.txt
 paren.txt
License: BSD-3-clause
"""


class TestStalePatternDedup(unittest.TestCase):
    """Regression lock for stale-pattern dedup, preserved end-to-end."""

    def test_multiple_dead_patterns_in_one_paragraph(self):
        _, out, _ = _run_explain(_MULTI_DEAD_PATTERN)
        self.assertIn("old_a.txt", out)
        self.assertIn("old_b.txt", out)
        # The paragraph license rides as a [License: X] tag on the pattern line.
        self.assertIn("[License: BSD-3-clause]", out)
        # Two distinct stale-pattern lines, one per dead pattern.
        stale_lines = [line for line in out.splitlines() if "matches nothing" in line]
        self.assertEqual(len(stale_lines), 2)

    def test_same_pattern_across_two_paragraphs(self):
        _, out, _ = _run_explain(_CROSS_PARAGRAPH_DEAD)
        stale_lines = [line for line in out.splitlines() if "matches nothing" in line]
        # gone_*.txt is dead in both the Artistic-1 and BSD-3-clause paragraphs,
        # so it is attributed once per paragraph.
        self.assertEqual(len(stale_lines), 2)


def _run_generate(root: Path, out_path: Path, extra: list[str] | None = None) -> int:
    """Run a plain (non-explain) generation writing copyright to out_path."""
    argv = [
        "--no-progress",
        "--no-expand-archives",
        "-o",
        str(out_path),
        "--root",
        str(root),
    ]
    if extra:
        argv.extend(extra)
    options = process_options(argv)
    return decopy.run_pipeline(options)


class TestExplainOutputFile(unittest.TestCase):
    """-o under explain writes the regenerated copyright, never the report."""

    def test_no_output_writes_no_file_and_leaves_intree_unchanged(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _write_tree(root, _STALE_COPYRIGHT)
            before = (root / "debian" / "copyright").read_text(encoding="utf-8")
            listing_before = sorted(p.name for p in root.iterdir())
            argv = [
                "--no-progress",
                "--no-expand-archives",
                "--explain",
                "--root",
                str(root),
            ]
            options = process_options(argv)
            out = io.StringIO()
            with contextlib.redirect_stdout(out):
                code = decopy.run_pipeline(options)
            after = (root / "debian" / "copyright").read_text(encoding="utf-8")
            listing_after = sorted(p.name for p in root.iterdir())
        self.assertEqual(code, 1)
        self.assertIn("debian/copyright is out of date:", out.getvalue())
        self.assertEqual(before, after)
        self.assertEqual(listing_before, listing_after)

    def test_output_writes_regenerated_copyright_keeps_report_on_stdout(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _write_tree(root, _STALE_COPYRIGHT)
            intree_before = (root / "debian" / "copyright").read_text(encoding="utf-8")
            out_path = root / "out.copyright"
            argv = [
                "--no-progress",
                "--no-expand-archives",
                "--explain",
                "-o",
                str(out_path),
                "--root",
                str(root),
            ]
            options = process_options(argv)
            out = io.StringIO()
            with contextlib.redirect_stdout(out):
                code = decopy.run_pipeline(options)
            stdout_text = out.getvalue()
            written = out_path.read_text(encoding="utf-8")
            intree_after = (root / "debian" / "copyright").read_text(encoding="utf-8")

            # The same -o write under a plain (non-explain) run is the baseline.
            # Generate it in a fresh identical tree: the explain run's out.copyright
            # is a non-excluded path now carrying real licence text, so scanning
            # the same tree would detect licences inside decopy's own output.
            with tempfile.TemporaryDirectory() as golden_tmp:
                golden_root = Path(golden_tmp)
                _write_tree(golden_root, _STALE_COPYRIGHT)
                golden_path = golden_root / "out.copyright"
                _run_generate(golden_root, golden_path)
                golden = golden_path.read_text(encoding="utf-8")

        self.assertEqual(code, 1)
        self.assertIn("debian/copyright is out of date:", stdout_text)
        self.assertEqual(written, golden)
        # The report never lands in the -o file.
        self.assertNotIn("debian/copyright is out of date:", written)
        # The regenerated copyright never lands on stdout.
        self.assertNotIn("Format: https://www.debian.org", stdout_text)
        # The in-tree copyright is untouched.
        self.assertEqual(intree_before, intree_after)

    def test_output_dash_under_explain_writes_no_file(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _write_tree(root, _STALE_COPYRIGHT)
            listing_before = sorted(p.name for p in root.iterdir())
            argv = [
                "--no-progress",
                "--no-expand-archives",
                "--explain",
                "-o",
                "-",
                "--root",
                str(root),
            ]
            options = process_options(argv)
            buf = io.StringIO()
            with contextlib.redirect_stdout(buf):
                code = decopy.run_pipeline(options)
            listing_after = sorted(p.name for p in root.iterdir())
        self.assertEqual(code, 1)
        self.assertIn("debian/copyright is out of date:", buf.getvalue())
        self.assertEqual(listing_before, listing_after)

    def test_unwritable_output_propagates_under_explain(self):
        # The -o write happens before the staleness return, so an unwritable
        # target raises rather than being masked by the exit code.
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _write_tree(root, _STALE_COPYRIGHT)
            bad = root / "missing-dir" / "out.copyright"  # parent does not exist
            argv = [
                "--no-progress",
                "--no-expand-archives",
                "--explain",
                "-o",
                str(bad),
                "--root",
                str(root),
            ]
            options = process_options(argv)
            buf = io.StringIO()
            with contextlib.redirect_stdout(buf), self.assertRaises(OSError):
                decopy.run_pipeline(options)


_FOUR_KINDS_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: art.txt
License: Artistic-1

Files: bsd.txt old_*.txt
License: BSD-3-clause

Files: paren.txt
License: GPL-3+

Files: legacy/*
License: GPL-2+

Files: u*.c
Copyright: 2010, Someone Else
License: GPL-2+
"""


def _write_four_kinds_tree(root: Path) -> None:
    """A tree stale in all four ways: stale-paragraph (legacy/*), stale-pattern
    (old_*.txt), a new uncovered file (new.c), and a relicensed file (util.c)."""
    src = _TESTDATA / "one"
    for name in ("art.txt", "bsd.txt", "paren.txt"):
        shutil.copy(src / name, root / name)
    (root / "util.c").write_text(_UTIL_C, encoding="utf-8")
    (root / "new.c").write_text(_UTIL_C, encoding="utf-8")
    debian = root / "debian"
    debian.mkdir()
    (debian / "copyright").write_text(_FOUR_KINDS_COPYRIGHT, encoding="utf-8")


def _run_four_kinds(partial_files: list[str]) -> tuple[int, str]:
    """Run explain over the four-kinds tree; partial_files (basenames) selects
    partial mode when non-empty, passed as absolute paths argparse can validate."""
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_four_kinds_tree(root)
        argv = [
            "--no-progress",
            "--no-expand-archives",
            "--explain",
            "--root",
            str(root),
        ]
        if partial_files:
            argv.extend(["--mode", "partial"])
            argv.extend(str(root / name) for name in partial_files)
        options = process_options(argv)
        out = io.StringIO()
        with contextlib.redirect_stdout(out):
            code = decopy.run_pipeline(options)
        return code, out.getvalue()


class TestPartialSuppression(unittest.TestCase):
    """Partial explain reports only new-file and relicensed; full reports all."""

    def test_partial_suppresses_stale_paragraph_and_pattern(self):
        code, out = _run_four_kinds(["new.c", "util.c"])
        self.assertEqual(code, 1)
        # The uncovered file is still reported as a would-be paragraph...
        self.assertIn("new paragraph would be added", out)
        self.assertIn("    Files:     new.c", out)
        self.assertIn("util.c", out)
        # ...but the whole-tree stale-* kinds are dropped for a subset.
        self.assertNotIn("matches no files", out)
        self.assertNotIn("matches nothing", out)

    def test_partial_keeps_moved_file_new_paragraph(self):
        # util.c is in the partial subset, relicenses (u*.c declares GPL-2+, the
        # file detects Apache-2), and its Apache-2 destination has no paragraph.
        # The new-paragraph block survives partial mode (NEW_PARAGRAPH is not a
        # stale-* kind) and lists the moved file alongside the uncovered new.c.
        code, out = _run_four_kinds(["new.c", "util.c"])
        self.assertEqual(code, 1)
        self.assertIn("1 file no longer matches its paragraph:", out)
        self.assertIn("util.c  was: GPL-2+ -> now: Apache-2", out)
        self.assertIn("1 new paragraph would be added:", out)
        # The moved file is a continuation line of the bucket's Files field.
        self.assertIn("               util.c", out)
        self.assertNotIn("matches no files", out)
        self.assertNotIn("matches nothing", out)

    def test_full_reports_all_four_kinds(self):
        code, out = _run_four_kinds([])
        self.assertEqual(code, 1)
        self.assertIn("new paragraph would be added", out)
        self.assertIn("no longer match", out)
        # The relicensed entry shows the was/now licenses (util.c is declared
        # GPL-2+ but detects Apache via the u*.c wildcard).
        self.assertIn("was: GPL-2+ -> now: Apache-2", out)
        self.assertIn("matches no files", out)
        self.assertIn("matches nothing", out)


_APACHE_TAIL = """\
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
"""

# util.c detected as Apache-2.0, carrying two holders.
_UTIL_C_TWO = (
    "// Copyright 2020 Foo Bar\n// Copyright 2010 Someone Else\n" + _APACHE_TAIL
)
# util.c detected as Apache-2.0, carrying only "Foo Bar".
_UTIL_C_ONE = "// Copyright 2020 Foo Bar\n" + _APACHE_TAIL

# Declares only "Someone Else": the kept file adds "Foo Bar" -> added drift.
_DRIFT_ADD = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: u*.c
Copyright: 2010, Someone Else
License: Apache-2.0
"""

# Declares both holders: the kept file backs only "Foo Bar" -> removed-only drift.
_DRIFT_REMOVE = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: u*.c
Copyright: 2020, Foo Bar
 2010, Someone Else
License: Apache-2.0
"""


def _run_drift(util_c: str, copyright_text: str) -> tuple[int, str]:
    """Explain over a minimal tree whose only file is util.c (fully covered)."""
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        (root / "util.c").write_text(util_c, encoding="utf-8")
        debian = root / "debian"
        debian.mkdir()
        (debian / "copyright").write_text(copyright_text, encoding="utf-8")
        argv = [
            "--no-progress",
            "--no-expand-archives",
            "--explain",
            "--root",
            str(root),
        ]
        options = process_options(argv)
        out = io.StringIO()
        with contextlib.redirect_stdout(out):
            code = decopy.run_pipeline(options)
        return code, out.getvalue()


class TestCopyrightDriftExitCode(unittest.TestCase):
    """Drift drives the exit code by direction: added is staleness, removed advisory."""

    def test_added_holder_drift_exits_one(self):
        code, out = _run_drift(_UTIL_C_TWO, _DRIFT_ADD)
        self.assertEqual(code, 1)
        self.assertIn("copyright drift", out)
        self.assertIn("+ Foo Bar", out)

    def test_removed_only_drift_is_advisory_exits_zero(self):
        code, out = _run_drift(_UTIL_C_ONE, _DRIFT_REMOVE)
        # The section still prints (report is non-empty)...
        self.assertIn("copyright drift", out)
        self.assertIn("- Someone Else  [advisory]", out)
        # ...but a removed-only drift does not fail the gate.
        self.assertEqual(code, 0)

    def test_generation_path_unaffected_by_drift(self):
        # A plain (non-explain) generation run never consults has_staleness and
        # unions the holders as before; the drift recording (NullDelta) is inert.
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "util.c").write_text(_UTIL_C_TWO, encoding="utf-8")
            debian = root / "debian"
            debian.mkdir()
            (debian / "copyright").write_text(_DRIFT_ADD, encoding="utf-8")
            out_path = root / "out.copyright"
            code = _run_generate(root, out_path)
            written = out_path.read_text(encoding="utf-8")
        self.assertEqual(code, 0)
        self.assertIn("Foo Bar", written)
        self.assertIn("Someone Else", written)


_ALL_CASES_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: src/*
License: GPL-2+

Files: art.txt old_gone.txt
License: Artistic-1

Files: ancient/*
License: Expat
"""


def _run_all_cases() -> tuple[int, str]:
    """Build a tree that triggers every staleness section in one run.

    The hand-written copyright is stale four independent ways:
    * `src/util.c` is matched by the wildcard `src/*` (License: GPL-2+) but its
      content detects Apache-2, so it relicenses (file no longer matches).
    * `art.txt`'s paragraph also lists `old_gone.txt`, which matches no file
      (a stale pattern; the paragraph survives because art.txt matches).
    * the `ancient/*` paragraph matches nothing at all (a stale paragraph).
    * `bsd.txt` and `paren.txt` are covered by no paragraph (new files).
    """
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_tree(root, _ALL_CASES_COPYRIGHT)
        (root / "src").mkdir()
        (root / "src" / "util.c").write_text(_UTIL_C, encoding="utf-8")
        argv = [
            "--no-progress",
            "--no-expand-archives",
            "--explain",
            "--root",
            str(root),
        ]
        options = process_options(argv)
        out = io.StringIO()
        with contextlib.redirect_stdout(out):
            code = decopy.run_pipeline(options)
        return code, out.getvalue()


class TestExplainAllCases(unittest.TestCase):
    """One tree that triggers all four staleness sections at once.

    Locks the current explain output for the new-paragraph, relicensed,
    stale-paragraph and stale-pattern cases together.
    """

    def test_every_section_renders_and_exits_one(self):
        code, out = _run_all_cases()
        self.assertEqual(code, 1)
        # Case 1 -- new paragraphs: two uncovered files (bsd.txt, paren.txt) plus
        # the relicensed src/util.c, whose Apache-2 destination has no existing
        # paragraph, become three would-be paragraphs, each naming its file,
        # license and where the license came from.
        self.assertIn("3 new paragraphs would be added:", out)
        self.assertIn("    Files:     bsd.txt", out)
        self.assertIn("    License:   BSD-3-clause  (detected in bsd.txt)", out)
        self.assertIn("    Files:     paren.txt", out)
        self.assertIn("    License:   GPL-3+  (detected in paren.txt)", out)
        self.assertIn("    Files:     src/util.c", out)
        self.assertIn("    License:   Apache-2  (detected in src/util.c)", out)
        # Case 2 -- relicensed: src/util.c detected Apache, declared GPL-2+. It is
        # named in both the relicensed and the new-paragraph sections by design.
        self.assertIn("1 file no longer matches its paragraph:", out)
        self.assertIn("src/util.c  was: GPL-2+ -> now: Apache-2", out)
        # Case 4 -- stale paragraph: ancient/* matches nothing.
        self.assertIn("1 paragraph matches no files:", out)
        self.assertIn("Files: ancient/*  [License: Expat]", out)
        # Case 3 -- stale pattern: old_gone.txt is dead in the Artistic paragraph.
        self.assertIn("1 stale pattern:", out)
        self.assertIn("old_gone.txt  matches nothing  [License: Artistic-1]", out)

    def test_sections_appear_in_fixed_order(self):
        # Section order: relicensed, copyright drift, new paragraphs, stale
        # paragraphs, stale patterns. (Copyright drift is absent here.)
        _, out = _run_all_cases()
        order = [
            out.index("no longer matches its paragraph"),
            out.index("new paragraphs would be added"),
            out.index("paragraph matches no files"),
            out.index("stale pattern"),
        ]
        self.assertEqual(order, sorted(order))


_HOLDER_C = """\
// Copyright 2020 Acme Inc <dev@acme.example>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
"""


class TestNewParagraphProvenance(unittest.TestCase):
    """End-to-end provenance for the would-be paragraphs of uncovered files."""

    def test_uncovered_files_bucket_into_one_paragraph_with_sources(self):
        # No debian/copyright at all, so both Apache files are uncovered. They
        # share a key, so they form one paragraph naming the license and holder
        # sources; an uncovered tree fails the gate (exit 1).
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "src").mkdir()
            (root / "src" / "a.c").write_text(_HOLDER_C, encoding="utf-8")
            (root / "src" / "b.c").write_text(_HOLDER_C, encoding="utf-8")
            argv = [
                "--no-progress",
                "--no-expand-archives",
                "--explain",
                "--root",
                str(root),
            ]
            buffer = io.StringIO()
            with contextlib.redirect_stdout(buffer):
                code = decopy.run_pipeline(process_options(argv))
            out = buffer.getvalue()
        self.assertEqual(code, 1)
        self.assertIn("1 new paragraph would be added:", out)
        self.assertIn("    Files:     src/a.c", out)
        self.assertIn("               src/b.c", out)
        self.assertIn("    License:   Apache-2  (detected in src/a.c)", out)
        self.assertIn("    Copyright: dev@acme.example  (in src/a.c, src/b.c)", out)

    def test_generation_path_unaffected_by_new_paragraphs(self):
        # A plain (non-explain) generation run groups the uncovered files into a
        # real paragraph; the explain-only new-paragraph recording (NullDelta) is
        # inert, so the diagnostic phrasing never leaks into the output.
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "src").mkdir()
            (root / "src" / "a.c").write_text(_HOLDER_C, encoding="utf-8")
            out_path = root / "out.copyright"
            code = _run_generate(root, out_path)
            written = out_path.read_text(encoding="utf-8")
        self.assertEqual(code, 0)
        # The file is grouped and rendered normally (its holder reaches output)...
        self.assertIn("Acme Inc <dev@acme.example>", written)
        # ...with none of the explain-only diagnostic phrasing leaking in.
        self.assertNotIn("new paragraph would be added", written)
        self.assertNotIn("detected in", written)


class TestExplainHelp(unittest.TestCase):
    """The parser help advertises --explain and its staleness exit code."""

    def test_help_mentions_explain_and_exit_one(self):
        out = io.StringIO()
        with self.assertRaises(SystemExit), contextlib.redirect_stdout(out):
            process_options(["--help"])
        text = out.getvalue()
        self.assertIn("--explain", text)
        self.assertIn("exits 1", text)


_IGNORE_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: keep.txt
License: Artistic-1
"""

_FILES_EXCLUDED_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Files-Excluded: build/*
Source: TODO

Files: keep.txt
License: Artistic-1
"""


def _run_in_ignore_tree(copyright_text, *, explain, gitignore=None, extra_args=None):
    """Build a tree (keep.txt covered, build/skip.txt uncovered BSD) and run.

    Returns (exit_code, stdout). In generation mode stdout is the regenerated
    debian/copyright (output defaults to stdout).
    """
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        shutil.copy(_TESTDATA / "one" / "art.txt", root / "keep.txt")
        (root / "build").mkdir()
        shutil.copy(_TESTDATA / "one" / "bsd.txt", root / "build" / "skip.txt")
        if gitignore is not None:
            (root / ".gitignore").write_text(gitignore, encoding="utf-8")
        debian = root / "debian"
        debian.mkdir()
        (debian / "copyright").write_text(copyright_text, encoding="utf-8")
        argv = ["--no-progress", "--no-expand-archives", "--root", str(root)]
        if explain:
            argv.append("--explain")
        if extra_args:
            argv.extend(extra_args)
        options = process_options(argv)
        out = io.StringIO()
        with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
            code = decopy.run_pipeline(options)
        return code, out.getvalue()


class TestIgnoredFilesExplain(unittest.TestCase):
    """Files the maintainer excluded never enter the tree, so explain neither
    reports them nor lets them fail the gate; --no-gitignore brings them back."""

    def test_gitignored_uncovered_file_is_not_reported_exits_zero(self):
        code, out = _run_in_ignore_tree(
            _IGNORE_COPYRIGHT, explain=True, gitignore="build/\n"
        )
        self.assertEqual(out, "debian/copyright is up to date.\n")
        self.assertEqual(code, 0)

    def test_no_gitignore_reports_the_uncovered_file_exits_one(self):
        code, out = _run_in_ignore_tree(
            _IGNORE_COPYRIGHT,
            explain=True,
            gitignore="build/\n",
            extra_args=["--no-gitignore"],
        )
        self.assertIn("new paragraph", out)
        self.assertIn("build/skip.txt", out)
        self.assertIn("BSD-3-clause", out)
        self.assertEqual(code, 1)

    def test_files_excluded_suppresses_regardless_of_gitignore_flag(self):
        code, out = _run_in_ignore_tree(
            _FILES_EXCLUDED_COPYRIGHT, explain=True, extra_args=["--no-gitignore"]
        )
        self.assertNotIn("skip.txt", out)
        self.assertEqual(code, 0)

    def test_generation_omits_the_gitignored_paragraph(self):
        code, out = _run_in_ignore_tree(
            _IGNORE_COPYRIGHT, explain=False, gitignore="build/\n"
        )
        self.assertIn("Artistic", out)
        self.assertNotIn("BSD-3-clause", out)
        self.assertNotIn("skip.txt", out)
        self.assertEqual(code, 0)


if __name__ == "__main__":
    unittest.main()
