"""AC-4 — components MUST NOT call ``time.monotonic_ns`` / ``time.time_ns`` / ``time.sleep``.

Enforces Invariant 2 of the replay contract
(``_docs/02_document/contracts/replay/replay_protocol.md``): every
time-driven code path in a C* component consumes an injected
:class:`Clock` instead. Replay determinism (R-DEMO-4) collapses the
moment a component reaches into the stdlib ``time`` module directly,
so this guard runs on every PR touching ``src/gps_denied_onboard/components/``.

The scan is AST-based — docstrings and comments mentioning the forbidden
APIs do NOT trip it; only call sites and attribute references do.
"""

from __future__ import annotations

import ast
from pathlib import Path

import pytest

_FORBIDDEN_ATTRS: frozenset[str] = frozenset(
    {"monotonic_ns", "time_ns", "sleep"}
)

_COMPONENTS_ROOT: Path = (
    Path(__file__).parent.parent.parent
    / "src"
    / "gps_denied_onboard"
    / "components"
)


def _python_files_under(root: Path) -> list[Path]:
    return sorted(p for p in root.rglob("*.py") if p.is_file())


def _find_direct_time_references(source: str) -> list[tuple[int, str]]:
    """Return ``(lineno, attribute_name)`` for every direct ``time.X`` ref.

    Only flags ``ast.Attribute(value=ast.Name(id='time'), attr=<x>)``
    where ``<x>`` is one of the forbidden names. Aliased imports
    (``import time as t`` → ``t.monotonic_ns()``) are intentionally NOT
    caught — the component code convention is to avoid such aliases, and
    catching them would require flow-sensitive analysis.
    """
    hits: list[tuple[int, str]] = []
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if not isinstance(node, ast.Attribute):
            continue
        if not isinstance(node.value, ast.Name):
            continue
        if node.value.id != "time":
            continue
        if node.attr in _FORBIDDEN_ATTRS:
            hits.append((node.lineno, f"time.{node.attr}"))
    return hits


def test_components_have_no_direct_time_references() -> None:
    # Arrange
    files = _python_files_under(_COMPONENTS_ROOT)
    assert files, f"AST scan found no .py files under {_COMPONENTS_ROOT}"
    offences: list[str] = []
    # Act
    for file in files:
        source = file.read_text(encoding="utf-8")
        for lineno, attr in _find_direct_time_references(source):
            rel = file.relative_to(_COMPONENTS_ROOT.parent.parent.parent.parent)
            offences.append(f"{rel}:{lineno} — {attr}")
    # Assert
    assert not offences, (
        "Invariant 2 violation: direct stdlib-`time` references found in "
        "`src/gps_denied_onboard/components/**/*.py`. Consume an injected "
        "`Clock` (`gps_denied_onboard.clock`) instead.\n"
        + "\n".join(offences)
    )


def test_scan_helper_detects_known_forbidden_pattern() -> None:
    # Arrange — self-check the AST helper so a stale scan can't silently pass.
    source = "import time\ndef f() -> int:\n    return time.monotonic_ns()\n"
    # Act
    hits = _find_direct_time_references(source)
    # Assert
    assert hits == [(3, "time.monotonic_ns")]


def test_scan_helper_ignores_docstring_mentions() -> None:
    # Arrange — docstrings naming the forbidden API must not trip the scan.
    source = '"""This module talks about time.monotonic_ns in prose only."""\n'
    # Act
    hits = _find_direct_time_references(source)
    # Assert
    assert hits == []


@pytest.mark.parametrize("forbidden", sorted(_FORBIDDEN_ATTRS))
def test_scan_helper_detects_each_forbidden_attr(forbidden: str) -> None:
    # Arrange
    source = f"import time\ntime.{forbidden}()\n"
    # Act
    hits = _find_direct_time_references(source)
    # Assert
    assert hits == [(2, f"time.{forbidden}")]
