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

"""Compute the files changed since debian/copyright was last committed.

A leaf module: it shells out to git and returns plain data, owning no policy.
On any unavailability (git missing, not a repo, an untracked copyright file, a
bad --since ref) it returns a Fallback carrying the reason, so the caller can
warn accurately while deciding whether to degrade to a full scan.
"""

from __future__ import annotations

import dataclasses
import shutil
import subprocess

from decopy.common.log import logger

GIT_MISSING = "git-missing"
NOT_A_REPO = "not-a-repo"
COPYRIGHT_UNTRACKED = "copyright-untracked"
BAD_REF = "bad-ref"


@dataclasses.dataclass(frozen=True)
class ChangedPaths:
    """Files that differ from the baseline, classified by how they changed."""

    added_or_modified: tuple[str, ...]
    deleted: tuple[str, ...]
    renamed: tuple[tuple[str, str], ...]


@dataclasses.dataclass(frozen=True)
class Fallback:
    """A reason the changed set could not be computed, with a ready message."""

    reason: str
    message: str


def _fallback(
    reason: str, *, root: str = "", copyright_path: str = "", since: str = ""
) -> Fallback:
    messages = {
        GIT_MISSING: "git was not found on PATH; scanning the full tree "
        "(install git or drop --changed)",
        NOT_A_REPO: f"{root} is not a git repository; scanning the full tree",
        COPYRIGHT_UNTRACKED: f"{copyright_path} is not tracked in git; "
        "scanning the full tree",
        BAD_REF: f"--since {since} is not a valid git ref; scanning the full tree",
    }
    return Fallback(reason=reason, message=messages[reason])


def _git(root: str, *args: str) -> str | None:
    """Run one git command in `root`; stdout on success, None on any failure.

    FileNotFoundError (git absent) and PermissionError are OSError subclasses,
    so a missing or unrunnable git collapses to None here rather than escaping.
    """
    cmd = ["git", "-C", root, *args]
    try:
        result = subprocess.run(  # noqa: S603
            cmd,
            check=True,
            capture_output=True,
            text=True,
        )
    except (OSError, subprocess.CalledProcessError) as exc:
        logger.debug("git %s failed: %s", args, exc)
        return None
    return result.stdout


def _git_available() -> bool:
    return shutil.which("git") is not None


def _resolve_base(root: str, copyright_path: str, since: str) -> str | None:
    """The baseline commit sha, or None when it cannot be resolved.

    With --since it is that ref; otherwise the last commit that touched the
    copyright file (empty output means the file has no git history).
    """
    if since:
        out = _git(root, "rev-parse", "--verify", f"{since}^{{commit}}")
    else:
        out = _git(root, "log", "-n1", "--format=%H", "--", copyright_path)
    if out is None:
        return None
    sha = out.strip()
    return sha or None


def _parse_name_status(
    text: str,
) -> tuple[list[str], list[str], list[tuple[str, str]]]:
    """Split `git diff --name-status -M` output into (added_or_modified,
    deleted, renamed) by each line's status code."""
    added: list[str] = []
    deleted: list[str] = []
    renamed: list[tuple[str, str]] = []
    for line in text.splitlines():
        if not line:
            continue
        fields = line.split("\t")
        code = fields[0][0]
        if code == "R":
            renamed.append((fields[1], fields[2]))
        elif code == "D":
            deleted.append(fields[1])
        else:
            added.append(fields[1])
    return added, deleted, renamed


def _untracked(root: str) -> list[str]:
    out = _git(root, "ls-files", "--others", "--exclude-standard")
    return out.splitlines() if out else []


def changed_paths(
    root: str, copyright_path: str, since: str = ""
) -> ChangedPaths | Fallback:
    """The files changed since the baseline, or a Fallback with the reason.

    The baseline is the commit that last touched `copyright_path` (or `since`
    when given). Working-tree changes are diffed against it and untracked files
    are added; paths are git's repo-root-relative form.
    """
    if not _git_available():
        return _fallback(GIT_MISSING)
    if _git(root, "rev-parse", "--is-inside-work-tree") is None:
        return _fallback(NOT_A_REPO, root=root)
    base = _resolve_base(root, copyright_path, since)
    if base is None:
        reason = BAD_REF if since else COPYRIGHT_UNTRACKED
        return _fallback(reason, copyright_path=copyright_path, since=since)

    added, deleted, renamed = _parse_name_status(
        _git(root, "diff", "--name-status", "-M", base) or ""
    )
    changed = set(added) | set(_untracked(root))
    return ChangedPaths(
        added_or_modified=tuple(sorted(changed)),
        deleted=tuple(sorted(deleted)),
        renamed=tuple(renamed),
    )
