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

"""Decide which tree paths the maintainer has excluded from the source.

Combines the debian/copyright Files-Excluded repack list (always honoured) with
the VCS ignore lists (.gitignore, .git/info/exclude; gated by use_gitignore).
The filesystem walk consults is_ignored to prune nodes before they enter the
tree, so generation and explain both skip excluded files.
"""

from __future__ import annotations

import typing as t
from pathlib import Path

import debian.copyright as dc

from decopy.common.log import logger

if t.TYPE_CHECKING:
    from pathspec import GitIgnoreSpec
else:
    try:
        from pathspec import GitIgnoreSpec
    except ImportError:
        GitIgnoreSpec = None

_warned_no_pathspec = False


def _warn_no_pathspec() -> None:
    """Warn once that gitignore support is inert without pathspec (DP7)."""
    global _warned_no_pathspec  # noqa: PLW0603
    if not _warned_no_pathspec:
        logger.warning(
            "pathspec is not installed; .gitignore/.git/info/exclude are ignored "
            "(install python3-pathspec to honour them)"
        )
        _warned_no_pathspec = True


def _ancestors(relpath: str) -> list[str]:
    """relpath and every ancestor directory, deepest first.

    A Files-Excluded pattern naming a directory must exclude its whole subtree,
    so a path is tested against the pattern set as itself and as each of its
    parent directories (mk-origtargz removes the directory recursively).
    """
    parts = relpath.split("/")
    return ["/".join(parts[: i + 1]) for i in range(len(parts) - 1, -1, -1)]


def _relative_to(match_path: str, anchor: str) -> str | None:
    """match_path relative to a .gitignore at anchor, or None if out of scope.

    The root anchor ("") applies everywhere; a nested anchor applies only to its
    strict descendants (a dir's .gitignore does not judge the dir itself).
    """
    if not anchor:
        return match_path
    prefix = anchor + "/"
    if match_path.startswith(prefix):
        return match_path[len(prefix) :]
    return None


class IgnoreMatcher:
    """Answers whether a tree-relative path is excluded from the source."""

    def __init__(
        self, *, use_gitignore: bool, files_excluded: tuple[str, ...], root: str
    ) -> None:
        self.use_gitignore = use_gitignore and GitIgnoreSpec is not None
        self.root = root
        self._files_excluded = (
            dc.globs_to_re(files_excluded) if files_excluded else None
        )
        # (anchor dir, spec) registered shallow-first, so deepest-last wins.
        self._git_specs: list[tuple[str, GitIgnoreSpec]] = []
        if use_gitignore and GitIgnoreSpec is None:
            _warn_no_pathspec()
        if self.use_gitignore:
            self._load_info_exclude()

    def _load_info_exclude(self) -> None:
        """Seed the root-anchored, lowest-precedence .git/info/exclude (DP6)."""
        git = Path(self.root) / ".git"
        exclude = git / "info" / "exclude"
        if git.is_dir() and exclude.is_file():
            self.add_gitignore("", exclude.read_text(encoding="utf-8").splitlines())

    def add_gitignore(self, reldir: str, lines: list[str]) -> None:
        """Register a directory's .gitignore patterns, anchored at reldir."""
        if self.use_gitignore:
            # use_gitignore is False when GitIgnoreSpec is None, so it is set here.
            spec = GitIgnoreSpec.from_lines(lines)
            self._git_specs.append((reldir, spec))

    def is_ignored(self, relpath: str, *, is_dir: bool) -> bool:
        return self._is_files_excluded(relpath) or self._is_git_ignored(
            relpath, is_dir=is_dir
        )

    def _is_files_excluded(self, relpath: str) -> bool:
        if self._files_excluded is None:
            return False
        return any(self._files_excluded.fullmatch(path) for path in _ancestors(relpath))

    def _is_git_ignored(self, relpath: str, *, is_dir: bool) -> bool:
        # A directory pattern (foo/) only matches a trailing-slash path in git.
        match_path = relpath + "/" if is_dir else relpath
        ignored = False
        for anchor, spec in self._git_specs:
            rel = _relative_to(match_path, anchor)
            if rel is None:
                continue
            result = spec.check_file(rel)
            if result.include is not None:
                ignored = result.include
        return ignored
