"""Lint that pins ``PROJECT_ROOT`` to a single canonical source.

Three rules are enforced:

1. Every Python file in the repo (``*.py``) MUST NOT compute the
   project root locally. The forbidden patterns are:

   * a top-level assignment that climbs ``parents[N]`` from
     ``__file__`` into a ``*_ROOT``-style variable,
   * a bare ``Path(__file__).…parents`` chain (no ``[N]`` index)
     used to iterate upward in search of the root,
   * any chain of ``..`` segments (string literals or ``os.pardir``
     references) used to walk up to the root,
   * a function such as ``repo_root()`` / ``project_root()`` (or
     their underscore-prefixed ``_repo_root`` / ``_project_root``
     siblings) that searches upward for ``pyproject.toml``.

   The single legitimate definition site is the package's own
   ``__init__.py``. Consumers MUST import ``PROJECT_ROOT`` from there
   (relatively as ``from . import PROJECT_ROOT`` or absolutely as
   ``from <pkg> import PROJECT_ROOT``) so a future move of the file
   does not silently break a hard-coded ``parents[N]`` index.

2. ``PROJECT_ROOT`` itself MUST only be assigned inside an
   ``__init__.py``. A ``PROJECT_ROOT = …`` line anywhere else (test
   modules, ``__main__.py`` shims, helper modules, …) fails the lint
   even when the right-hand side does not climb ``parents[N]`` —
   re-binding the constant locally defeats the single-source guarantee
   rule 1 was added to enforce.

3. Every ``__init__.py`` that DOES define ``PROJECT_ROOT`` MUST point
   at a directory containing ``pyproject.toml``. A wrong
   ``parents[N]`` index turns the constant into a silent footgun;
   the lint catches it the moment the index drifts off the repo root.

Suppression
-----------

Bootstrap files that prepend the repo root to ``sys.path`` before any
package import resolves (the ``__main__`` shims under ``cli/``) MUST
mark the offending line with ``# nocheck: project-root-import`` and
explain why the local computation is unavoidable. The same applies to
standalone scripts under ``roles/<role>/files/`` that have no
package container to import from. See
[suppression.md](../../../docs/contributing/actions/testing/suppression.md).
"""

from __future__ import annotations

import re
import unittest
from pathlib import Path

from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import iter_non_ignored_files, read_text

SUPPRESS_RULE: str = "project-root-import"


# with `# nocheck: project-root-import` and document why.
_FORBIDDEN_PATTERNS: tuple[tuple[str, re.Pattern[str], str], ...] = (
    (
        "`parents[N]` index expression",
        re.compile(r"\.parents\[\s*\d+\s*\]"),
        "stripped",
    ),
    (
        "chained `Path(__file__)…parent.parent.…` upward walk",
        re.compile(
            r"Path\(\s*__file__\s*\)(?:\s*\.\s*[A-Za-z_]\w*\([^)]*\))*"
            r"(?:\s*\.\s*parent\b){2,}"
        ),
        "stripped",
    ),
    (
        "`Path(__file__).…parents` upward iteration",
        re.compile(
            r"Path\(\s*__file__\s*\)(?:\s*\.\s*[A-Za-z_]\w*\([^)]*\))*"
            r"\s*\.\s*parents\b(?!\s*\[)"
        ),
        "stripped",
    ),
    (
        "`os.pardir` reference",
        re.compile(r"\bos\.pardir\b"),
        "stripped",
    ),
    (
        # nocheck: project-root-import  catalog entry mentions the literal pattern by design
        '".." path segment in a path-construction context',
        re.compile(r"""["']\.\.(?:/+\.\.)*/?["']"""),
        "no_docs",
    ),
    (
        "function searching upward for pyproject.toml",
        re.compile(r"^\s*def\s+_*(?:repo_root|project_root)\s*\("),
        "stripped",
    ),
)

_PROJECT_ROOT_ASSIGN_RE = re.compile(r"^\s*PROJECT_ROOT(?:\s*:\s*[^=]+?)?\s*=(?!=)")

_INIT_PROJECT_ROOT_RE = re.compile(
    r"^\s*PROJECT_ROOT(?:\s*:\s*[A-Za-z_][\w\[\], ]*)?\s*=\s*"
    r"Path\(\s*__file__\s*\)\s*\.resolve\(\)\.parents\[\s*(\d+)\s*\]"
)


def _is_init_file(path: Path) -> bool:
    return path.name == "__init__.py"


def _strip_strings_and_comments(text: str) -> str:
    """Return *text* with every string literal and `#` comment replaced
    by whitespace that preserves line numbers and column offsets.

    The forbidden patterns must only fire on real code, never on
    documentation that mentions the patterns by name (the catalog
    rule definitions in this very file, the dictionary docstring of
    `utils/cache/__init__.py`, etc.).
    """
    return _strip(text, keep_inline_strings=False)


def _strip_docs_and_comments(text: str) -> str:
    """Like :func:`_strip_strings_and_comments`, but preserve regular
    single-line ``"…"`` and ``'…'`` literals.

    Used by the ``".."`` rule, which deliberately scans the *content*
    of path strings: with the inline strings stripped (the default
    behaviour above) the regex would never match a real
    ``Path(… / ".." / …)`` callsite, while a docstring or
    ``# comment`` mentioning ``..`` should still be ignored.
    """
    return _strip(text, keep_inline_strings=True)


def _strip(text: str, *, keep_inline_strings: bool) -> str:
    out: list[str] = []
    i = 0
    n = len(text)
    while i < n:
        ch = text[i]
        if text[i : i + 3] in ('"""', "'''"):
            quote = text[i : i + 3]
            end = text.find(quote, i + 3)
            if end == -1:
                out.append("\n" * text[i:].count("\n"))
                break
            block = text[i : end + 3]
            out.append("".join("\n" if c == "\n" else " " for c in block))
            i = end + 3
            continue
        if ch in ('"', "'"):
            quote = ch
            j = i + 1
            while j < n and text[j] != quote:
                if text[j] == "\\" and j + 1 < n:
                    j += 2
                    continue
                if text[j] == "\n":
                    break
                j += 1
            block = text[i : j + 1]
            if keep_inline_strings:
                out.append(block)
            else:
                out.append("".join("\n" if c == "\n" else " " for c in block))
            i = j + 1
            continue
        if ch == "#":
            j = text.find("\n", i)
            if j == -1:
                out.append(" " * (n - i))
                break
            out.append(" " * (j - i))
            i = j
            continue
        out.append(ch)
        i += 1
    return "".join(out)


def _scan_file(path: Path) -> list[str]:
    """Return human-readable failure descriptions for *path*.

    Empty list means the file is clean. Suppression markers are
    consulted on the offending line and the line directly above
    (`same-or-above` semantics matching the rest of the suppression
    catalog).
    """
    try:
        text = read_text(str(path))
    except Exception:
        return []

    raw_lines = text.splitlines()
    code_lines = _strip_strings_and_comments(text).splitlines()
    no_docs_lines = _strip_docs_and_comments(text).splitlines()
    failures: list[str] = []

    is_init = _is_init_file(path)

    for lineno in range(1, len(raw_lines) + 1):
        stripped = code_lines[lineno - 1] if lineno - 1 < len(code_lines) else ""
        no_docs = no_docs_lines[lineno - 1] if lineno - 1 < len(no_docs_lines) else ""
        for label, pattern, scan_mode in _FORBIDDEN_PATTERNS:
            target = stripped if scan_mode == "stripped" else no_docs
            if not pattern.search(target):
                continue
            if is_init and _INIT_PROJECT_ROOT_RE.match(stripped):
                continue
            if is_suppressed_at(raw_lines, lineno, SUPPRESS_RULE, mode="same-or-above"):
                continue
            failures.append(f"{path}:{lineno}: {label}")

        if (
            not is_init
            and _PROJECT_ROOT_ASSIGN_RE.match(stripped)
            and not is_suppressed_at(
                raw_lines, lineno, SUPPRESS_RULE, mode="same-or-above"
            )
        ):
            failures.append(
                f"{path}:{lineno}: `PROJECT_ROOT` assignment outside "
                f"`__init__.py` (import via `from . import PROJECT_ROOT` "
                f"or `from <pkg> import PROJECT_ROOT` instead)"
            )

    return failures


def _check_init_project_root(path: Path) -> list[str]:
    """If *path* is an ``__init__.py`` that defines ``PROJECT_ROOT`` at
    top level via ``Path(__file__).resolve().parents[N]``, validate
    that the resolved path actually carries ``pyproject.toml``."""
    if not _is_init_file(path):
        return []
    try:
        text = read_text(str(path))
    except Exception:
        return []

    failures: list[str] = []
    for lineno, line in enumerate(text.splitlines(), start=1):
        m = _INIT_PROJECT_ROOT_RE.match(line)
        if not m:
            continue
        depth = int(m.group(1))
        try:
            resolved = path.resolve().parents[depth]
        except IndexError:
            failures.append(
                f"{path}:{lineno}: PROJECT_ROOT parents[{depth}] is out of "
                f"range for this file's depth."
            )
            continue
        if not (resolved / "pyproject.toml").is_file():
            failures.append(
                f"{path}:{lineno}: PROJECT_ROOT resolves to {resolved}, "
                f"which does not contain pyproject.toml; fix the "
                f"parents[{depth}] index."
            )
    return failures


class TestProjectRootImport(unittest.TestCase):
    def test_no_local_project_root_computation(self):
        offenders: list[str] = []
        for path_str in iter_non_ignored_files(extensions=(".py",)):
            path = Path(path_str)
            offenders.extend(_scan_file(path))
            offenders.extend(_check_init_project_root(path))

        if offenders:
            self.fail(
                f"{len(offenders)} project-root violation(s):\n"
                + "\n".join(f"  - {o}" for o in offenders)
            )


if __name__ == "__main__":  # pragma: no cover
    unittest.main()
