"""Contract tests for :mod:`decnet.ttp.base` (E.1.3 + E.2.4).

E.1.3 contract surface: shape of TaggerEvent, abstractness of
Tagger, the swallow-Exception / propagate-BaseException boundary
of TolerantTagger, and the closed-by-enumeration
KNOWN_SOURCE_KINDS constant.

E.2.4 conformance (this commit): hypothesis fuzz over a curated
set of Exception subclasses → all swallowed and converted to ``[]``
at WARNING level (never ERROR). The propagate-list
(``KeyboardInterrupt`` / ``SystemExit`` /
``asyncio.CancelledError``) stays separate.
"""
from __future__ import annotations

import asyncio
import logging
from typing import Any

import pytest
from hypothesis import HealthCheck, given, settings, strategies as st

from decnet.ttp.base import (
    KNOWN_SOURCE_KINDS,
    Tagger,
    TaggerEvent,
    TolerantTagger,
)


def _ev(source_kind: str = "command") -> TaggerEvent:
    return TaggerEvent(
        source_kind=source_kind,
        source_id="src1",
        attacker_uuid=None,
        identity_uuid="id1",
        session_id=None,
        decky_id=None,
        payload={},
    )


def test_tagger_event_is_namedtuple_and_hashable() -> None:
    ev = _ev()
    assert ev.source_kind == "command"
    assert ev.identity_uuid == "id1"
    # NamedTuple gives instances tuple identity for downstream dedup
    # paths. The payload field is a dict (unhashable by design — the
    # raw event isn't meant to live in a set), but the structural
    # tuple shape is what callers actually rely on.
    assert tuple(ev)[0] == "command"
    assert len(ev) == 7


def test_tagger_is_abstract() -> None:
    with pytest.raises(TypeError):
        Tagger()  # type: ignore[abstract]


def test_tagger_subclass_without_tag_is_abstract() -> None:
    class Half(Tagger):
        name = "half"

    with pytest.raises(TypeError):
        Half()  # type: ignore[abstract]


def test_known_source_kinds_is_frozenset_of_strings() -> None:
    assert isinstance(KNOWN_SOURCE_KINDS, frozenset)
    assert all(isinstance(k, str) for k in KNOWN_SOURCE_KINDS)
    # The contract requires at least the lifter-aligned kinds enumerated
    # in the design doc; further kinds may be added but these MUST be
    # present.
    must_have = {
        "command", "intel", "email", "canary_fingerprint",
        "identity", "credential",
    }
    assert must_have <= KNOWN_SOURCE_KINDS


def test_tolerant_tagger_swallows_exception_and_returns_empty(
    caplog: pytest.LogCaptureFixture,
) -> None:
    class Boom(TolerantTagger):
        name = "boom"
        HANDLES = frozenset({"command"})

        async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
            raise RuntimeError("synthetic")

    caplog.set_level(logging.WARNING, logger="decnet.ttp.base")
    out = asyncio.run(Boom().tag(_ev()))
    assert out == []
    # WARNING — never ERROR — per the absence-is-normal doctrine.
    records = [r for r in caplog.records if r.name == "decnet.ttp.base"]
    assert records, "expected a log line on swallowed exception"
    assert all(r.levelno == logging.WARNING for r in records)


@pytest.mark.parametrize(
    "exc_cls",
    [KeyboardInterrupt, SystemExit, asyncio.CancelledError],
)
def test_tolerant_tagger_propagates_base_exceptions(
    exc_cls: type[BaseException],
) -> None:
    class Cancel(TolerantTagger):
        name = "cancel"
        HANDLES = frozenset({"command"})

        async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
            raise exc_cls()

    with pytest.raises(exc_cls):
        asyncio.run(Cancel().tag(_ev()))


def test_tolerant_tagger_subclass_must_implement_tag_impl() -> None:
    class Empty(TolerantTagger):
        name = "empty"

    with pytest.raises(TypeError):
        Empty()  # type: ignore[abstract]


def test_tagger_default_handles_is_empty_frozenset() -> None:
    # Misconfigured subclass that forgets HANDLES is loudly idle,
    # not loudly noisy — the composite skips it entirely.
    assert Tagger.HANDLES == frozenset()


# ─── E.2.4 — Hypothesis fuzz over the swallow-Exception boundary ─────────────

# Curated list of plausible runtime failure modes a lifter could hit
# when its sibling-worker join is absent or malformed. Spec calls for
# ``st.sampled_from`` over a curated list — no fully random class
# synthesis (which could pick BaseException subclasses we explicitly
# do NOT want swallowed).
_SWALLOWED_EXCS: tuple[type[Exception], ...] = (
    ValueError,
    RuntimeError,
    KeyError,
    # TypeError is intentionally NOT swallowed — it propagates as a
    # programmer-error signal (bad evidence shape). See TolerantTagger.tag.
    AttributeError,
    LookupError,
    OSError,
    asyncio.TimeoutError,
    ZeroDivisionError,
    StopIteration,
)


@given(exc_cls=st.sampled_from(_SWALLOWED_EXCS))
@settings(
    max_examples=50,
    deadline=None,
    suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_tolerant_tagger_fuzzed_exceptions_swallowed(
    exc_cls: type[Exception],
    caplog: pytest.LogCaptureFixture,
) -> None:
    class Boom(TolerantTagger):
        name = "boom"
        HANDLES = frozenset({"command"})

        async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
            raise exc_cls("synthetic")

    caplog.clear()
    caplog.set_level(logging.DEBUG, logger="decnet.ttp.base")
    out = asyncio.run(Boom().tag(_ev()))
    assert out == []
    records = [r for r in caplog.records if r.name == "decnet.ttp.base"]
    assert records, f"expected a WARNING for {exc_cls.__name__}"
    # Absence-is-normal: every swallowed exception logs at WARNING,
    # never ERROR. A future change that flips to ERROR — paging the
    # operator on every empty join — trips this assert.
    assert all(r.levelno == logging.WARNING for r in records)
    assert not any(r.levelno >= logging.ERROR for r in records)


def test_tolerant_tagger_no_error_records_on_swallow(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Sibling property to the fuzz test: pin the no-ERROR invariant
    independent of Hypothesis sampling, so the contract is visible
    in plain pytest output."""
    class Boom(TolerantTagger):
        name = "boom"
        HANDLES = frozenset({"command"})

        async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
            raise KeyError("no such join")

    caplog.set_level(logging.DEBUG, logger="decnet.ttp.base")
    asyncio.run(Boom().tag(_ev()))
    base_records = [r for r in caplog.records if r.name == "decnet.ttp.base"]
    assert base_records
    assert not [r for r in base_records if r.levelno >= logging.ERROR]
