"""
pytest-imply — skip tests implied by stronger ones.

Markers
-------
implies("token", ...)
    Record named tokens when this test passes.

implied_by("token")
    If the token was already recorded (its implier passed),
    emit a synthetic PASSED without running the test.  Tokens from
    the implied test's own ``implies`` marker are propagated
    transitively.

implied_by_any("token", ...)
    Like ``implied_by`` but accepts multiple tokens.  The test is
    implied when ANY of the listed tokens has been recorded (OR).

implied_by_all("token", ...)
    Like ``implied_by`` but accepts multiple tokens.  The test is
    implied only when ALL listed tokens have been recorded (AND).

monotonic("param", direction="desc")
    Parametrised shorthand.  For ``direction="desc"`` (default), the
    highest value of *param* runs first; if it passes, all lower
    values are implied.  For ``direction="asc"``, the lowest runs
    first.

imply_suppress
    Suppress the warning about non-function-scoped fixtures for
    this test.  Use when the session/module/class-scoped fixtures
    are known to be safe to skip (e.g. autouse directory creation).

CLI
---
--no-imply
    Run every test, ignoring implication markers.  Use for exhaustive
    nightly or release-gate runs.

How it works
------------
1. ``pytest_collection_modifyitems`` (trylast) validates marker
   arguments, then builds a dependency graph (implies→implied_by /
   implied_by_any / implied_by_all edges, monotonic chains) and
   topologically sorts the items using Kahn's algorithm.  Tie-breaking
   preserves original collection order.  Skipped entirely when
   ``--no-imply`` is active.

2. ``pytest_runtest_protocol`` (tryfirst) checks whether the
   current item is implied by a previously-passed test.  If so, it
   emits synthetic ``TestReport`` objects for setup/call/teardown
   and returns ``True`` to tell pytest the item is handled.
   Tokens from the implied test's ``implies`` marker are propagated.

3. ``pytest_runtest_makereport`` (hookwrapper) records tokens and
   monotonic passes after a test's call phase succeeds.

4. ``pytest_report_teststatus`` renders IMPLIED tests with a
   distinct status word and ``i`` short letter.

5. ``pytest_terminal_summary`` prints the count of implied tests.
"""

from __future__ import annotations

import heapq
import warnings
from typing import TYPE_CHECKING

import pytest
from _pytest.reports import TestReport

if TYPE_CHECKING:
    from typing import Optional

    from _pytest.config import Config
    from _pytest.config.argparsing import Parser
    from _pytest.main import Session
    from _pytest.nodes import Item
    from _pytest.terminal import TerminalReporter
    from pytest import Mark


# -----------------------------------------------------------------------
# Warning class
# -----------------------------------------------------------------------

class PytestImplyWarning(pytest.PytestWarning):
    """Warning emitted by pytest-imply for non-fatal issues."""

    __module__ = "pytest_imply"


# -----------------------------------------------------------------------
# Stash keys (plugin state stored via pytest's public API)
# -----------------------------------------------------------------------

_imply_tokens_key = pytest.StashKey[dict]()
_monotonic_passed_key = pytest.StashKey[dict]()
_imply_count_key = pytest.StashKey[int]()
_imply_disabled_key = pytest.StashKey[bool]()
_imply_cycle_nids_key = pytest.StashKey[set]()

# User-property key used to tag implied reports (public API, survives
# serialisation, visible in JUnit XML).
_IMPLIED_REASON_PROP = "pytest_imply_reason"


# -----------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------

def _monotonic_group_key(item: Item, marker: Mark) -> Optional[tuple]:
    """Grouping key for a monotonic-marked item.

    Items in the same group share the same test function and all
    parameter values EXCEPT the monotonic axis.
    """
    param_name: str = marker.args[0]
    if not hasattr(item, "callspec") or param_name not in item.callspec.params:
        return None
    other = tuple(sorted(
        (k, v) for k, v in item.callspec.params.items()
        if k != param_name
    ))
    return (item.parent.nodeid, item.originalname, other)


def _imply_active(config: Config) -> bool:
    """Return True when implication logic is enabled."""
    return (not config.getoption("--no-imply", default=False)
            and config.getini("imply_enabled")
            and not config.stash[_imply_disabled_key])


def _propagate_tokens(item: Item, config: Config) -> None:
    """Propagate implies tokens from an implied (short-circuited) test."""
    for marker in item.iter_markers("implies"):
        for tok in marker.args:
            config.stash[_imply_tokens_key][tok] = True


def _emit_implied(item: Item, reason: str) -> None:
    """Emit synthetic PASSED reports for all three phases."""
    for when in ("setup", "call", "teardown"):
        report = TestReport(
            nodeid=item.nodeid,
            location=item.location,
            keywords=dict(item.keywords),
            outcome="passed",
            longrepr=None,
            when=when,
            duration=0.0,
            user_properties=[(_IMPLIED_REASON_PROP, reason)],
        )
        item.ihook.pytest_runtest_logreport(report=report)


def _get_implied_reason(report: TestReport) -> Optional[str]:
    """Extract the implied reason from a report's user_properties."""
    for key, value in report.user_properties:
        if key == _IMPLIED_REASON_PROP:
            return value
    return None


def _validate_string_token_marker(
    item: Item,
    marker_name: str,
    min_args: int = 0,
    exact_args: Optional[int] = None,
) -> list[str]:
    errors: list[str] = []
    for marker in item.iter_markers(marker_name):
        for arg in marker.args:
            if not isinstance(arg, str):
                errors.append(
                    f"{item.nodeid}: @pytest.mark.{marker_name}() token arguments "
                    f"must be strings, got {arg!r}"
                )
        if exact_args is not None and len(marker.args) != exact_args:
            count_word = "one" if exact_args == 1 else str(exact_args)
            errors.append(
                f"{item.nodeid}: @pytest.mark.{marker_name}() requires exactly "
                f"{count_word} token argument" + ("s" if exact_args != 1 else "")
                + "; use implied_by_any / implied_by_all for multiple"
            )
        elif len(marker.args) < min_args:
            count_word = "one" if min_args == 1 else "two" if min_args == 2 else str(min_args)
            errors.append(
                f"{item.nodeid}: @pytest.mark.{marker_name}() requires at least "
                f"{count_word} token argument" + ("s" if min_args != 1 else "")
                + "; use implied_by for a single token"
            )
    return errors


def _validate_monotonic_marker(item: Item) -> list[str]:
    errors: list[str] = []
    for marker in item.iter_markers("monotonic"):
        if not marker.args:
            errors.append(
                f"{item.nodeid}: @pytest.mark.monotonic() requires a parameter "
                "name argument"
            )
            continue

        param_name = marker.args[0]
        direction = marker.kwargs.get("direction", "desc")
        if direction not in ("asc", "desc"):
            errors.append(
                f"{item.nodeid}: @pytest.mark.monotonic() direction must "
                f"be 'asc' or 'desc', got {direction!r}"
            )
        if not hasattr(item, "callspec"):
            errors.append(
                f"{item.nodeid}: @pytest.mark.monotonic() requires a "
                "parametrized test"
            )
        elif param_name not in item.callspec.params:
            errors.append(
                f"{item.nodeid}: @pytest.mark.monotonic({param_name!r}) — parameter "
                "not found in parametrize"
            )
    return errors


def _validate_markers(items: list[Item]) -> None:
    """Validate marker arguments during collection; raise on errors."""
    errors: list[str] = []
    for item in items:
        errors.extend(_validate_string_token_marker(item, "implies", min_args=1))
        errors.extend(_validate_string_token_marker(item, "implied_by", exact_args=1))
        errors.extend(_validate_string_token_marker(item, "implied_by_any", min_args=2))
        errors.extend(_validate_string_token_marker(item, "implied_by_all", min_args=2))
        errors.extend(_validate_monotonic_marker(item))

    if errors:
        raise pytest.UsageError("\n".join(errors))


def _collect_token_edges(items: list[Item]) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
    token_providers: dict[str, list[str]] = {}
    token_consumers: dict[str, list[str]] = {}
    for item in items:
        for m in item.iter_markers("implies"):
            for tok in m.args:
                token_providers.setdefault(tok, []).append(item.nodeid)
        for marker_name in ("implied_by", "implied_by_any", "implied_by_all"):
            for m in item.iter_markers(marker_name):
                for tok in m.args:
                    token_consumers.setdefault(tok, []).append(item.nodeid)
    return token_providers, token_consumers


def _warn_self_referential(items: list[Item]) -> None:
    for item in items:
        provides = {tok for m in item.iter_markers("implies") for tok in m.args}
        consumes = {
            tok
            for marker_name in ("implied_by", "implied_by_any", "implied_by_all")
            for m in item.iter_markers(marker_name)
            for tok in m.args
        }
        overlap = provides & consumes
        if overlap:
            token_list = ", ".join(repr(t) for t in sorted(overlap))
            warnings.warn(
                f"pytest-imply: {item.nodeid} both provides and consumes "
                f"token(s) {token_list} — this is likely a mistake",
                PytestImplyWarning,
                stacklevel=1,
            )


def _warn_orphan_tokens(
    token_providers: dict[str, list[str]],
    token_consumers: dict[str, list[str]],
) -> None:
    orphan_tokens = set(token_consumers) - set(token_providers)
    for tok in sorted(orphan_tokens):
        consumer_nids = token_consumers[tok]
        consumer_list = ", ".join(consumer_nids)
        warnings.warn(
            f"pytest-imply: token {tok!r} is referenced by implied_by / "
            f"implied_by_any / implied_by_all but no test provides it "
            f"via implies({tok!r}).  Tests: {consumer_list}",
            PytestImplyWarning,
            stacklevel=1,
        )


def _apply_token_edges(
    token_providers: dict[str, list[str]],
    token_consumers: dict[str, list[str]],
    in_edges: dict[str, set[str]],
    out_edges: dict[str, set[str]],
) -> None:
    for tok, consumers in token_consumers.items():
        for provider_nid in token_providers.get(tok, []):
            for consumer_nid in consumers:
                if provider_nid != consumer_nid:
                    out_edges[provider_nid].add(consumer_nid)
                    in_edges[consumer_nid].add(provider_nid)


def _collect_monotonic_groups(items: list[Item]) -> dict[tuple, list[Item]]:
    mono_groups: dict[tuple, list[Item]] = {}
    for item in items:
        marker = item.get_closest_marker("monotonic")
        if marker is None:
            continue
        group_key = _monotonic_group_key(item, marker)
        if group_key is None:
            continue
        mono_groups.setdefault(group_key, []).append(item)
    return mono_groups


def _add_monotonic_edges(
    mono_groups: dict[tuple, list[Item]],
    in_edges: dict[str, set[str]],
    out_edges: dict[str, set[str]],
) -> None:
    for _, group_items in mono_groups.items():
        marker = group_items[0].get_closest_marker("monotonic")
        param_name = marker.args[0]
        reverse = marker.kwargs.get("direction", "desc") == "desc"
        try:
            group_items.sort(
                key=lambda it, p=param_name: it.callspec.params[p],
                reverse=reverse,
            )
        except TypeError as exc:
            raise pytest.UsageError(
                f"monotonic({param_name!r}): parameter values must be orderable: {exc}"
            ) from None
        for i in range(len(group_items) - 1):
            a_nid = group_items[i].nodeid
            b_nid = group_items[i + 1].nodeid
            out_edges[a_nid].add(b_nid)
            in_edges[b_nid].add(a_nid)


def _warn_implied_fixtures(config: Config, items: list[Item]) -> None:
    _builtin_safe = {"request", "tmp_path_factory", "tmp_path"}
    _user_safe = set(config.getini("imply_ignore_fixtures"))
    _safe_fixtures = _builtin_safe | _user_safe
    for item in items:
        if item.get_closest_marker("imply_suppress") is not None:
            continue
        has_consumer_marker = (
            any(item.iter_markers("implied_by"))
            or any(item.iter_markers("implied_by_any"))
            or any(item.iter_markers("implied_by_all"))
            or item.get_closest_marker("monotonic") is not None
        )
        if not has_consumer_marker:
            continue
        fixtureinfo = getattr(item, "_fixtureinfo", None)
        if fixtureinfo is None:
            continue
        name2fixturedefs = fixtureinfo.name2fixturedefs
        for fixturedef_list in name2fixturedefs.values():
            for fd in fixturedef_list:
                if fd.scope != "function" and fd.argname not in _safe_fixtures:
                    warnings.warn(
                        f"pytest-imply: {item.nodeid} may be implied but uses "
                        f"{fd.scope}-scoped fixture {fd.argname!r} — its side effects "
                        "will be skipped",
                        PytestImplyWarning,
                        stacklevel=1,
                    )
                    break


def _kahn_sort_items(
    items: list[Item],
    in_edges: dict[str, set[str]],
    out_edges: dict[str, set[str]],
    nid_to_idx: dict[str, int],
    nid_to_item: dict[str, Item],
) -> tuple[list[Item], set[str]]:
    ready: list[tuple[int, Item]] = [
        (nid_to_idx[it.nodeid], it)
        for it in items
        if not in_edges[it.nodeid]
    ]
    heapq.heapify(ready)

    sorted_items: list[Item] = []
    while ready:
        _pri, item = heapq.heappop(ready)
        sorted_items.append(item)
        for succ_nid in out_edges[item.nodeid]:
            in_edges[succ_nid].discard(item.nodeid)
            if not in_edges[succ_nid]:
                succ = nid_to_item[succ_nid]
                heapq.heappush(ready, (nid_to_idx[succ_nid], succ))

    if len(sorted_items) < len(items):
        seen = {it.nodeid for it in sorted_items}
        cycle_items = [it for it in items if it.nodeid not in seen]
        cycle_nids = {it.nodeid for it in cycle_items}
        sorted_items.extend(cycle_items)
        return sorted_items, cycle_nids

    return sorted_items, set()


# -----------------------------------------------------------------------
# Hooks
# -----------------------------------------------------------------------

def pytest_configure(config: Config) -> None:
    """Register markers and initialise plugin stash keys."""
    config.addinivalue_line("markers",
        "implies(*tokens): record named tokens when this test passes")
    config.addinivalue_line("markers",
        "implied_by(token): skip (synthetic PASSED) if the token "
        "was already recorded")
    config.addinivalue_line("markers",
        "implied_by_any(*tokens): skip if ANY of the listed tokens "
        "was already recorded (OR semantics)")
    config.addinivalue_line("markers",
        "implied_by_all(*tokens): skip only if ALL listed tokens "
        "have been recorded (AND semantics)")
    config.addinivalue_line("markers",
        "monotonic(param, direction='desc'): parametrised implication "
        "— passing the extreme value implies all others")
    config.addinivalue_line("markers",
        "imply_suppress: suppress pytest-imply warnings about "
        "non-function-scoped fixtures for this test")
    config.stash[_imply_tokens_key] = {}
    config.stash[_monotonic_passed_key] = {}
    config.stash[_imply_count_key] = 0
    config.stash[_imply_disabled_key] = False
    config.stash[_imply_cycle_nids_key] = set()


def pytest_addoption(parser: Parser) -> None:
    """Register the ``--no-imply`` CLI flag and ini options."""
    group = parser.getgroup("imply", "Test implication")
    group.addoption(
        "--no-imply",
        action="store_true",
        default=False,
        help="Disable test implication: run every test even when "
             "implies / implied_by / monotonic markers say it can "
             "be skipped.  Use for exhaustive nightly runs.",
    )
    parser.addini(
        "imply_enabled",
        type="bool",
        default=True,
        help="Enable/disable test implication (default: true). "
             "Set to false to disable without --no-imply.",
    )
    parser.addini(
        "imply_ignore_fixtures",
        type="args",
        default=[],
        help="Fixture names to ignore when warning about "
             "non-function-scoped fixtures on implied tests.",
    )


def pytest_sessionstart(session: Session) -> None:
    """Reset state at the start of each session and warn about xdist."""
    config = session.config
    config.stash[_imply_tokens_key] = {}
    config.stash[_monotonic_passed_key] = {}
    config.stash[_imply_count_key] = 0
    config.stash[_imply_disabled_key] = False
    config.stash[_imply_cycle_nids_key] = set()

    if config.pluginmanager.has_plugin("xdist"):
        worker_count = getattr(config.option, "numprocesses", None)
        if worker_count is not None and worker_count != 0:
            warnings.warn(
                "pytest-imply does not support parallel execution "
                "with pytest-xdist.  Implication logic automatically "
                "disabled.  Use -p no:imply to suppress this warning.",
                PytestImplyWarning,
                stacklevel=1,
            )
            config.stash[_imply_disabled_key] = True


@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(config: Config, items: list[Item]) -> None:
    """Build a dependency graph and topologically sort items."""
    _validate_markers(items)

    if not _imply_active(config):
        return

    nid_to_idx: dict[str, int] = {it.nodeid: i for i, it in enumerate(items)}
    nid_to_item: dict[str, Item] = {it.nodeid: it for it in items}
    in_edges: dict[str, set[str]] = {it.nodeid: set() for it in items}
    out_edges: dict[str, set[str]] = {it.nodeid: set() for it in items}

    token_providers, token_consumers = _collect_token_edges(items)
    _warn_self_referential(items)
    _warn_orphan_tokens(token_providers, token_consumers)
    _apply_token_edges(token_providers, token_consumers, in_edges, out_edges)

    mono_groups = _collect_monotonic_groups(items)
    _add_monotonic_edges(mono_groups, in_edges, out_edges)

    _warn_implied_fixtures(config, items)

    sorted_items, cycle_nids = _kahn_sort_items(
        items, in_edges, out_edges, nid_to_idx, nid_to_item,
    )

    config.stash[_imply_cycle_nids_key] = cycle_nids
    if cycle_nids:
        cycle_list = ", ".join(sorted(cycle_nids))
        warnings.warn(
            f"pytest-imply: dependency cycle detected among {len(cycle_nids)} "
            f"test(s); implication disabled for: {cycle_list}",
            PytestImplyWarning,
            stacklevel=1,
        )

    items[:] = sorted_items


def _get_implied_reason_for_item(
    item: Item,
    tokens: dict[str, bool],
    config: Config,
) -> Optional[str]:
    for marker in item.iter_markers("implied_by"):
        token = marker.args[0]
        if tokens.get(token):
            return token

    for marker in item.iter_markers("implied_by_any"):
        for token in marker.args:
            if tokens.get(token):
                return token

    for marker in item.iter_markers("implied_by_all"):
        if all(tokens.get(tok) for tok in marker.args):
            return " & ".join(marker.args)

    marker = item.get_closest_marker("monotonic")
    if marker:
        group_key = _monotonic_group_key(item, marker)
        if group_key is not None and config.stash[_monotonic_passed_key].get(group_key):
            param_name: str = marker.args[0]
            direction = marker.kwargs.get("direction", "desc")
            adjective = "higher" if direction == "desc" else "lower"
            return f"{adjective} {param_name} passed"

    return None


@pytest.hookimpl(tryfirst=True)
def pytest_runtest_protocol(item: Item, nextitem: Optional[Item]) -> Optional[bool]:
    """Short-circuit implied tests with a synthetic PASSED result."""
    config = item.config
    if not _imply_active(config):
        return None

    if nextitem is None:
        pass

    # Never imply tests involved in a dependency cycle.
    if item.nodeid in config.stash[_imply_cycle_nids_key]:
        return None

    tokens = config.stash[_imply_tokens_key]
    implied_reason = _get_implied_reason_for_item(item, tokens, config)

    if implied_reason is not None:
        _emit_implied(item, implied_reason)
        config.stash[_imply_count_key] += 1
        _propagate_tokens(item, config)
        return True

    return None


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: Item, call: pytest.CallInfo) -> None:
    """Record implication tokens after a test passes."""
    outcome = yield
    report: TestReport = outcome.get_result()
    if report.when != "call" or not report.passed:
        return

    config = item.config
    if call is None:
        return
    for marker in item.iter_markers("implies"):
        for token in marker.args:
            config.stash[_imply_tokens_key][token] = True

    marker = item.get_closest_marker("monotonic")
    if marker:
        group_key = _monotonic_group_key(item, marker)
        if group_key is not None:
            config.stash[_monotonic_passed_key][group_key] = True


def pytest_report_teststatus(report: TestReport, config: Config) -> Optional[tuple]:
    """Render IMPLIED tests with a distinct status word."""
    # config is unused in this implementation but required by pytest hook.
    if config is None:
        return None

    if report.when != "call":
        return None
    implied = _get_implied_reason(report)
    if implied:
        return "passed", "i", (f"IMPLIED ({implied})", {"cyan": True})
    return None


def pytest_terminal_summary(
    terminalreporter: TerminalReporter,
    exitstatus: int,
    config: Config,
) -> None:
    """Print a summary line showing how many tests were implied."""
    # exitstatus is unused in this implementation but required by pubspec.
    if exitstatus is None:
        return

    count = config.stash.get(_imply_count_key, 0)
    if count:
        plural = "s" if count != 1 else ""
        terminalreporter.write_sep("=", f"{count} test{plural} implied")
