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

"""Copyright-holder drift detection (explain mode, --group-by license).

These exercise the copyright_processor side of the drift signal: the declared
holders snapshot taken in _process_groups and (later) the drift recording in
remove_misplaced_files.
"""

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path

from decopy import cmdoptions
from decopy.dep5doc import dep5
from decopy.dep5doc.delta import (
    COPYRIGHT_DRIFT,
    DRIFT_ADDED,
    DRIFT_REMOVED,
    RELICENSED,
    STALE_PARAGRAPH,
    DeltaReport,
)
from decopy.fstree import nodes, tree

_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
"""

# A file detected as Apache-2.0 carrying the holder "Foo Bar".
_UTIL_C = "// Copyright 2020 Foo Bar\n" + _APACHE_TAIL

# Same, but carrying two holders.
_UTIL_C_TWO = (
    "// Copyright 2020 Foo Bar\n// Copyright 2010 Someone Else\n" + _APACHE_TAIL
)

# The paragraph matches util.c by license (Apache-2.0) so the file is kept, but
# declares a different holder ("Someone Else") than the file carries.
_DRIFT_COPYRIGHT = """\
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
"""


def _build_processed(root: Path, *, group_by: str = "license"):
    """Run the pipeline stages up to remove_misplaced_files, returning copyright_.

    Mirrors run_pipeline's stage order: copyright_.process matches files and
    builds groups (declared snapshot taken here), filetree.process parses files,
    then remove_misplaced_files merges kept files' holders.
    """
    (root / "util.c").write_text(_UTIL_C, encoding="utf-8")
    debian = root / "debian"
    debian.mkdir()
    (debian / "copyright").write_text(_DRIFT_COPYRIGHT, encoding="utf-8")

    options = cmdoptions.process_options(
        [
            "--no-progress",
            "--no-expand-archives",
            "--group-by",
            group_by,
            "--root",
            str(root),
        ]
    )
    filetree = tree.RootInfo.build(options)
    copyright_ = dep5.read_copyright(options)
    copyright_.process(filetree)
    filetree.process(options)
    copyright_.remove_misplaced_files(options)
    return copyright_, options


def _util_group(copyright_):
    """The group that ends up holding util.c after remove_misplaced_files."""
    for group in copyright_.groups or ():
        if "util.c" in {f.fullname for f in group.files}:
            return group
    msg = "util.c was not kept in any group"
    raise AssertionError(msg)


def _drift_report(
    root: Path, util_c: str, copyright_text: str, *, group_by: str = "license"
) -> DeltaReport:
    """Build a tree and run remove_misplaced_files with an injected report."""
    (root / "util.c").write_text(util_c, encoding="utf-8")
    debian = root / "debian"
    debian.mkdir()
    (debian / "copyright").write_text(copyright_text, encoding="utf-8")

    options = cmdoptions.process_options(
        [
            "--no-progress",
            "--no-expand-archives",
            "--group-by",
            group_by,
            "--root",
            str(root),
        ]
    )
    filetree = tree.RootInfo.build(options)
    copyright_ = dep5.read_copyright(options)
    report = DeltaReport()
    copyright_.process(filetree)
    filetree.process(options)
    copyright_.remove_misplaced_files(options, None, report)
    return report


def _drift_entries(report: DeltaReport):
    return report.by_kind().get(COPYRIGHT_DRIFT, [])


_DECLARES_SOMEONE = """\
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 = """\
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
"""

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

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


class IsPackagingTest(unittest.TestCase):
    """FileInfo.is_packaging identifies the debian/ packaging directory."""

    def setUp(self):
        self.root = nodes.DirInfo(parent=None, name="")
        self.debian = nodes.DirInfo(parent=self.root, name="debian")
        self.src = nodes.DirInfo(parent=self.root, name="src")

    def test_debian_dir_is_packaging(self):
        self.assertTrue(self.debian.is_packaging())

    def test_file_under_debian_is_packaging(self):
        rules = nodes.FileInfo(parent=self.debian, name="rules")
        self.assertTrue(rules.is_packaging())

    def test_upstream_file_is_not_packaging(self):
        util = nodes.FileInfo(parent=self.src, name="util.c")
        self.assertFalse(util.is_packaging())

    def test_debian_prefix_without_slash_is_not_packaging(self):
        # "debianish.c" starts with "debian" but is not under debian/.
        debianish = nodes.FileInfo(parent=self.root, name="debianish.c")
        self.assertFalse(debianish.is_packaging())


class RecordCopyrightDriftTest(unittest.TestCase):
    """remove_misplaced_files records drift for kept files under license grouping."""

    def test_added_holder_is_drift_added(self):
        # File carries {Foo Bar, Someone Else}; paragraph declares only Someone Else.
        with tempfile.TemporaryDirectory() as tmp:
            report = _drift_report(Path(tmp), _UTIL_C_TWO, _DECLARES_SOMEONE)
        entries = _drift_entries(report)
        self.assertEqual(len(entries), 1)
        self.assertEqual(entries[0].sub_kind, DRIFT_ADDED)
        self.assertIn("Foo Bar", " ".join(entries[0].now_copyright or ()))

    def test_orphaned_holder_is_drift_removed(self):
        # File carries only Foo Bar; paragraph declares Foo Bar and Someone Else.
        with tempfile.TemporaryDirectory() as tmp:
            report = _drift_report(Path(tmp), _UTIL_C, _DECLARES_BOTH)
        entries = _drift_entries(report)
        self.assertEqual(len(entries), 1)
        self.assertEqual(entries[0].sub_kind, DRIFT_REMOVED)
        self.assertIn("Someone Else", " ".join(entries[0].was_copyright or ()))

    def test_matching_holders_record_nothing(self):
        with tempfile.TemporaryDirectory() as tmp:
            report = _drift_report(Path(tmp), _UTIL_C, _DECLARES_FOO)
        self.assertEqual(_drift_entries(report), [])

    def test_copyright_grouping_records_no_drift(self):
        # The same added-holder fixture under --group-by copyright: holder changes
        # surface via the relicensed path, so drift is gated off (D5).
        with tempfile.TemporaryDirectory() as tmp:
            report = _drift_report(
                Path(tmp), _UTIL_C_TWO, _DECLARES_SOMEONE, group_by="copyright"
            )
        self.assertEqual(_drift_entries(report), [])


def _multi_report(
    root: Path,
    files: dict[str, str],
    copyright_text: str,
    *,
    group_by: str = "license",
    extra_args: list[str] | None = None,
) -> DeltaReport:
    """Write several files plus debian/copyright, run remove_misplaced_files."""
    for relpath, content in files.items():
        path = root / relpath
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")
    debian = root / "debian"
    debian.mkdir(exist_ok=True)
    (debian / "copyright").write_text(copyright_text, encoding="utf-8")

    options = cmdoptions.process_options(
        [
            "--no-progress",
            "--no-expand-archives",
            "--group-by",
            group_by,
            "--root",
            str(root),
            *(extra_args or []),
        ]
    )
    filetree = tree.RootInfo.build(options)
    copyright_ = dep5.read_copyright(options)
    report = DeltaReport()
    copyright_.process(filetree)
    filetree.process(options)
    copyright_.remove_misplaced_files(options, None, report)
    return report


# src/* and debian/* both declared GPL-2+, but the files below detect Apache-2.0,
# so each would relicense were it not for the packaging suppression.
_SRC_AND_DEBIAN_GPL = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: src/*
License: GPL-2+

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


class RelicenseDebianSuppressionTest(unittest.TestCase):
    """A license mismatch under debian/ is not reported as relicensed."""

    def test_upstream_relicensed_debian_suppressed(self):
        with tempfile.TemporaryDirectory() as tmp:
            report = _multi_report(
                Path(tmp),
                {"src/bar.c": _UTIL_C, "debian/foo.c": _UTIL_C},
                _SRC_AND_DEBIAN_GPL,
            )
        paths = {e.path for e in report.by_kind().get(RELICENSED, [])}
        self.assertIn("src/bar.c", paths)
        self.assertNotIn("debian/foo.c", paths)


# A packaging file carrying a contributor holder the maintainer did not declare.
_PACKAGER_C = "// Copyright 2024 Packager Person\n" + _APACHE_TAIL

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

Files: debian/*
Copyright: 2026, The Maintainer
License: Apache-2.0
"""

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

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


class DriftDebianSuppressionTest(unittest.TestCase):
    """Packaging files do not drive copyright drift; debian/copyright is truth."""

    def test_pure_debian_paragraph_reports_no_drift(self):
        # debian/foo.c carries "Foo Bar"; the maintainer declared "The Maintainer".
        # Were it not packaging, this would be a both-direction drift.
        with tempfile.TemporaryDirectory() as tmp:
            report = _multi_report(Path(tmp), {"debian/foo.c": _UTIL_C}, _DEBIAN_ONLY)
        self.assertEqual(report.by_kind().get(COPYRIGHT_DRIFT, []), [])

    def test_mixed_group_drifts_on_upstream_holders_only(self):
        # One Files: * group holds an upstream file (adds "Foo Bar") and a
        # packaging file (carries "Packager Person"); only the upstream holder
        # may surface. --no-split-debian keeps both files in the one group.
        with tempfile.TemporaryDirectory() as tmp:
            report = _multi_report(
                Path(tmp),
                {"src/up.c": _UTIL_C_TWO, "debian/foo.c": _PACKAGER_C},
                _CATCHALL_SOMEONE,
                extra_args=["--no-split-debian"],
            )
        entries = report.by_kind().get(COPYRIGHT_DRIFT, [])
        self.assertEqual(len(entries), 1)
        self.assertEqual(entries[0].sub_kind, DRIFT_ADDED)
        now = " ".join(entries[0].now_copyright or ())
        self.assertIn("Foo Bar", now)
        self.assertNotIn("Packager Person", now)


def _process_report(
    root: Path, files: dict[str, str], copyright_text: str
) -> DeltaReport:
    """Build a tree and run copyright_.process with an injected report.

    Stale-paragraph/pattern deltas are recorded during process(), before
    filetree.process, so this captures them without the later stages.
    """
    for relpath, content in files.items():
        path = root / relpath
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")
    debian = root / "debian"
    debian.mkdir(exist_ok=True)
    (debian / "copyright").write_text(copyright_text, encoding="utf-8")

    options = cmdoptions.process_options(
        ["--no-progress", "--no-expand-archives", "--root", str(root)]
    )
    filetree = tree.RootInfo.build(options)
    copyright_ = dep5.read_copyright(options)
    report = DeltaReport()
    copyright_.process(filetree, report)
    return report


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

Files: u*.c
License: Expat

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


class StaleParagraphLicenseTest(unittest.TestCase):
    """A stale-paragraph entry carries the paragraph's declared license."""

    def test_stale_paragraph_carries_license(self):
        # legacy/* matches no files -> recorded as a stale paragraph; util.c
        # keeps the u*.c paragraph alive so only legacy/* is stale.
        with tempfile.TemporaryDirectory() as tmp:
            report = _process_report(
                Path(tmp), {"util.c": _UTIL_C}, _STALE_PARA_COPYRIGHT
            )
        entries = report.by_kind().get(STALE_PARAGRAPH, [])
        self.assertEqual(len(entries), 1)
        self.assertEqual(entries[0].paragraph_license, "GPL-2+")


class DeclaredCopyrightKeyTest(unittest.TestCase):
    """_process_groups snapshots the paragraph's declared holders before merge."""

    def test_declared_key_predates_kept_file_merge(self):
        with tempfile.TemporaryDirectory() as tmp:
            copyright_, _ = _build_processed(Path(tmp))
            group = _util_group(copyright_)
            declared = group.declared_copyright_key
            merged = group.copyrights.key()

        # The snapshot reflects only the paragraph's Copyright field...
        self.assertIn("Someone Else", " ".join(declared))
        self.assertNotIn("Foo Bar", " ".join(declared))
        # ...while the live holders absorbed the kept file's "Foo Bar".
        self.assertIn("Foo Bar", " ".join(merged))


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