"""Unit tests for :class:`decnet.ttp.impl._rule_index.RuleIndex`.

The dispatch index was extracted from :class:`RuleEngine` so the four
per-source lifters (E.3.9–E.3.13) can reuse the install / evict /
state-restamp atomic-swap protocol. These tests pin the contract
independently of the engine — a future regression in the engine
shouldn't be the only signal that the shared helper broke.
"""
from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator
from datetime import datetime, timedelta, timezone
from typing import Any

import pytest

from decnet.ttp.impl._rule_index import RuleIndex
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleChange, RuleState


def _rule(
    rule_id: str = "R0001",
    *,
    rule_version: int = 1,
    applies_to: frozenset[str] = frozenset({"command"}),
    state: RuleState | None = None,
    emits: tuple[tuple[str, str | None, str, float], ...] = (
        ("T1110", None, "TA0006", 0.85),
    ),
) -> CompiledRule:
    return CompiledRule(
        rule_id=rule_id,
        rule_version=rule_version,
        name="test",
        applies_to=applies_to,
        match_spec={"pattern": "x"},
        emits=emits,
        evidence_fields=(),
        state=state if state is not None else RuleState(),
    )


def test_install_and_lookup() -> None:
    idx = RuleIndex()
    rule = _rule()
    idx.install(rule)
    assert idx.get("R0001") is rule
    assert idx.by_kind("command") == [rule]
    assert idx.by_kind("email") == []


def test_install_replaces_same_rule_id() -> None:
    idx = RuleIndex()
    idx.install(_rule(rule_version=1))
    idx.install(_rule(rule_version=2))
    bucket = idx.by_kind("command")
    assert len(bucket) == 1
    assert bucket[0].rule_version == 2


def test_install_deletion_sentinel_evicts() -> None:
    idx = RuleIndex()
    idx.install(_rule())
    sentinel = CompiledRule(
        rule_id="R0001",
        rule_version=1,
        name="",
        applies_to=frozenset(),
        match_spec={},
        emits=(),
        evidence_fields=(),
        state=RuleState(),
    )
    idx.install(sentinel)
    assert idx.get("R0001") is None
    assert idx.by_kind("command") == []


def test_evict_unknown_is_noop() -> None:
    idx = RuleIndex()
    idx.evict("R_NOPE")  # must not raise


def test_apply_change_definition_installs() -> None:
    idx = RuleIndex()
    rule = _rule()
    idx.apply_change(
        RuleChange(change_kind="definition", rule_id="R0001", new_value=rule),
        RuleState,
    )
    assert idx.get("R0001") is rule


def test_apply_change_state_restamps_atomically() -> None:
    idx = RuleIndex()
    idx.install(_rule())
    new_state = RuleState(state="clipped", confidence_max=0.5)
    idx.apply_change(
        RuleChange(change_kind="state", rule_id="R0001", new_value=new_state),
        RuleState,
    )
    restamped = idx.get("R0001")
    assert restamped is not None
    assert restamped.state == new_state
    bucket = idx.by_kind("command")
    assert len(bucket) == 1
    assert bucket[0].state.confidence_max == 0.5


def test_apply_state_change_for_unknown_rule_is_noop() -> None:
    idx = RuleIndex()
    idx.apply_change(
        RuleChange(
            change_kind="state",
            rule_id="R_GHOST",
            new_value=RuleState(state="disabled"),
        ),
        RuleState,
    )
    assert idx.get("R_GHOST") is None


# ── Hydrate / watch via stub store ──────────────────────────────────


class _StubStore:
    def __init__(
        self,
        compiled: list[CompiledRule] | None = None,
        changes: list[RuleChange] | None = None,
    ) -> None:
        self._compiled = compiled or []
        self._changes = changes or []

    async def load_compiled(self) -> list[CompiledRule]:
        return list(self._compiled)

    async def get_state(self, _rule_id: str) -> RuleState:
        return RuleState()

    async def set_state(self, *_a: Any, **_kw: Any) -> None:
        return None

    def subscribe_changes(self) -> AsyncIterator[RuleChange]:
        changes = list(self._changes)

        async def _gen() -> AsyncIterator[RuleChange]:
            for change in changes:
                yield change

        return _gen()


def test_hydrate_from_installs_all() -> None:
    idx = RuleIndex()
    a = _rule("R_A")
    b = _rule("R_B", applies_to=frozenset({"email"}))
    store = _StubStore(compiled=[a, b])
    asyncio.run(idx.hydrate_from(store))
    assert idx.get("R_A") is a
    assert idx.get("R_B") is b


def test_hydrate_predicate_filters() -> None:
    idx = RuleIndex()
    a = _rule("R_A")  # match.kind defaults to {"pattern": "x"}
    b = _rule("R_B")
    store = _StubStore(compiled=[a, b])
    asyncio.run(
        idx.hydrate_from(store, predicate=lambda r: r.rule_id == "R_A")
    )
    assert idx.get("R_A") is a
    assert idx.get("R_B") is None


def test_watch_drains_definition_changes() -> None:
    idx = RuleIndex()
    a = _rule("R_A")
    b = _rule("R_B", applies_to=frozenset({"email"}))
    store = _StubStore(
        compiled=[],
        changes=[
            RuleChange(change_kind="definition", rule_id="R_A", new_value=a),
            RuleChange(change_kind="definition", rule_id="R_B", new_value=b),
        ],
    )
    asyncio.run(idx.watch(store))
    assert idx.get("R_A") is a
    assert idx.get("R_B") is b


def test_watch_predicate_evicts_unowned_definition_changes() -> None:
    """A rule whose YAML moves out of our predicate's claim set must be
    evicted from the index, not silently retained.
    """
    idx = RuleIndex()
    owned = _rule("R_A")
    unowned = _rule("R_B")
    idx.install(owned)
    idx.install(unowned)

    # Predicate now only owns R_A; R_B's incoming definition update
    # should evict it.
    new_b = _rule("R_B", rule_version=2)
    store = _StubStore(
        compiled=[],
        changes=[
            RuleChange(
                change_kind="definition", rule_id="R_B", new_value=new_b,
            ),
        ],
    )
    asyncio.run(idx.watch(store, predicate=lambda r: r.rule_id == "R_A"))
    assert idx.get("R_A") is owned
    assert idx.get("R_B") is None


def test_watch_state_change_for_owned_rule_applies() -> None:
    idx = RuleIndex()
    idx.install(_rule("R_A"))
    new_state = RuleState(state="disabled")
    store = _StubStore(
        compiled=[],
        changes=[
            RuleChange(
                change_kind="state", rule_id="R_A", new_value=new_state,
            ),
        ],
    )
    asyncio.run(idx.watch(store, predicate=lambda r: r.rule_id == "R_A"))
    restamped = idx.get("R_A")
    assert restamped is not None
    assert restamped.state.state == "disabled"


def test_watch_state_change_for_unowned_rule_skipped() -> None:
    idx = RuleIndex()
    # R_B was never installed (predicate excluded it). State change
    # for R_B must NOT install a phantom entry.
    store = _StubStore(
        compiled=[],
        changes=[
            RuleChange(
                change_kind="state",
                rule_id="R_B",
                new_value=RuleState(state="disabled"),
            ),
        ],
    )
    asyncio.run(idx.watch(store, predicate=lambda r: r.rule_id == "R_A"))
    assert idx.get("R_B") is None


def test_apply_change_continues_on_error(caplog: pytest.LogCaptureFixture) -> None:
    """A single bad change must not stall the watch loop."""
    idx = RuleIndex()
    # Force an exception by passing the wrong value type for definition.
    bad = RuleChange(
        change_kind="definition",
        rule_id="R_BAD",
        new_value=RuleState(),  # wrong type — apply_change ignores silently
    )
    good = _rule("R_GOOD")
    store = _StubStore(
        compiled=[],
        changes=[
            bad,
            RuleChange(
                change_kind="definition", rule_id="R_GOOD", new_value=good,
            ),
        ],
    )
    asyncio.run(idx.watch(store))
    assert idx.get("R_GOOD") is good
    assert idx.get("R_BAD") is None


def test_expired_state_treated_as_disabled_by_is_active() -> None:
    """Sanity check on the helper used by both engine and lifters."""
    from decnet.ttp.impl._state import is_active

    expired = RuleState(
        state="enabled",
        expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
    )
    assert is_active(expired) is False
    fresh = RuleState(
        state="enabled",
        expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
    )
    assert is_active(fresh) is True


def test_apply_ceiling_only_clamps_clipped() -> None:
    from decnet.ttp.impl._state import apply_ceiling

    enabled = RuleState(state="enabled", confidence_max=0.5)
    assert apply_ceiling(0.9, enabled) == 0.9  # ceiling ignored unless clipped
    clipped = RuleState(state="clipped", confidence_max=0.5)
    assert apply_ceiling(0.9, clipped) == pytest.approx(0.45)
    clipped_no_max = RuleState(state="clipped", confidence_max=None)
    assert apply_ceiling(0.9, clipped_no_max) == 0.9
