"""
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", bisect=False)
    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.

    With ``bisect=True``, when the extreme value fails the plugin
    uses O(log n) binary search to find the pass/fail boundary
    instead of running every value linearly.  Items on the passing
    side are reported as IMPLIED PASS, items on the failing side
    as IMPLIED FAIL.

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 collections import deque
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_fail_count_key = pytest.StashKey[int]()
_imply_disabled_key = pytest.StashKey[bool]()
_imply_cycle_nids_key = pytest.StashKey[set]()
_imply_expected_order_key = pytest.StashKey[list]()
_bisect_state_key = pytest.StashKey[dict]()
_bisect_item_info_key = pytest.StashKey[dict]()

# User-property keys used to tag implied reports (public API, survives
# serialisation, visible in JUnit XML).
_IMPLIED_REASON_PROP = "pytest_imply_reason"
_IMPLIED_FAIL_REASON_PROP = "pytest_imply_fail_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 _bisect_probe_order(n: int) -> list[int]:
    """Return indices [0..n-1] in binary-search probe order.

    Index 0 (the extreme) comes first, then a BFS traversal of
    the implicit binary-search tree over [1..n-1].
    """
    if n <= 0:
        return []
    order = [0]
    if n == 1:
        return order
    queue: deque[tuple[int, int]] = deque([(1, n - 1)])
    while queue:
        lo, hi = queue.popleft()
        if lo > hi:
            continue
        mid = (lo + hi) // 2
        order.append(mid)
        queue.append((lo, mid - 1))
        queue.append((mid + 1, hi))
    return order


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:
            if tok not in config.stash[_imply_tokens_key]:
                config.stash[_imply_tokens_key][tok] = item.nodeid


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 _emit_implied_fail(item: Item, reason: str) -> None:
    """Emit synthetic FAILED reports (setup/teardown pass, call fails)."""
    for when in ("setup", "call", "teardown"):
        longrepr = None
        if when == "call":
            detail = f" {reason}" if reason else ""
            longrepr = f"IMPLIED FAIL{detail} — implied failure (monotonic bisect)"
        report = TestReport(
            nodeid=item.nodeid,
            location=item.location,
            keywords=dict(item.keywords),
            outcome="failed" if when == "call" else "passed",
            longrepr=longrepr,
            when=when,
            duration=0.0,
            user_properties=[(_IMPLIED_FAIL_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 _get_implied_fail_reason(report: TestReport) -> Optional[str]:
    """Extract the implied-fail reason from a report's user_properties."""
    for key, value in report.user_properties:
        if key == _IMPLIED_FAIL_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}"
            )
        bisect = marker.kwargs.get("bisect", True)
        if not isinstance(bisect, bool):
            errors.append(
                f"{item.nodeid}: @pytest.mark.monotonic() bisect must be a bool, got {bisect!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}) "
                f"— 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 _chain_edges(
    items: list[Item],
    in_edges: dict[str, set[str]],
    out_edges: dict[str, set[str]],
) -> None:
    """Add sequential dependency edges between consecutive items."""
    for i in range(len(items) - 1):
        a_nid = items[i].nodeid
        b_nid = items[i + 1].nodeid
        out_edges[a_nid].add(b_nid)
        in_edges[b_nid].add(a_nid)


def _init_bisect_group(
    config: Config,
    group_key: tuple,
    group_items: list[Item],
) -> None:
    """Initialise bisect state and reorder a group into probe order."""
    sorted_nids = [it.nodeid for it in group_items]
    config.stash[_bisect_state_key][group_key] = {
        "sorted_nids": sorted_nids,
        "lo": 0,
        "hi": len(sorted_nids) - 1,
        "boundary": None,
    }
    for idx, item in enumerate(group_items):
        config.stash[_bisect_item_info_key][item.nodeid] = {
            "group_key": group_key,
            "index": idx,
        }
    probe_order = _bisect_probe_order(len(group_items))
    group_items[:] = [group_items[i] for i in probe_order]


def _add_monotonic_edges(
    config: Config,
    mono_groups: dict[tuple, list[Item]],
    in_edges: dict[str, set[str]],
    out_edges: dict[str, set[str]],
) -> None:
    for group_key, group_items in mono_groups.items():
        marker = group_items[0].get_closest_marker("monotonic")
        direction = marker.kwargs.get("direction", "desc")
        reverse = direction == "desc"
        bisect = marker.kwargs.get("bisect", True)

        # Preserve the original parametrization order and apply direction as
        # simple reversal, not value sorting.  This avoids surprising effects
        # from non-monotonic value sequences (e.g. [1, 5, 3, 7, 2]).
        if reverse:
            group_items.reverse()

        if bisect and len(group_items) > 1:
            _init_bisect_group(config, group_key, group_items)

        _chain_edges(group_items, in_edges, out_edges)


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', bisect=True): parametrised "
        "implication — passing the extreme value implies all others. "
        "bisect=True (default) uses binary search to find the pass/fail boundary")
    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_fail_count_key] = 0
    config.stash[_imply_disabled_key] = False
    config.stash[_imply_cycle_nids_key] = set()
    config.stash[_bisect_state_key] = {}
    config.stash[_bisect_item_info_key] = {}


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_fail_count_key] = 0
    config.stash[_imply_disabled_key] = False
    config.stash[_imply_cycle_nids_key] = set()
    config.stash[_bisect_state_key] = {}
    config.stash[_bisect_item_info_key] = {}

    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


def _build_dependency_graph(config: Config, items: list[Item]) -> tuple[
    dict[str, set[str]], dict[str, set[str]],
    dict[str, int], dict[str, Item],
]:
    """Build the full dependency graph (token + monotonic edges)."""
    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(config, mono_groups, in_edges, out_edges)

    _warn_implied_fixtures(config, items)
    return in_edges, out_edges, nid_to_idx, nid_to_item


@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

    in_edges, out_edges, nid_to_idx, nid_to_item = _build_dependency_graph(
        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
    config.stash[_imply_expected_order_key] = [it.nodeid for it in sorted_items]


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

    for marker in item.iter_markers("implied_by_any"):
        for token in marker.args:
            provider = tokens.get(token)
            if provider:
                return f"by [{provider}]"

    for marker in item.iter_markers("implied_by_all"):
        providers = []
        for tok in marker.args:
            provider = tokens.get(tok)
            if not provider:
                break
            providers.append(provider)
        else:
            providers_sorted = sorted(dict.fromkeys(providers))
            return f"by [{', '.join(providers_sorted)}]"

    marker = item.get_closest_marker("monotonic")
    if marker:
        group_key = _monotonic_group_key(item, marker)
        if group_key is not None:
            # Bisect groups are handled separately in the protocol hook.
            bisect_info = config.stash[_bisect_item_info_key].get(item.nodeid)
            if bisect_info is None and config.stash[_monotonic_passed_key].get(group_key):
                return ""

    return None


def _bisect_reason(item: Item) -> str:
    """Build a human-readable reason string for a bisect verdict."""
    marker = item.get_closest_marker("monotonic")
    if marker is None:
        return ""
    param_name: str = marker.args[0]
    direction = marker.kwargs.get("direction", "desc")
    return f"monotonic bisect ({param_name}, {direction})"


def _get_bisect_verdict(item: Item, config: Config) -> Optional[str]:
    """Check whether a bisect group already knows this item's outcome.

    Returns:
        "pass"  — the item is in the known-passing range (implied pass)
        "fail"  — the item is in the known-failing range (implied fail)
        None    — not decided yet; must run the item (it's a probe)
    """
    bisect_info = config.stash[_bisect_item_info_key].get(item.nodeid)
    if bisect_info is None:
        return None
    bstate = config.stash[_bisect_state_key].get(bisect_info["group_key"])
    if bstate is None:
        return None
    return _bisect_verdict_for_index(bisect_info["index"], bstate)


def _bisect_verdict_for_index(idx: int, bstate: dict) -> Optional[str]:
    """Return "pass", "fail", or None for an index within a bisect group."""
    boundary = bstate["boundary"]
    if boundary is not None:
        return "fail" if idx <= boundary else "pass"
    if idx == 0:
        return None
    lo, hi = bstate["lo"], bstate["hi"]
    if idx < lo:
        return "fail"
    if idx > hi:
        return "pass"
    if idx == (lo + hi + 1) // 2:
        return None
    return None


def _check_reorder_once(config: Config, item: Item) -> None:
    """Warn once if another plugin reordered items after our topo sort."""
    expected = config.stash.get(_imply_expected_order_key, None)
    if expected is None:
        return
    config.stash[_imply_expected_order_key] = None
    actual = [it.nodeid for it in item.session.items]
    if actual == expected:
        return
    _builtin = {
        "imply", "python", "terminal", "main", "mark",
        "runner", "fixtures", "funcmanage", "cacheprovider",
        "legacypath", "lfplugin", "nfplugin", "stepwise",
        "skipping", "tmpdir", "capture", "logging",
        "debugging", "pastebin", "helpconfig", "nose",
        "assertion", "junitxml", "doctest", "monkeypatch",
        "recwarn", "unraisableexception",
        "threadexception", "faulthandler", "warnings",
    }
    suspects = [
        impl.plugin_name
        for impl in config.pluginmanager.hook
            .pytest_collection_modifyitems.get_hookimpls()
        if impl.plugin_name not in _builtin
    ]
    detail = f"  Suspect plugins: {', '.join(suspects)}." if suspects else ""
    warnings.warn(
        "pytest-imply: another plugin reordered tests after "
        "the dependency sort.  Implication may be less "
        f"effective.  Correctness is not affected.{detail}",
        PytestImplyWarning,
        stacklevel=2,
    )


@pytest.hookimpl(tryfirst=True)
def pytest_runtest_protocol(  # pylint: disable=unused-argument
    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

    _check_reorder_once(config, item)

    if (config.stash[_imply_disabled_key]
            or item.nodeid in config.stash[_imply_cycle_nids_key]):
        return None

    # Check bisect verdict first.
    bisect_verdict = _get_bisect_verdict(item, config)
    if bisect_verdict == "pass":
        reason = _bisect_reason(item)
        _emit_implied(item, reason)
        config.stash[_imply_count_key] += 1
        _propagate_tokens(item, config)
        return True
    if bisect_verdict == "fail":
        reason = _bisect_reason(item)
        _emit_implied_fail(item, reason)
        config.stash[_imply_fail_count_key] += 1
        return True

    implied_reason = _get_implied_reason_for_item(
        item, config.stash[_imply_tokens_key], 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


def _update_bisect_state(bstate: dict, idx: int, passed: bool) -> None:
    """Advance a bisect group's binary search after a probe result."""
    if idx == 0 and passed:
        bstate["boundary"] = -1
    elif idx == 0:
        pass  # search begins with initial lo/hi
    elif passed:
        bstate["hi"] = idx - 1
        if bstate["lo"] > bstate["hi"]:
            bstate["boundary"] = bstate["hi"]
        elif bstate["lo"] == bstate["hi"]:
            bstate["boundary"] = bstate["lo"]
    else:
        bstate["lo"] = idx
        if bstate["lo"] > bstate["hi"]:
            bstate["boundary"] = bstate["lo"] - 1
        elif bstate["lo"] == bstate["hi"]:
            bstate["boundary"] = bstate["lo"]


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: Item, call: pytest.CallInfo) -> None:
    """Record implication tokens and update bisect state."""
    outcome = yield
    report: TestReport = outcome.get_result()
    if report.when != "call":
        return

    config = item.config
    if call is None:
        return

    passed = report.passed

    if passed:
        for marker in item.iter_markers("implies"):
            for token in marker.args:
                if token not in config.stash[_imply_tokens_key]:
                    config.stash[_imply_tokens_key][token] = item.nodeid

    marker = item.get_closest_marker("monotonic")
    if marker:
        group_key = _monotonic_group_key(item, marker)
        if group_key is not None:
            if passed:
                config.stash[_monotonic_passed_key][group_key] = True
            bisect_info = config.stash[_bisect_item_info_key].get(
                item.nodeid,
            )
            if bisect_info is not None:
                bstate = config.stash[_bisect_state_key].get(group_key)
                if bstate is not None and bstate["boundary"] is None:
                    _update_bisect_state(
                        bstate, bisect_info["index"], passed,
                    )


def pytest_report_teststatus(report: TestReport, config: Config) -> Optional[tuple]:
    """Render IMPLIED PASS / IMPLIED FAIL with distinct status words."""
    if config is None or report.when != "call":
        return None

    implied_fail = _get_implied_fail_reason(report)
    if implied_fail is not None:
        label = "IMPLIED FAIL" if not implied_fail else f"IMPLIED FAIL {implied_fail}"
        return "failed", "I", (label, {"red": True})

    implied = _get_implied_reason(report)
    if implied is not None:
        label = "IMPLIED PASS" if not implied else f"IMPLIED PASS {implied}"
        return "passed", "i", (label, {"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)
    fail_count = config.stash.get(_imply_fail_count_key, 0)
    if count:
        plural = "s" if count != 1 else ""
        terminalreporter.write_sep("=", f"{count} test{plural} implied pass")
    if fail_count:
        plural = "s" if fail_count != 1 else ""
        terminalreporter.write_sep("=", f"{fail_count} test{plural} implied fail")
