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

Builds a git repository whose one commit touches debian/copyright (the
baseline), then asserts --changed scopes the explain report and generation to
the files changed since that baseline, that the git-shorthand equals
--mode=changed, and that an unavailable git degrades to a full scan.
"""

from __future__ import annotations

import contextlib
import io
import logging
import os
import shutil
import subprocess
from pathlib import Path

import pytest

from decopy import decopy
from decopy.cmdoptions import process_options
from decopy.fstree import vcs

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

pytestmark = pytest.mark.skipif(
    shutil.which("git") is None, reason="git is not installed"
)

# A wildcard paragraph declaring a license none of the files detect, so every
# wildcard-matched file reads as relicensed (license-changed only surfaces under
# a wildcard Files: pattern).
_STALE = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

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


# bsd.txt (BSD-3-clause) is covered cleanly; legacy/* matches no current file, so
# it is a whole-tree stale paragraph that changed mode must keep and a plain
# partial subset must drop.
_STALE_PARAGRAPH = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
License: BSD-3-clause

Files: legacy/*
License: MIT
"""


def _run_git(repo, *args):
    env = dict(os.environ)
    env["GIT_CONFIG_GLOBAL"] = os.devnull
    env["GIT_CONFIG_SYSTEM"] = os.devnull
    env.update(
        GIT_AUTHOR_NAME="Test",
        GIT_AUTHOR_EMAIL="test@example.com",
        GIT_COMMITTER_NAME="Test",
        GIT_COMMITTER_EMAIL="test@example.com",
    )
    cmd = ["git", "-C", str(repo), *args]
    subprocess.run(cmd, check=True, capture_output=True, env=env)  # noqa: S603


def _build_repo(root, copyright_text=_STALE):
    """A committed repo with art.txt (Artistic-1), bsd.txt (BSD-3-clause) and the
    given debian/copyright; the single commit is the baseline."""
    for name in ("art.txt", "bsd.txt"):
        shutil.copy(_TESTDATA / name, root / name)
    debian = root / "debian"
    debian.mkdir()
    (debian / "copyright").write_text(copyright_text, encoding="utf-8")
    _run_git(root, "init")
    _run_git(root, "add", "-A")
    _run_git(root, "commit", "-m", "base")


# legacy/old.c is the sole match of Files: legacy/*; removing it empties that
# paragraph, so a deletion since the baseline makes it stale.
_LEGACY_COPYRIGHT = """\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: TODO

Files: *
License: BSD-3-clause

Files: legacy/*
License: Expat
"""


def _build_legacy_repo(root):
    """Like _build_repo, plus a legacy/old.c covered only by Files: legacy/*."""
    for name in ("art.txt", "bsd.txt"):
        shutil.copy(_TESTDATA / name, root / name)
    (root / "legacy").mkdir()
    (root / "legacy" / "old.c").write_text("int old;\n", encoding="utf-8")
    debian = root / "debian"
    debian.mkdir()
    (debian / "copyright").write_text(_LEGACY_COPYRIGHT, encoding="utf-8")
    _run_git(root, "init")
    _run_git(root, "add", "-A")
    _run_git(root, "commit", "-m", "base")


def _run(root, extra_args):
    argv = [
        "--no-progress",
        "--no-expand-archives",
        "--root",
        str(root),
        *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()


def test_explain_changed_scopes_to_changed_file(tmp_path):
    _build_repo(tmp_path)
    # Modify only bsd.txt in the working tree; art.txt is equally relicensed but
    # unchanged, so only bsd.txt must be reported.
    (tmp_path / "bsd.txt").write_text(
        (tmp_path / "bsd.txt").read_text() + "\n// touched\n", encoding="utf-8"
    )
    code, out, _ = _run(tmp_path, ["--explain", "--changed"])
    assert code == 1
    assert "bsd.txt" in out
    assert "art.txt" not in out


def test_empty_diff_reports_nothing_and_exits_zero(tmp_path):
    _build_repo(tmp_path)
    # Nothing changed since the baseline, though the committed copyright is
    # stale: --changed must report nothing (the staleness is not recent).
    code, out, _ = _run(tmp_path, ["--explain", "--changed"])
    assert code == 0
    assert "bsd.txt" not in out
    assert "art.txt" not in out
    # A full explain of the same stale tree would flag the relicenses.
    full_code, full_out, _ = _run(tmp_path, ["--explain"])
    assert full_code == 1
    assert "bsd.txt" in full_out


def test_not_a_repo_warns_and_falls_back_to_full(tmp_path, caplog):
    # No git init: --changed cannot resolve a baseline.
    for name in ("art.txt", "bsd.txt"):
        shutil.copy(_TESTDATA / name, tmp_path / name)
    (tmp_path / "debian").mkdir()
    (tmp_path / "debian" / "copyright").write_text(_STALE, encoding="utf-8")

    with caplog.at_level(logging.WARNING, logger="decopy"):
        changed_code, changed_out, _ = _run(tmp_path, ["--explain", "--changed"])
    full_code, full_out, _ = _run(tmp_path, ["--explain"])
    assert changed_code == full_code
    assert changed_out == full_out
    assert "not a git repository" in caplog.text


def test_git_missing_warns_and_falls_back_to_full(tmp_path, monkeypatch, caplog):
    _build_repo(tmp_path)
    monkeypatch.setattr(vcs.shutil, "which", lambda _cmd: None)
    with caplog.at_level(logging.WARNING, logger="decopy"):
        changed_code, changed_out, _ = _run(tmp_path, ["--explain", "--changed"])
    # With git unavailable the run degrades to a full explain of the stale tree.
    assert changed_code == 1
    assert "bsd.txt" in changed_out
    assert "art.txt" in changed_out
    assert "git was not found" in caplog.text


def test_changed_flag_equals_mode_changed(tmp_path):
    _build_repo(tmp_path)
    (tmp_path / "bsd.txt").write_text(
        (tmp_path / "bsd.txt").read_text() + "\n// touched\n", encoding="utf-8"
    )
    shorthand = _run(tmp_path, ["--explain", "--changed"])
    explicit = _run(tmp_path, ["--explain", "--mode=changed"])
    assert shorthand[0] == explicit[0]
    assert shorthand[1] == explicit[1]


def test_changed_reports_stale_paragraph_that_partial_drops(tmp_path):
    # ChangedDelta keeps the whole-tree stale-paragraph signal (a paragraph that
    # matches no current file); a generic partial subset (PartialDelta) drops it.
    _build_repo(tmp_path, _STALE_PARAGRAPH)
    (tmp_path / "bsd.txt").write_text(
        (tmp_path / "bsd.txt").read_text() + "\n// touched\n", encoding="utf-8"
    )
    changed_code, changed_out, _ = _run(tmp_path, ["--explain", "--changed"])
    assert changed_code == 1
    assert "legacy/*" in changed_out
    _, partial_out, _ = _run(
        tmp_path, ["--explain", "--mode", "partial", str(tmp_path / "bsd.txt")]
    )
    assert "legacy/*" not in partial_out


def test_generation_changed_matches_named_partial(tmp_path):
    _build_repo(tmp_path)
    (tmp_path / "bsd.txt").write_text(
        (tmp_path / "bsd.txt").read_text() + "\n// touched\n", encoding="utf-8"
    )
    _, changed_out, _ = _run(tmp_path, ["--changed"])
    _, partial_out, _ = _run(tmp_path, ["--mode", "partial", str(tmp_path / "bsd.txt")])
    assert changed_out == partial_out


def test_deletion_reports_stale_paragraph_with_provenance(tmp_path):
    _build_legacy_repo(tmp_path)
    (tmp_path / "legacy" / "old.c").unlink()
    code, out, _ = _run(tmp_path, ["--explain", "--changed"])
    assert code == 1
    assert "Files: legacy/*" in out
    assert "legacy/old.c (deleted)" in out
    # Nothing was added/modified, so the file-level scope is empty: art.txt's
    # relicense under the wildcard paragraph is not reported.
    assert "art.txt" not in out


def test_deletion_stale_paragraph_dropped_by_plain_partial(tmp_path):
    _build_legacy_repo(tmp_path)
    (tmp_path / "legacy" / "old.c").unlink()
    _, out, _ = _run(
        tmp_path, ["--explain", "--mode", "partial", str(tmp_path / "bsd.txt")]
    )
    assert "legacy/*" not in out


def test_full_explain_reports_stale_paragraph_without_provenance(tmp_path):
    _build_legacy_repo(tmp_path)
    (tmp_path / "legacy" / "old.c").unlink()
    code, out, _ = _run(tmp_path, ["--explain"])
    assert code == 1
    assert "Files: legacy/*" in out
    # The deleted set is computed only in changed mode, so no provenance here.
    assert "(deleted)" not in out


def test_rename_names_old_side_as_provenance(tmp_path):
    _build_legacy_repo(tmp_path)
    _run_git(tmp_path, "mv", "legacy/old.c", "moved.c")
    code, out, _ = _run(tmp_path, ["--explain", "--changed"])
    assert code == 1
    assert "legacy/old.c (deleted)" in out


def test_empty_diff_still_reports_pre_existing_stale_paragraph(tmp_path):
    # A paragraph stale already at the baseline (no legacy/ dir ever): changed
    # mode reports it even with an empty diff, but without provenance since no
    # deletion caused it.
    _build_repo(tmp_path, _STALE_PARAGRAPH)
    code, out, _ = _run(tmp_path, ["--explain", "--changed"])
    assert code == 1
    assert "legacy/*" in out
    assert "(deleted)" not in out
