"""unit tests for :class:`threetears.nats.NatsClient`.

these tests substitute fakes for the underlying nats-py client so the
wrapper logic (typed publish, kw-only subscribe, deadletter, error
mapping) can be exercised without a live broker. integration tests
against a real NATS testcontainer live in tests/integration/.
"""

from __future__ import annotations

import asyncio
import json
import logging
from datetime import timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest
from pydantic import BaseModel

from threetears.nats import (
    IncomingMessage,
    NatsClient,
    PublishError,
    NoRespondersError,
    RequestError,
    RequestTimeoutError,
    Subject,
    Subjects,
    SubscribeError,
    Subscription,
    set_default_namespace,
)


# ---------------------------------------------------------------------------
# fakes
# ---------------------------------------------------------------------------


class _StreamBoom:
    """queue sentinel making :class:`_FakeSubscription` fail mid-stream.

    distinct from the ``None`` end-of-stream sentinel: this models the message iterator
    raising (transport dropped, nats-py internal error) rather than ending cleanly.
    """

    def __init__(self, message: str) -> None:
        self.message = message


class _FakeSubscription:
    """fake nats-py subscription with an asyncio.Queue-backed message stream."""

    def __init__(self) -> None:
        self.queue: asyncio.Queue[Any] = asyncio.Queue()
        self.unsubscribed = False

    async def unsubscribe(self) -> None:
        self.unsubscribed = True

    @property
    def messages(self) -> Any:
        async def _gen() -> Any:
            while True:
                msg = await self.queue.get()
                if msg is None:
                    return
                if isinstance(msg, _StreamBoom):
                    raise RuntimeError(msg.message)
                yield msg

        return _gen()


class _FakeMsg:
    """tiny stand-in for nats.aio.msg.Msg with .data, .reply, and .subject."""

    def __init__(self, *, data: bytes, reply: str = "", subject: str = "3tears.tools.call") -> None:
        self.data = data
        self.reply = reply
        self.subject = subject


class _FakeNatsPyClient:
    """minimal fake of nats.aio.client.Client used by tests."""

    def __init__(self) -> None:
        self.is_closed = False
        self.is_connected = True
        self.published: list[tuple[str, bytes, str | None]] = []
        self.subscribed: list[tuple[str, str | None, _FakeSubscription]] = []
        self.request_response: bytes = b"{}"
        self.request_calls: list[tuple[str, bytes, float]] = []
        # records errors passed to the nats-py op-error reconnect entry point that
        # :meth:`NatsClient.reconnect` drives.
        self.op_err_calls: list[Exception] = []

    async def _process_op_err(self, e: Exception) -> None:
        self.op_err_calls.append(e)

    async def publish(self, subject: str, payload: bytes, reply: str | None = None) -> None:
        self.published.append((subject, payload, reply))

    async def subscribe(self, subject: str, queue: str = "") -> _FakeSubscription:
        sub = _FakeSubscription()
        self.subscribed.append((subject, queue or None, sub))
        return sub

    async def request(self, subject: str, payload: bytes, timeout: float) -> _FakeMsg:
        self.request_calls.append((subject, payload, timeout))
        return _FakeMsg(data=self.request_response)

    async def drain(self) -> None:
        self.is_closed = True

    async def close(self) -> None:
        self.is_closed = True

    async def flush(self, timeout: float = 2.0) -> None:
        self.flush_calls: list[float]
        if not hasattr(self, "flush_calls"):
            self.flush_calls = []
        self.flush_calls.append(timeout)
        flush_error = getattr(self, "flush_error", None)
        if flush_error is not None:
            raise flush_error


class _Hello(BaseModel):
    """sample message type for typed publish/subscribe tests."""

    greeting: str
    count: int


class _Reply(BaseModel):
    """sample response type for request/reply tests."""

    ok: bool


@pytest.fixture(autouse=True)
def _bind_namespace() -> None:
    """ensure each test starts from a known namespace."""
    set_default_namespace("3tears")


def _make_client() -> tuple[NatsClient, _FakeNatsPyClient]:
    """construct a NatsClient backed by a fake nats-py client."""
    fake = _FakeNatsPyClient()
    client = NatsClient(raw=fake, namespace="3tears", client_name="test")  # type: ignore[arg-type]
    return client, fake


@pytest.mark.asyncio
async def test_publish_serializes_pydantic() -> None:
    """publish encodes BaseModel via model_dump_json."""
    client, fake = _make_client()
    subject = Subjects.tools_call()
    msg = _Hello(greeting="hi", count=3)
    await client.publish(subject=subject, message=msg)
    assert len(fake.published) == 1
    sub_path, payload, reply_to = fake.published[0]
    assert sub_path == "3tears.tools.call"
    assert json.loads(payload) == {"greeting": "hi", "count": 3}
    assert reply_to is None


@pytest.mark.asyncio
async def test_publish_with_reply_to() -> None:
    """publish forwards reply_to to underlying client."""
    client, fake = _make_client()
    await client.publish(
        subject=Subjects.tools_call(),
        message=_Hello(greeting="x", count=1),
        reply_to=Subject.raw("3tears.reply.inbox"),
    )
    assert fake.published[0][2] == "3tears.reply.inbox"


@pytest.mark.asyncio
async def test_publish_raw_passes_bytes_unchanged() -> None:
    """publish_raw does not re-serialize payload."""
    client, fake = _make_client()
    await client.publish_raw(subject=Subjects.l3_query(), payload=b"raw-bytes")
    assert fake.published[0][1] == b"raw-bytes"


@pytest.mark.asyncio
async def test_publish_reply_validates_subject() -> None:
    """publish_reply rejects empty reply_subject."""
    client, _ = _make_client()
    with pytest.raises(PublishError):
        await client.publish_reply(reply_subject="", message=_Hello(greeting="x", count=1))


@pytest.mark.asyncio
async def test_publish_wraps_underlying_error() -> None:
    """publish errors raise PublishError, not the underlying exception."""

    class _Boom(_FakeNatsPyClient):
        async def publish(self, subject: str, payload: bytes, reply: str | None = None) -> None:
            raise RuntimeError("transport down")

    fake = _Boom()
    client = NatsClient(raw=fake, namespace="3tears", client_name="test")  # type: ignore[arg-type]
    with pytest.raises(PublishError):
        await client.publish_raw(subject=Subjects.l3_query(), payload=b"x")


@pytest.mark.asyncio
async def test_subscribe_is_kw_only() -> None:
    """positional subscribe args raise TypeError — the cb-as-positional footgun is impossible."""
    client, _ = _make_client()

    async def handler(_msg: IncomingMessage) -> None:
        pass

    with pytest.raises(TypeError):
        await client.subscribe(Subjects.tools_call(), handler)  # type: ignore[misc]


@pytest.mark.asyncio
async def test_subscribe_typed_decodes_pydantic() -> None:
    """subscribe_typed parses incoming bytes into the declared BaseModel."""
    client, fake = _make_client()
    received: list[_Hello] = []

    async def on_msg(msg: _Hello) -> None:
        received.append(msg)

    sub = await client.subscribe_typed(
        subject=Subjects.tools_call(),
        cb=on_msg,
        message_type=_Hello,
    )
    assert isinstance(sub, Subscription)
    fake_sub = fake.subscribed[0][2]
    payload = _Hello(greeting="hello", count=7).model_dump_json().encode("utf-8")
    await fake_sub.queue.put(_FakeMsg(data=payload))
    # let the dispatch task run
    await asyncio.sleep(0.05)
    assert received == [_Hello(greeting="hello", count=7)]
    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_subscribe_typed_validation_failure_deadletters() -> None:
    """validation failure publishes to deadletter when deadletter_on_failure=True (default)."""
    client, fake = _make_client()

    async def on_msg(_msg: _Hello) -> None:
        pytest.fail("callback should not run on invalid payload")

    sub = await client.subscribe_typed(
        subject=Subjects.tools_call(),
        cb=on_msg,
        message_type=_Hello,
    )
    fake_sub = fake.subscribed[0][2]
    bad = b'{"not_the_right_field": true}'
    await fake_sub.queue.put(_FakeMsg(data=bad))
    await asyncio.sleep(0.05)
    # validate that the bad message landed on deadletter subject
    dl_publishes = [p for p in fake.published if p[0].startswith("3tears.deadletter.")]
    assert len(dl_publishes) == 1
    assert dl_publishes[0][0] == "3tears.deadletter.3tears.tools.call"
    envelope = json.loads(dl_publishes[0][1])
    assert envelope["original_subject"] == "3tears.tools.call"
    assert envelope["error_type"] == "ValidationError"
    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_subscribe_callback_exception_deadletters() -> None:
    """callback exceptions deadletter when deadletter_on_failure=True."""
    client, fake = _make_client()

    async def on_msg(_msg: _Hello) -> None:
        raise RuntimeError("kaboom")

    sub = await client.subscribe_typed(
        subject=Subjects.tools_call(),
        cb=on_msg,
        message_type=_Hello,
    )
    fake_sub = fake.subscribed[0][2]
    payload = _Hello(greeting="x", count=1).model_dump_json().encode("utf-8")
    await fake_sub.queue.put(_FakeMsg(data=payload))
    await asyncio.sleep(0.05)
    dl = [p for p in fake.published if p[0].startswith("3tears.deadletter.")]
    assert len(dl) == 1
    envelope = json.loads(dl[0][1])
    assert envelope["error_type"] == "RuntimeError"
    assert envelope["error_message"] == "kaboom"
    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_subscribe_callback_exception_no_deadletter_when_disabled() -> None:
    """deadletter_on_failure=False — callback exceptions log only, no republish."""
    client, fake = _make_client()

    async def on_msg(_msg: _Hello) -> None:
        raise RuntimeError("kaboom")

    sub = await client.subscribe_typed(
        subject=Subjects.tools_call(),
        cb=on_msg,
        message_type=_Hello,
        deadletter_on_failure=False,
    )
    fake_sub = fake.subscribed[0][2]
    payload = _Hello(greeting="x", count=1).model_dump_json().encode("utf-8")
    await fake_sub.queue.put(_FakeMsg(data=payload))
    await asyncio.sleep(0.05)
    dl = [p for p in fake.published if p[0].startswith("3tears.deadletter.")]
    assert len(dl) == 0
    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_subscribe_with_queue_group() -> None:
    """queue group is forwarded to underlying subscribe."""
    client, fake = _make_client()

    async def handler(_msg: IncomingMessage) -> None:
        pass

    await client.subscribe(
        subject=Subjects.audit_wildcard(),
        cb=handler,
        queue="audit-consumer",
    )
    assert fake.subscribed[0][1] == "audit-consumer"


@pytest.mark.asyncio
async def test_subscribe_max_in_flight_invalid() -> None:
    """max_in_flight=0 is rejected."""
    client, _ = _make_client()

    async def handler(_msg: IncomingMessage) -> None:
        pass

    with pytest.raises(SubscribeError):
        await client.subscribe(
            subject=Subjects.tools_call(),
            cb=handler,
            max_in_flight=0,
        )


def _concurrency_probe() -> tuple[Any, Any, Any, Any, Any]:
    """shared harness for the max_in_flight tests: a handler that parks at a barrier so the
    number of *simultaneously in-flight* callbacks is observable.

    :return: (handler, started, done, release, peak_getter) — ``started``/``done`` are
        semaphores released on callback entry/exit, ``release`` an event the callbacks await,
        and ``peak_getter()`` returns the max concurrency observed so far.
    """
    started = asyncio.Semaphore(0)
    done = asyncio.Semaphore(0)
    release = asyncio.Event()
    state = {"concurrent": 0, "peak": 0}

    async def handler(_msg: IncomingMessage) -> None:
        state["concurrent"] += 1
        state["peak"] = max(state["peak"], state["concurrent"])
        started.release()
        await release.wait()
        state["concurrent"] -= 1
        done.release()

    return handler, started, done, release, lambda: state["peak"]


@pytest.mark.asyncio
async def test_subscribe_max_in_flight_runs_callbacks_concurrently() -> None:
    """max_in_flight lets up to N callbacks run at once instead of one-at-a-time.

    Regression guard: the cap used to wrap an awaited call (``async with semaphore: await
    _dispatch_one``), holding the slot for the callback's whole duration, so it serialized
    every message despite max_in_flight. This asserts three callbacks are actually in flight
    together — it fails against that serial implementation.
    """
    client, fake = _make_client()
    handler, started, done, release, peak = _concurrency_probe()

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler, max_in_flight=3)
    raw = fake.subscribed[-1][2]
    for _ in range(3):
        await raw.queue.put(_FakeMsg(data=b"{}"))

    for _ in range(3):  # all three reach the barrier concurrently
        await asyncio.wait_for(started.acquire(), timeout=2.0)
    assert peak() == 3

    release.set()
    for _ in range(3):
        await asyncio.wait_for(done.acquire(), timeout=2.0)
    await raw.queue.put(None)
    await sub.unsubscribe()


@pytest.mark.asyncio
async def test_subscribe_max_in_flight_bounds_concurrency() -> None:
    """max_in_flight caps simultaneous callbacks — never more than N, even with more queued."""
    client, fake = _make_client()
    handler, started, done, release, peak = _concurrency_probe()

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler, max_in_flight=2)
    raw = fake.subscribed[-1][2]
    for _ in range(5):
        await raw.queue.put(_FakeMsg(data=b"{}"))

    for _ in range(2):  # exactly two may start before any completes
        await asyncio.wait_for(started.acquire(), timeout=2.0)
    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(started.acquire(), timeout=0.2)
    assert peak() == 2

    release.set()
    for _ in range(5):
        await asyncio.wait_for(done.acquire(), timeout=2.0)
    assert peak() == 2  # cap held across the whole run
    await raw.queue.put(None)
    await sub.unsubscribe()


@pytest.mark.asyncio
async def test_subscribe_serial_when_max_in_flight_unset() -> None:
    """Without max_in_flight, callbacks stay strictly serial (in-order) — the default that
    subscribers relying on ordering depend on is unchanged by the concurrency path."""
    client, fake = _make_client()
    handler, started, done, release, peak = _concurrency_probe()

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler)  # no max_in_flight
    raw = fake.subscribed[-1][2]
    for _ in range(2):
        await raw.queue.put(_FakeMsg(data=b"{}"))

    await asyncio.wait_for(started.acquire(), timeout=2.0)
    with pytest.raises(asyncio.TimeoutError):  # second must not start until the first finishes
        await asyncio.wait_for(started.acquire(), timeout=0.2)
    assert peak() == 1

    release.set()
    for _ in range(2):
        await asyncio.wait_for(done.acquire(), timeout=2.0)
    assert peak() == 1
    await raw.queue.put(None)
    await sub.unsubscribe()


@pytest.mark.asyncio
async def test_subscribe_max_in_flight_joins_callbacks_when_stream_ends() -> None:
    """the dispatch loop ending normally waits for its in-flight callbacks.

    Regression guard: the message stream can end on its own (server-side unsubscribe,
    connection drain) rather than by cancellation. Cleaning up in-flight callbacks only on
    the cancellation path left them running unowned — the loop task finished, so the later
    ``unsubscribe`` cancelled an already-completed task and never reached them.
    """
    client, fake = _make_client()
    release = asyncio.Event()
    entered = asyncio.Semaphore(0)
    finished = False

    async def handler(_msg: IncomingMessage) -> None:
        nonlocal finished
        entered.release()
        await release.wait()
        finished = True

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler, max_in_flight=2)
    raw = fake.subscribed[-1][2]
    await raw.queue.put(_FakeMsg(data=b"{}"))
    await asyncio.wait_for(entered.acquire(), timeout=2.0)

    await raw.queue.put(None)  # stream ends while the callback is still in flight
    await asyncio.sleep(0)
    assert not sub.dispatch_task.done(), "loop must not finish while a callback is in flight"

    release.set()
    await asyncio.wait_for(sub.dispatch_task, timeout=2.0)
    assert finished
    await sub.unsubscribe()


@pytest.mark.asyncio
async def test_subscribe_max_in_flight_unsubscribe_awaits_inflight_cancellation() -> None:
    """unsubscribe does not return until cancelled callbacks have finished unwinding.

    Regression guard: firing ``task.cancel()`` without joining let ``unsubscribe`` — and so
    :meth:`NatsClient.shutdown`, which unsubscribes and then drains the connection — return
    while a callback was still running its cleanup, pulling the connection out from under an
    in-flight reply. The serial path always joined its callback via the dispatch task.
    """
    client, fake = _make_client()
    entered = asyncio.Semaphore(0)
    cleanup_done = False

    async def handler(_msg: IncomingMessage) -> None:
        nonlocal cleanup_done
        entered.release()
        try:
            await asyncio.Event().wait()  # park until cancelled
        except asyncio.CancelledError:
            await asyncio.sleep(0.05)  # cleanup work that must be allowed to finish
            cleanup_done = True
            raise

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler, max_in_flight=2)
    raw = fake.subscribed[-1][2]
    await raw.queue.put(_FakeMsg(data=b"{}"))
    await asyncio.wait_for(entered.acquire(), timeout=2.0)

    await asyncio.wait_for(sub.unsubscribe(), timeout=2.0)
    assert cleanup_done, "unsubscribe returned before the in-flight callback finished unwinding"


@pytest.mark.asyncio
async def test_subscribe_max_in_flight_unsubscribe_joins_every_sibling() -> None:
    """all concurrently-parked callbacks are unwound before unsubscribe returns, not just one.

    The cap's whole point is several callbacks in flight at once, so the teardown join has to
    cover the full set — a partial join would leave siblings running against a closing
    connection exactly when it matters most.
    """
    client, fake = _make_client()
    entered = asyncio.Semaphore(0)
    unwound: list[int] = []

    async def handler(_msg: IncomingMessage) -> None:
        entered.release()
        try:
            await asyncio.Event().wait()  # park until cancelled
        except asyncio.CancelledError:
            await asyncio.sleep(0.02)  # cleanup work that must be allowed to finish
            unwound.append(1)
            raise

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler, max_in_flight=3)
    raw = fake.subscribed[-1][2]
    for _ in range(3):
        await raw.queue.put(_FakeMsg(data=b"{}"))
    for _ in range(3):
        await asyncio.wait_for(entered.acquire(), timeout=2.0)

    await asyncio.wait_for(sub.unsubscribe(), timeout=2.0)
    assert len(unwound) == 3, f"only {len(unwound)}/3 callbacks finished unwinding"


@pytest.mark.asyncio
async def test_subscribe_max_in_flight_loop_crash_logs_real_error_type(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """a message-stream failure is logged with its own error type, not a wrapper's.

    Regression guard: the bounded path runs inside an ``asyncio.TaskGroup``, and an exception
    allowed to escape into ``TaskGroup.__aexit__`` comes back wrapped in an ``ExceptionGroup``
    — which would reduce every transport failure in the log to ``error_type=ExceptionGroup``
    with the real message buried.
    """
    client, fake = _make_client()

    async def handler(_msg: IncomingMessage) -> None:
        return None

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler, max_in_flight=2)
    raw = fake.subscribed[-1][2]

    with caplog.at_level(logging.ERROR):
        await raw.queue.put(_StreamBoom("nats transport went away"))
        await asyncio.wait_for(sub.dispatch_task, timeout=2.0)

    crashes = [rec for rec in caplog.records if rec.message == "subscription dispatch loop crashed"]
    assert len(crashes) == 1
    logged = crashes[0].extra_data  # type: ignore[attr-defined]
    assert logged["error_type"] == "RuntimeError"
    assert "nats transport went away" in logged["error"]
    await sub.unsubscribe()


@pytest.mark.asyncio
async def test_request_typed_round_trip() -> None:
    """request encodes message, decodes response into declared type."""
    client, fake = _make_client()
    fake.request_response = _Reply(ok=True).model_dump_json().encode("utf-8")
    response = await client.request(
        subject=Subjects.tools_call(),
        message=_Hello(greeting="ping", count=1),
        response_type=_Reply,
        timeout=timedelta(seconds=2),
    )
    assert response == _Reply(ok=True)
    assert fake.request_calls[0][2] == 2.0  # timeout converted to seconds


@pytest.mark.asyncio
async def test_request_decode_failure_raises_request_error() -> None:
    """response that fails to decode raises RequestError."""
    client, fake = _make_client()
    fake.request_response = b'{"not_a_valid_reply": 1}'
    with pytest.raises(RequestError):
        await client.request(
            subject=Subjects.tools_call(),
            message=_Hello(greeting="x", count=1),
            response_type=_Reply,
        )


@pytest.mark.asyncio
async def test_request_timeout_maps_to_request_error() -> None:
    """nats-py TimeoutError maps to RequestError."""
    from nats.errors import TimeoutError as NatsTimeout

    class _TimeoutClient(_FakeNatsPyClient):
        async def request(self, subject: str, payload: bytes, timeout: float) -> _FakeMsg:
            raise NatsTimeout()

    fake = _TimeoutClient()
    client = NatsClient(raw=fake, namespace="3tears", client_name="t")  # type: ignore[arg-type]
    with pytest.raises(RequestError) as exc_info:
        await client.request_raw(
            subject=Subjects.tools_call(),
            payload=b"x",
            timeout=timedelta(seconds=1),
        )
    assert "timed out" in str(exc_info.value)


@pytest.mark.asyncio
async def test_unsubscribe_marks_closed_and_drops_underlying() -> None:
    """unsubscribe is idempotent and unsubscribes underlying."""
    client, fake = _make_client()

    async def handler(_msg: IncomingMessage) -> None:
        pass

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler)
    assert sub.is_closed is False
    await client.unsubscribe(sub)
    assert sub.is_closed is True
    fake_sub = fake.subscribed[0][2]
    assert fake_sub.unsubscribed is True
    # second call is a no-op
    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_shutdown_drains_subscriptions() -> None:
    """shutdown unsubscribes everything and drains underlying client."""
    client, fake = _make_client()

    async def handler(_msg: IncomingMessage) -> None:
        pass

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler)
    await client.shutdown(drain_timeout=timedelta(seconds=1))
    assert sub.is_closed is True
    assert fake.is_closed is True


@pytest.mark.asyncio
async def test_subscribe_callback_receives_incoming_message_envelope() -> None:
    """raw subscribe callback receives :class:`IncomingMessage` carrying data + reply_subject."""
    client, fake = _make_client()
    received: list[IncomingMessage] = []

    async def handler(msg: IncomingMessage) -> None:
        received.append(msg)

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler)
    fake_sub = fake.subscribed[0][2]
    await fake_sub.queue.put(_FakeMsg(data=b"payload-bytes", reply="3tears._INBOX.42"))
    await asyncio.sleep(0.05)
    assert len(received) == 1
    assert received[0].data == b"payload-bytes"
    assert received[0].reply_subject == "3tears._INBOX.42"
    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_subscribe_callback_reply_subject_none_for_pubsub() -> None:
    """raw subscribe callback receives ``reply_subject=None`` when producer skipped reply-to."""
    client, fake = _make_client()
    received: list[IncomingMessage] = []

    async def handler(msg: IncomingMessage) -> None:
        received.append(msg)

    sub = await client.subscribe(subject=Subjects.tools_call(), cb=handler)
    fake_sub = fake.subscribed[0][2]
    # _FakeMsg.reply defaults to "" — the wrapper coerces empty to None
    await fake_sub.queue.put(_FakeMsg(data=b"pubsub"))
    await asyncio.sleep(0.05)
    assert received == [
        IncomingMessage(data=b"pubsub", reply_subject=None, subject="3tears.tools.call"),
    ]
    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_connect_validates_url() -> None:
    """connect rejects empty nats_url."""
    from threetears.nats import NatsClientError

    with pytest.raises(NatsClientError):
        await NatsClient.connect(nats_url="", client_name="x", nats_subject_namespace="3tears")


@pytest.mark.asyncio
async def test_connect_validates_client_name() -> None:
    """connect rejects empty client_name."""
    from threetears.nats import NatsClientError

    with pytest.raises(NatsClientError):
        await NatsClient.connect(nats_url="nats://x", client_name="", nats_subject_namespace="3tears")


@pytest.mark.asyncio
async def test_connect_validates_namespace() -> None:
    """connect rejects empty subject namespace."""
    from threetears.nats import NatsClientError

    with pytest.raises(NatsClientError):
        await NatsClient.connect(
            nats_url="nats://x",
            client_name="x",
            nats_subject_namespace="",
        )


@pytest.mark.asyncio
async def test_connect_passes_credentials_and_scoped_inbox(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """the auth_token provider, user_credentials, and a scoped inbox_prefix reach the connect options."""
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        auth_token=lambda: "bootstrap-tok",
        user_credentials="/run/secrets/agent.creds",
        inbox_prefix="_INBOX_agent_pod_pod-1",
        verify_jetstream=False,
    )

    options = captured["options"]
    assert options["token"]() == "bootstrap-tok"  # a provider callable; nats-py invokes it per connect
    assert options["user_credentials"] == "/run/secrets/agent.creds"
    assert options["inbox_prefix"] == b"_INBOX_agent_pod_pod-1"  # bytes, scoped per-principal


@pytest.mark.asyncio
async def test_connect_auth_token_provider_is_reinvoked_per_connect(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """the auth_token provider is passed through UNWRAPPED so nats-py re-invokes it on every
    (re)connect — each reconnect presents a freshly-minted token, never a cached (expired) snapshot."""
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    minted: list[str] = []

    def _mint() -> str:
        token = f"tok-{len(minted)}"
        minted.append(token)
        return token

    await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        auth_token=_mint,
        verify_jetstream=False,
    )

    provider = captured["options"]["token"]
    # stored as the callable itself (not a snapshotted string): successive calls yield successive
    # fresh tokens, which is exactly what nats-py's per-(re)connect invocation relies on.
    assert callable(provider)
    assert provider() == "tok-0"
    assert provider() == "tok-1"


@pytest.mark.asyncio
async def test_connect_passes_user_and_password(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """user + password reach the nats-py connect options (config-mode static auth_users)."""
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="gateway-svc",
        user="gateway",
        password="s3cret",
        verify_jetstream=False,
    )

    options = captured["options"]
    assert options["user"] == "gateway"
    assert options["password"] == "s3cret"


@pytest.mark.asyncio
async def test_connect_omits_credential_options_when_absent(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """anonymous connect (the legacy shared bus) sets none of the credential/inbox options."""
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        verify_jetstream=False,
    )

    options = captured["options"]
    assert "token" not in options
    assert "user_credentials" not in options
    assert "user" not in options
    assert "password" not in options
    assert "inbox_prefix" not in options


@pytest.mark.asyncio
async def test_connect_uses_forever_reconnect(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """runtime reconnect is unbounded: the options reaching nats-py carry
    ``max_reconnect_attempts=-1`` (forever) paced by a bounded per-attempt wait.

    this is the k8s-resilience contract -- a NATS outage of ANY duration must be
    ridden out and auto-recovered with no human action, never give up after a
    finite ceiling. the previous value (100, ~200s of budget) closed the client
    for good on any longer outage, bricking every service with no self-heal.
    """
    import threetears.nats.client as client_module

    # the contract the constant encodes is forever, not a finite ceiling.
    assert client_module.RUNTIME_MAX_RECONNECT_ATTEMPTS == -1

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        verify_jetstream=False,
    )

    options = captured["options"]
    # forever-reconnect: the value that actually reaches nats-py (``< 0`` ==
    # unbounded; nats-py never empties the server pool, so it never raises
    # NoServersError and never permanently closes the client on a reconnect).
    assert options["max_reconnect_attempts"] == -1
    assert options["allow_reconnect"] is True
    # each attempt is paced so forever-retry is a bounded loop, not a hot spin.
    assert options["reconnect_time_wait"] == 2


@pytest.mark.asyncio
async def test_dispatch_loop_survives_message_gap_across_reconnect() -> None:
    """the dispatch loop keeps delivering after a quiet gap (a reconnect window).

    the audit's failure mode: after a long outage the old finite-ceiling client
    CLOSED, which ended ``raw_sub.messages`` so the dispatch loop exited silently
    -- messages that resumed post-recovery were never delivered. with
    forever-reconnect the client never closes and nats-py replays the
    subscription on the SAME object, so the loop reads the same queue across the
    gap. here the fake's message stream goes quiet then resumes (no
    close/sentinel); the loop must still deliver the post-gap message and stay
    alive.
    """
    client, fake = _make_client()
    received: list[_Hello] = []

    async def on_msg(msg: _Hello) -> None:
        received.append(msg)

    sub = await client.subscribe_typed(
        subject=Subjects.tools_call(),
        cb=on_msg,
        message_type=_Hello,
    )
    fake_sub = fake.subscribed[0][2]

    # pre-outage message delivered.
    before = _Hello(greeting="before", count=1)
    await fake_sub.queue.put(_FakeMsg(data=before.model_dump_json().encode("utf-8")))
    await asyncio.sleep(0.05)
    assert received == [before]

    # a quiet gap stands in for the disconnect/reconnect window: no message and
    # NO close/sentinel. the loop must NOT end.
    await asyncio.sleep(0.05)
    assert sub.is_closed is False
    assert sub.dispatch_task.done() is False

    # post-recovery message: nats-py replayed the SUB onto the same queue, so the
    # SAME dispatch loop delivers it.
    after = _Hello(greeting="after", count=2)
    await fake_sub.queue.put(_FakeMsg(data=after.model_dump_json().encode("utf-8")))
    await asyncio.sleep(0.05)
    assert received == [before, after]
    assert sub.dispatch_task.done() is False

    await client.unsubscribe(sub)


@pytest.mark.asyncio
async def test_reconnect_callbacks_fan_out_on_reconnect(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """callbacks registered via ``add_reconnect_callback`` fire in order on reconnect.

    forever-reconnect rides out an outage and replays subscriptions, but the BROKER
    may have dropped state backing a short-lived credential meanwhile; a consumer
    hook re-establishes it once the connection returns. this pins the shared-list
    bridge: nats-py has a single ``reconnected_cb`` slot wired at connect time, and
    the instance adopts the SAME list the dispatcher closes over, so post-connect
    registrations are visible to the already-installed slot.
    """
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    client = await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        verify_jetstream=False,
    )

    order: list[str] = []

    async def _first() -> None:
        order.append("first")

    async def _second() -> None:
        order.append("second")

    client.add_reconnect_callback(_first)
    client.add_reconnect_callback(_second)

    # invoke the exact slot nats-py would call on a reconnect.
    await captured["options"]["reconnected_cb"]()

    assert order == ["first", "second"]


@pytest.mark.asyncio
async def test_reconnect_callback_failure_is_isolated(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """one reconnect hook raising is logged and does not abort the others.

    a re-mint hook that fails must not break the callback chain or the nats-py
    reconnect path -- otherwise a single bad consumer would re-introduce the wedge
    forever-reconnect exists to prevent.
    """
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    client = await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        verify_jetstream=False,
    )

    ran: list[str] = []

    async def _boom() -> None:
        ran.append("boom")
        raise RuntimeError("re-mint failed")

    async def _after() -> None:
        ran.append("after")

    client.add_reconnect_callback(_boom)
    client.add_reconnect_callback(_after)

    # must NOT raise: the dispatcher logs the failure and continues to the next hook.
    await captured["options"]["reconnected_cb"]()

    assert ran == ["boom", "after"]


# ---------------------------------------------------------------------------
# reconnect() — proactive force-reconnect primitive (NATS-JWT re-auth)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_reconnect_triggers_nats_py_reconnect_when_connected() -> None:
    """on a CONNECTED client, reconnect() drives nats-py's own op-error reconnect path.

    this is the proactive NATS-JWT re-auth primitive: forcing a reconnect while the current
    short-lived user JWT is still valid makes nats-py re-run the auth-callout and mint a FRESH
    JWT, so the connection never reaches the auth-expiry close (which nats-py routes straight to
    a terminal ``_close``, bypassing forever-reconnect). the synthesized error is a
    StaleConnectionError -- the connection is about to go stale as the JWT nears expiry.
    """
    from nats.errors import StaleConnectionError

    client, fake = _make_client()
    fake.is_connected = True
    fake.is_closed = False

    await client.reconnect()

    assert len(fake.op_err_calls) == 1
    assert isinstance(fake.op_err_calls[0], StaleConnectionError)


@pytest.mark.asyncio
async def test_reconnect_raises_on_closed_client() -> None:
    """reconnect() on a terminally-closed client raises -- it cannot revive a closed ``_raw``.

    once nats-py has run ``_close(CLOSED)`` (the auth-expiry terminal path), ``_attempt_reconnect``
    early-returns on ``is_closed``; a proactive trigger cannot help. the caller's reactive
    self-heal path (a fresh re-establish) owns that case. failing loud keeps the two paths distinct
    and never silently lets ``_process_op_err`` fall into its connection-closing else-branch.
    """
    from threetears.nats import NatsClientError

    client, fake = _make_client()
    fake.is_closed = True

    with pytest.raises(NatsClientError):
        await client.reconnect()
    assert fake.op_err_calls == []


@pytest.mark.asyncio
async def test_reconnect_noops_when_not_currently_connected() -> None:
    """reconnect() is a no-op when not connected (but not closed) -- a reconnect is already in flight.

    forever-reconnect drives an in-flight reconnect after a network drop. forcing another through
    ``_process_op_err`` while disconnected would hit its else-branch and ``_close`` the client; the
    ``is_connected`` guard keeps reconnect() out of that branch entirely.
    """
    client, fake = _make_client()
    fake.is_connected = False
    fake.is_closed = False

    await client.reconnect()

    assert fake.op_err_calls == []


@pytest.mark.asyncio
async def test_ping_returns_true_when_flush_succeeds() -> None:
    """ping forwards to nats-py flush and returns True on success."""
    client, fake = _make_client()
    fake.is_connected = True
    result = await client.ping(timeout=1.5)
    assert result is True
    assert fake.flush_calls == [1.5]


@pytest.mark.asyncio
async def test_ping_returns_false_when_disconnected() -> None:
    """ping short-circuits when the local socket reports disconnected."""
    client, fake = _make_client()
    fake.is_connected = False
    result = await client.ping()
    assert result is False
    assert not hasattr(fake, "flush_calls") or fake.flush_calls == []


@pytest.mark.asyncio
async def test_ping_returns_false_when_flush_raises() -> None:
    """ping converts a flush exception (timeout / broker error) into False."""
    client, fake = _make_client()
    fake.is_connected = True
    fake.flush_error = TimeoutError("server slow")
    result = await client.ping(timeout=0.1)
    assert result is False


# ---------------------------------------------------------------------------
# JetStream durable-delivery helpers
# ---------------------------------------------------------------------------


def _client_with_js() -> tuple[NatsClient, Any]:
    """build a client whose JetStream context is a mock JS recorder.

    ``jetstream_context()`` returns ``self._raw.jetstream()``, so the seam is
    stubbed on the fake raw client (the wrapper method is read-only).
    """
    client, fake = _make_client()
    js = MagicMock()
    js.add_stream = AsyncMock()
    js.update_stream = AsyncMock()
    js.publish = AsyncMock()
    js.subscribe = AsyncMock(return_value="sub-handle")
    fake.jetstream = MagicMock(return_value=js)  # type: ignore[attr-defined]
    return client, js


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_creates_and_returns_full_name() -> None:
    """ensure_jetstream_stream adds the stream and returns the prefixed name."""
    from nats.js.api import StorageType

    client, js = _client_with_js()
    full = await client.ensure_jetstream_stream(
        name="channels-deliver",
        subjects=["3tears.channels.deliver.*"],
        storage="file",
    )

    assert full == "3tears-channels-deliver"
    js.add_stream.assert_awaited_once()
    js.update_stream.assert_not_awaited()
    config = js.add_stream.await_args.args[0]
    assert config.name == "3tears-channels-deliver"
    assert config.subjects == ["3tears.channels.deliver.*"]
    assert config.storage == StorageType.FILE


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_memory_storage() -> None:
    """storage='memory' selects the MEMORY storage type."""
    from nats.js.api import StorageType

    client, js = _client_with_js()
    await client.ensure_jetstream_stream(
        name="ephemeral",
        subjects=["3tears.x.*"],
        storage="memory",
    )
    config = js.add_stream.await_args.args[0]
    assert config.storage == StorageType.MEMORY


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_defaults_to_memory() -> None:
    """NATS is the L2 tier in 3tears: with no storage arg the stream is MEMORY, never FILE."""
    from nats.js.api import StorageType

    client, js = _client_with_js()
    await client.ensure_jetstream_stream(name="default", subjects=["3tears.x.*"])
    config = js.add_stream.await_args.args[0]
    assert config.storage == StorageType.MEMORY


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_applies_retention_bounds() -> None:
    """a stream whose messages are addressed to ONE waiter needs both bounds.

    without ``max_age`` a caller that never returns for its answer leaves it resident for the life of
    the stream; without ``max_msgs_per_subject=1`` a retried delivery leaves a stale first answer
    behind for the next waiter on that subject to pick up.
    """
    client, js = _client_with_js()
    await client.ensure_jetstream_stream(
        name="tools-results",
        subjects=["3tears.tools.result.>"],
        max_age_seconds=900.0,
        max_msgs_per_subject=1,
    )
    config = js.add_stream.await_args.args[0]
    assert config.max_age == 900.0
    assert config.max_msgs_per_subject == 1


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_leaves_retention_unbounded_by_default() -> None:
    """existing streams must not silently acquire a retention bound they never had."""
    client, js = _client_with_js()
    await client.ensure_jetstream_stream(name="plain", subjects=["3tears.x.*"])
    config = js.add_stream.await_args.args[0]
    assert config.max_age is None


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_reconciles_existing() -> None:
    """when the stream already exists, fall back to update_stream (reconcile)."""
    client, js = _client_with_js()
    js.add_stream.side_effect = RuntimeError("stream name already in use")

    full = await client.ensure_jetstream_stream(
        name="channels-deliver",
        subjects=["3tears.channels.deliver.*", "3tears.channels.deliver.slack"],
        storage="file",
    )

    assert full == "3tears-channels-deliver"
    js.update_stream.assert_awaited_once()
    config = js.update_stream.await_args.args[0]
    assert config.subjects == [
        "3tears.channels.deliver.*",
        "3tears.channels.deliver.slack",
    ]


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_propagates_when_both_fail() -> None:
    """a malformed config that fails create AND update surfaces, not silently."""
    client, js = _client_with_js()
    js.add_stream.side_effect = RuntimeError("create failed")
    js.update_stream.side_effect = ValueError("malformed config")

    with pytest.raises(ValueError, match="malformed config"):
        await client.ensure_jetstream_stream(
            name="bad",
            subjects=["3tears.x.*"],
            storage="file",
        )


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_raises_on_subjects_overlap_err_code() -> None:
    """subjects claimed by a DIFFERENT stream (JetStream err_code 10065) raise, not reconcile.

    the buggy path called update_stream on a name that was never created, 404ing
    and masking the real 'subjects overlap' error. it must surface instead.
    """
    from threetears.nats.errors import StreamSubjectsOverlapError

    client, js = _client_with_js()

    class _OverlapError(Exception):
        err_code = 10065

    js.add_stream.side_effect = _OverlapError("stream creation rejected")

    with pytest.raises(StreamSubjectsOverlapError, match="overlap"):
        await client.ensure_jetstream_stream(
            name="events",
            subjects=["faidh.signals.arbitrary"],
            storage="file",
        )
    js.update_stream.assert_not_awaited()


@pytest.mark.asyncio
async def test_ensure_jetstream_stream_raises_on_subjects_overlap_message() -> None:
    """overlap detected via the JetStream message when no err_code is present."""
    from threetears.nats.errors import StreamSubjectsOverlapError

    client, js = _client_with_js()
    js.add_stream.side_effect = RuntimeError("subjects overlap with an existing stream")

    with pytest.raises(StreamSubjectsOverlapError):
        await client.ensure_jetstream_stream(
            name="events",
            subjects=["faidh.signals.arbitrary"],
            storage="file",
        )
    js.update_stream.assert_not_awaited()


@pytest.mark.asyncio
async def test_connect_requires_explicit_namespace() -> None:
    """there is no default namespace: omitting it fails loud (no silent shared subject space)."""
    with pytest.raises(TypeError):
        await NatsClient.connect(nats_url="nats://x", client_name="c")  # type: ignore[call-arg]


@pytest.mark.asyncio
async def test_jetstream_publish_awaits_puback() -> None:
    """jetstream_publish persists the payload via the JS context."""
    client, js = _client_with_js()
    subject = Subjects.channels_deliver("slack")
    await client.jetstream_publish(subject=subject, payload=b"answer-bytes")

    js.publish.assert_awaited_once_with(subject.path, b"answer-bytes")


def _fake_js_msg(*, data: bytes = b"answer", num_delivered: int = 1) -> Any:
    """build a fake nats-py JetStream message for the bounded-cb tests."""
    msg = MagicMock()
    msg.data = data
    msg.metadata = MagicMock()
    msg.metadata.num_delivered = num_delivered
    msg.ack = AsyncMock()
    msg.nak = AsyncMock()
    return msg


@pytest.mark.asyncio
async def test_jetstream_subscribe_durable_binds_manual_ack_and_max_deliver() -> None:
    """the durable consumer binds manual-ack with a bounded max_deliver config."""
    from threetears.nats import JetStreamPushConsumer

    client, js = _client_with_js()
    subject = Subjects.channels_deliver("slack")
    cb = AsyncMock()

    handle = await client.jetstream_subscribe_durable(
        subject=subject,
        durable="slack-delivery",
        cb=cb,
        max_deliver=5,
        dead_letter_subject=Subjects.channels_deliver("deadletter"),
        stream="3tears-channels-deliver",
    )

    # the returned handle wraps the raw nats-py subscription rather than
    # exposing it directly -- see JetStreamPushConsumer's docstring for
    # why: the raw object is NOT compatible with NatsClient.unsubscribe()
    # (a real bug this wrapping fixes; a caller following the pattern
    # documented on subscribe()/unsubscribe() hit an AttributeError the
    # first time it actually stopped a durable push consumer).
    assert isinstance(handle, JetStreamPushConsumer)
    assert handle.raw_subscription == "sub-handle"
    assert handle.subject == subject
    assert handle.durable == "slack-delivery"
    assert handle.is_closed is False
    kwargs = js.subscribe.await_args.kwargs
    assert js.subscribe.await_args.args[0] == subject.path
    assert kwargs["durable"] == "slack-delivery"
    assert kwargs["manual_ack"] is True
    assert kwargs["stream"] == "3tears-channels-deliver"
    # the consumer config carries the bounded redelivery policy.
    assert kwargs["config"].max_deliver == 5
    assert kwargs["config"].durable_name == "slack-delivery"
    # the cb is WRAPPED (not the raw cb) so the factory owns redelivery.
    assert kwargs["cb"] is not cb


@pytest.mark.asyncio
async def test_jetstream_push_consumer_stop_unsubscribes_raw_subscription() -> None:
    """stop() drops the underlying nats-py subscription directly -- the fix's whole point.

    regression test for the bug this shard fixes: jetstream_subscribe_durable()
    used to return the raw nats-py subscription object directly, which callers
    following subscribe()'s documented pattern passed to
    NatsClient.unsubscribe() -- raising AttributeError, since that method
    expects a Subscription (.is_closed / .mark_closed()), not the raw
    object. JetStreamPushConsumer.stop() is the correct, documented
    teardown path instead.
    """
    client, js = _client_with_js()
    raw_sub = AsyncMock()
    js.subscribe = AsyncMock(return_value=raw_sub)

    handle = await client.jetstream_subscribe_durable(
        subject=Subjects.channels_deliver("slack"),
        durable="slack-delivery",
        cb=AsyncMock(),
        max_deliver=5,
    )

    await handle.stop()

    raw_sub.unsubscribe.assert_awaited_once()
    assert handle.is_closed is True


@pytest.mark.asyncio
async def test_jetstream_push_consumer_stop_is_idempotent() -> None:
    """a second stop() call is a no-op, matching Subscription.unsubscribe()'s own contract."""
    client, js = _client_with_js()
    raw_sub = AsyncMock()
    js.subscribe = AsyncMock(return_value=raw_sub)

    handle = await client.jetstream_subscribe_durable(
        subject=Subjects.channels_deliver("slack"),
        durable="slack-delivery",
        cb=AsyncMock(),
        max_deliver=5,
    )

    await handle.stop()
    await handle.stop()

    raw_sub.unsubscribe.assert_awaited_once()


@pytest.mark.asyncio
async def test_jetstream_push_consumer_stop_swallows_raw_unsubscribe_failure() -> None:
    """a raw nats-py unsubscribe failure is logged, not raised -- best-effort cleanup."""
    client, js = _client_with_js()
    raw_sub = AsyncMock()
    raw_sub.unsubscribe = AsyncMock(side_effect=RuntimeError("connection already closed"))
    js.subscribe = AsyncMock(return_value=raw_sub)

    handle = await client.jetstream_subscribe_durable(
        subject=Subjects.channels_deliver("slack"),
        durable="slack-delivery",
        cb=AsyncMock(),
        max_deliver=5,
    )

    await handle.stop()  # must not raise

    assert handle.is_closed is True


@pytest.mark.asyncio
async def test_durable_requires_positive_max_deliver() -> None:
    """max_deliver < 1 is rejected: a durable consumer cannot redeliver forever."""
    client, _js = _client_with_js()
    with pytest.raises(ValueError, match="max_deliver"):
        await client.jetstream_subscribe_durable(
            subject=Subjects.channels_deliver("slack"),
            durable="d",
            cb=AsyncMock(),
            max_deliver=0,
        )


@pytest.mark.asyncio
async def test_durable_cb_success_defers_ack_to_handler() -> None:
    """on success the wrapper just runs the handler (which owns the ack)."""
    client, js = _client_with_js()
    cb = AsyncMock()
    await client.jetstream_subscribe_durable(
        subject=Subjects.channels_deliver("slack"),
        durable="d",
        cb=cb,
        max_deliver=5,
    )
    wrapped = js.subscribe.await_args.kwargs["cb"]
    msg = _fake_js_msg()
    await wrapped(msg)
    cb.assert_awaited_once_with(msg)
    msg.nak.assert_not_awaited()
    msg.ack.assert_not_awaited()  # the handler acks, not the wrapper


@pytest.mark.asyncio
async def test_durable_cb_naks_with_backoff_before_budget() -> None:
    """a retryable raise on an early attempt naks with a backoff delay."""
    client, js = _client_with_js()
    cb = AsyncMock(side_effect=RuntimeError("slack 429"))
    await client.jetstream_subscribe_durable(
        subject=Subjects.channels_deliver("slack"),
        durable="d",
        cb=cb,
        max_deliver=5,
        dead_letter_subject=Subjects.channels_deliver("deadletter"),
    )
    wrapped = js.subscribe.await_args.kwargs["cb"]
    msg = _fake_js_msg(num_delivered=1)
    await wrapped(msg)
    msg.nak.assert_awaited_once()
    assert msg.nak.await_args.kwargs.get("delay", 0) > 0
    msg.ack.assert_not_awaited()
    js.publish.assert_not_awaited()  # not yet at the budget -> no dead-letter


@pytest.mark.asyncio
async def test_durable_cb_dead_letters_at_budget() -> None:
    """a retryable raise on the LAST attempt parks the payload + acks (one alert)."""
    client, js = _client_with_js()
    cb = AsyncMock(side_effect=RuntimeError("channel deleted"))
    dlq = Subjects.channels_deliver("deadletter")
    await client.jetstream_subscribe_durable(
        subject=Subjects.channels_deliver("slack"),
        durable="d",
        cb=cb,
        max_deliver=5,
        dead_letter_subject=dlq,
    )
    wrapped = js.subscribe.await_args.kwargs["cb"]
    msg = _fake_js_msg(data=b"poison", num_delivered=5)
    await wrapped(msg)
    # parked on the dead-letter subject, then acked so it leaves the live consumer.
    js.publish.assert_awaited_once_with(dlq.path, b"poison")
    msg.ack.assert_awaited_once()
    msg.nak.assert_not_awaited()


# ---------------------------------------------------------------------------
# JetStream SHARED PULL consumer (horizontally-scalable delivery worker)
# ---------------------------------------------------------------------------


def _pull_js(*, psub: Any) -> tuple[NatsClient, Any]:
    """build a client whose JetStream context returns a mock with pull_subscribe."""
    client, js = _client_with_js()
    js.pull_subscribe = AsyncMock(return_value=psub)
    return client, js


@pytest.mark.asyncio
async def test_pull_subscribe_binds_shared_durable_filtered_manual_ack() -> None:
    """the pull consumer binds the shared durable with a filter subject + bounded max_deliver."""
    psub = MagicMock()
    client, js = _pull_js(psub=psub)
    subject = Subjects.channels_deliver("slack")

    consumer = await client.jetstream_pull_subscribe(
        subject=subject,
        durable="channel-delivery-slack",
        cb=AsyncMock(),
        max_deliver=5,
        dead_letter_subject=Subjects.channels_deliver("deadletter"),
        stream="3tears-channels-deliver",
    )

    kwargs = js.pull_subscribe.await_args.kwargs
    assert js.pull_subscribe.await_args.args[0] == subject.path
    assert kwargs["durable"] == "channel-delivery-slack"
    assert kwargs["stream"] == "3tears-channels-deliver"
    config = kwargs["config"]
    assert config.durable_name == "channel-delivery-slack"
    assert config.max_deliver == 5
    # FILTER on the exact subject so the sibling deadletter subject on the same
    # stream is not re-drained by this consumer.
    assert config.filter_subject == subject.path
    from threetears.nats import JetStreamPullConsumer

    assert isinstance(consumer, JetStreamPullConsumer)


@pytest.mark.asyncio
async def test_pull_subscribe_requires_positive_max_deliver() -> None:
    """max_deliver < 1 is rejected: a durable consumer cannot redeliver forever."""
    psub = MagicMock()
    client, _js = _pull_js(psub=psub)
    with pytest.raises(ValueError, match="max_deliver"):
        await client.jetstream_pull_subscribe(
            subject=Subjects.channels_deliver("slack"),
            durable="d",
            cb=AsyncMock(),
            max_deliver=0,
        )


@pytest.mark.asyncio
async def test_pull_fetch_dispatches_each_message_success() -> None:
    """fetch_and_process runs the handler for every fetched message (handler acks)."""
    m1 = _fake_js_msg(data=b"a")
    m2 = _fake_js_msg(data=b"b")
    psub = MagicMock()
    psub.fetch = AsyncMock(return_value=[m1, m2])
    client, _js = _pull_js(psub=psub)
    cb = AsyncMock()

    consumer = await client.jetstream_pull_subscribe(
        subject=Subjects.channels_deliver("slack"),
        durable="d",
        cb=cb,
        max_deliver=5,
    )
    processed = await consumer.fetch_and_process()

    assert processed == 2
    assert cb.await_count == 2
    # the wrapper does not ack on success -- the handler owns the ack.
    m1.ack.assert_not_awaited()


@pytest.mark.asyncio
async def test_pull_fetch_timeout_is_idle_zero() -> None:
    """a fetch that times out with no message is the idle case: returns 0, not an error."""
    from nats.errors import TimeoutError as NatsTimeoutError

    psub = MagicMock()
    psub.fetch = AsyncMock(side_effect=NatsTimeoutError())
    client, _js = _pull_js(psub=psub)

    consumer = await client.jetstream_pull_subscribe(
        subject=Subjects.channels_deliver("slack"),
        durable="d",
        cb=AsyncMock(),
        max_deliver=5,
    )
    processed = await consumer.fetch_and_process()

    assert processed == 0


@pytest.mark.asyncio
async def test_pull_handler_raise_naks_with_backoff_before_budget() -> None:
    """a handler raise on an early attempt naks-with-backoff (shared policy), never propagates."""
    msg = _fake_js_msg(num_delivered=1)
    psub = MagicMock()
    psub.fetch = AsyncMock(return_value=[msg])
    client, js = _pull_js(psub=psub)
    cb = AsyncMock(side_effect=RuntimeError("discord 500"))

    consumer = await client.jetstream_pull_subscribe(
        subject=Subjects.channels_deliver("discord"),
        durable="d",
        cb=cb,
        max_deliver=5,
        dead_letter_subject=Subjects.channels_deliver("deadletter"),
    )
    await consumer.fetch_and_process()

    msg.nak.assert_awaited_once()
    assert msg.nak.await_args.kwargs.get("delay", 0) > 0
    msg.ack.assert_not_awaited()
    js.publish.assert_not_awaited()


@pytest.mark.asyncio
async def test_pull_handler_raise_dead_letters_at_budget() -> None:
    """a handler raise on the LAST attempt parks the payload + acks (shared policy)."""
    msg = _fake_js_msg(data=b"poison", num_delivered=5)
    psub = MagicMock()
    psub.fetch = AsyncMock(return_value=[msg])
    client, js = _pull_js(psub=psub)
    dlq = Subjects.channels_deliver("deadletter")

    consumer = await client.jetstream_pull_subscribe(
        subject=Subjects.channels_deliver("discord"),
        durable="d",
        cb=AsyncMock(side_effect=RuntimeError("channel gone")),
        max_deliver=5,
        dead_letter_subject=dlq,
    )
    await consumer.fetch_and_process()

    js.publish.assert_awaited_once_with(dlq.path, b"poison")
    msg.ack.assert_awaited_once()


@pytest.mark.asyncio
async def test_pull_stop_unsubscribes_and_halts_run() -> None:
    """stop() unsubscribes the pull consumer and ends the run loop."""
    psub = MagicMock()
    psub.fetch = AsyncMock(return_value=[])
    psub.unsubscribe = AsyncMock()
    client, _js = _pull_js(psub=psub)

    consumer = await client.jetstream_pull_subscribe(
        subject=Subjects.channels_deliver("slack"),
        durable="d",
        cb=AsyncMock(),
        max_deliver=5,
    )
    await consumer.stop()
    await consumer.run()  # already stopped -> returns immediately

    psub.unsubscribe.assert_awaited_once()


@pytest.mark.asyncio
async def test_pull_run_survives_transport_error_and_retries(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """REGRESSION: a non-timeout transport error from fetch must NOT kill run(); it retries.

    run() had no guard, so a ConnectionClosedError from fetch (or from nak/ack/publish inside
    redelivery) during a NATS reconnect escaped and silently killed the unsupervised consumer task --
    channel delivery then stopped until the pod restarted. run() must catch, pace, and retry instead.
    """
    import threetears.nats.client as client_module
    from nats.errors import ConnectionClosedError

    # avoid the real 1s error-pace so the test is fast.
    monkeypatch.setattr(client_module, "_PULL_CONSUMER_ERROR_BACKOFF_SECONDS", 0.0)

    calls = {"n": 0}
    psub = MagicMock()
    psub.unsubscribe = AsyncMock()

    async def _fetch(_batch: int, timeout: float) -> list[Any]:
        calls["n"] += 1
        if calls["n"] == 1:
            raise ConnectionClosedError()  # the reconnect-window transport error
        consumer._stopped = True  # noqa: SLF001  # second cycle: end the loop cleanly
        return []

    psub.fetch = _fetch
    client, _js = _pull_js(psub=psub)

    consumer = await client.jetstream_pull_subscribe(
        subject=Subjects.channels_deliver("slack"),
        durable="d",
        cb=AsyncMock(),
        max_deliver=5,
    )

    # run() must return normally (not propagate the transport error) after retrying past it.
    await asyncio.wait_for(consumer.run(), timeout=5.0)
    assert calls["n"] >= 2  # it retried after the transport error rather than dying


# ---------------------------------------------------------------------------
# reconnect callback fan-out (consumer-registered post-reconnect hooks)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_add_reconnect_callback_runs_on_reconnect(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """a callback registered via ``add_reconnect_callback`` fires when nats-py reconnects.

    nats-py exposes a single ``reconnected_cb`` slot; the wrapper installs a dispatcher there at
    connect time and fans each reconnect out to consumer-registered callbacks. this is the seam the
    agent SDK uses to re-handshake (re-mint its short-lived identity token + re-establish the Hub
    session) after an outage, so tool calls resume immediately instead of failing until the next
    scheduled refresh.
    """
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    client = await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        verify_jetstream=False,
    )

    hook = AsyncMock()
    client.add_reconnect_callback(hook)

    # the dispatcher nats-py would invoke on reconnect is whatever reached the connect options.
    dispatcher = captured["options"]["reconnected_cb"]
    await dispatcher()

    hook.assert_awaited_once_with()


@pytest.mark.asyncio
async def test_reconnect_dispatcher_isolates_failing_callback(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """a callback that raises is logged but does not abort the others or the reconnect path."""
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    client = await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        verify_jetstream=False,
    )

    boom = AsyncMock(side_effect=RuntimeError("hook blew up"))
    after = AsyncMock()
    client.add_reconnect_callback(boom)
    client.add_reconnect_callback(after)

    dispatcher = captured["options"]["reconnected_cb"]
    # must not raise even though the first hook does.
    await dispatcher()

    boom.assert_awaited_once_with()
    after.assert_awaited_once_with()


def test_is_authorization_violation_detects_the_server_rejection() -> None:
    """the wedged-auth signal is matched on the server's -ERR text, robust across nats-py versions."""
    from threetears.nats.client import _is_authorization_violation

    assert _is_authorization_violation(Exception("nats: 'Authorization Violation'"))
    assert _is_authorization_violation(Exception("AUTHORIZATION VIOLATION"))
    assert not _is_authorization_violation(OSError("connection reset by peer"))


def test_is_authorization_violation_detects_typed_authorization_error() -> None:
    """nats-py's typed AuthorizationError (str: 'nats: authorization failed') must also trip the signal.

    Its message shares no substring with 'authorization violation', so the reconnect-loop -ERR match
    alone would miss it — detecting it by type (and its own text) keeps the wedge signal alive if a
    future nats-py routes the typed error to error_cb instead of the generic -ERR string."""
    from nats.errors import AuthorizationError

    from threetears.nats.client import _is_authorization_violation

    assert _is_authorization_violation(AuthorizationError())
    assert _is_authorization_violation(Exception("nats: authorization failed"))


@pytest.mark.asyncio
async def test_is_healthy_trips_on_persistent_auth_violation(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """a persistent Authorization-Violation reconnect loop flips is_healthy False (so a /healthz keyed
    on it trips and k8s restarts the pod); a successful reconnect clears it; a network drop never does."""
    import threetears.nats.client as client_module

    captured: dict[str, Any] = {}

    async def _fake_establish(servers: list[str], options: dict[str, Any], nats_url: str) -> Any:
        captured["options"] = options
        return MagicMock()

    monkeypatch.setattr(client_module, "_establish_connection", _fake_establish)

    client = await NatsClient.connect(
        nats_url="nats://localhost:4222",
        nats_subject_namespace="3tears",
        client_name="agent-x",
        verify_jetstream=False,
    )
    error_cb = captured["options"]["error_cb"]
    reconnected_cb = captured["options"]["reconnected_cb"]

    assert client.is_healthy  # a fresh connection is healthy

    # a network drop (non-auth error) never trips health, no matter how many land.
    for _ in range(5):
        await error_cb(OSError("connection reset"))
    assert client.is_healthy

    auth_violation = Exception("nats: 'Authorization Violation'")
    # below the threshold (3): still healthy.
    await error_cb(auth_violation)
    await error_cb(auth_violation)
    assert client.is_healthy

    # crossing the threshold: unhealthy -- the connection is stuck being auth-rejected.
    await error_cb(auth_violation)
    assert not client.is_healthy

    # a successful reconnect clears the wedged-auth signal.
    await reconnected_cb()
    assert client.is_healthy


# "nobody is subscribed" and "somebody is, but did not answer" are different
# operational facts: the first points at a service that never started or is on
# another subject/namespace, the second at one that is present and wedged. Both
# used to arrive as a bare RequestError carrying only a message string, so a
# caller wanting to tell them apart had to match on that text.
#
# The caller that forced this: an agent registering with the agent router at boot.
# On a cold rollout the router may not have subscribed yet -- an ordinary race
# worth waiting out -- whereas a transport failure is not. Both subclasses still
# ARE RequestError, so every existing `except RequestError` is unaffected.


@pytest.mark.asyncio
async def test_no_responders_raises_the_no_responders_subclass() -> None:
    from nats.errors import NoRespondersError as NatsNoResponders

    class _NoRespondersClient(_FakeNatsPyClient):
        async def request(self, subject: str, payload: bytes, timeout: float) -> _FakeMsg:
            raise NatsNoResponders()

    client = NatsClient(raw=_NoRespondersClient(), namespace="3tears", client_name="t")  # type: ignore[arg-type]
    with pytest.raises(NoRespondersError) as exc_info:
        await client.request_raw(
            subject=Subjects.tools_call(),
            payload=b"x",
            timeout=timedelta(seconds=1),
        )
    assert "no responders" in str(exc_info.value)


@pytest.mark.asyncio
async def test_timeout_raises_the_timeout_subclass() -> None:
    from nats.errors import TimeoutError as NatsTimeout

    class _TimeoutClient(_FakeNatsPyClient):
        async def request(self, subject: str, payload: bytes, timeout: float) -> _FakeMsg:
            raise NatsTimeout()

    client = NatsClient(raw=_TimeoutClient(), namespace="3tears", client_name="t")  # type: ignore[arg-type]
    with pytest.raises(RequestTimeoutError):
        await client.request_raw(
            subject=Subjects.tools_call(),
            payload=b"x",
            timeout=timedelta(seconds=1),
        )


@pytest.mark.asyncio
async def test_the_two_conditions_are_distinguishable_from_each_other() -> None:
    """The whole point: catching one must not catch the other."""
    from nats.errors import NoRespondersError as NatsNoResponders

    class _NoRespondersClient(_FakeNatsPyClient):
        async def request(self, subject: str, payload: bytes, timeout: float) -> _FakeMsg:
            raise NatsNoResponders()

    client = NatsClient(raw=_NoRespondersClient(), namespace="3tears", client_name="t")  # type: ignore[arg-type]
    with pytest.raises(NoRespondersError):
        await client.request_raw(subject=Subjects.tools_call(), payload=b"x", timeout=timedelta(seconds=1))
    # and it is NOT a timeout
    try:
        await client.request_raw(subject=Subjects.tools_call(), payload=b"x", timeout=timedelta(seconds=1))
    except RequestTimeoutError:  # pragma: no cover - would be the regression
        pytest.fail("no-responders must not surface as RequestTimeoutError")
    except NoRespondersError:
        pass


@pytest.mark.asyncio
async def test_both_subclasses_remain_catchable_as_request_error() -> None:
    """Existing `except RequestError` call sites must be unaffected -- this is a
    refinement of the hierarchy, not a breaking rename."""
    from nats.errors import NoRespondersError as NatsNoResponders

    class _NoRespondersClient(_FakeNatsPyClient):
        async def request(self, subject: str, payload: bytes, timeout: float) -> _FakeMsg:
            raise NatsNoResponders()

    client = NatsClient(raw=_NoRespondersClient(), namespace="3tears", client_name="t")  # type: ignore[arg-type]
    with pytest.raises(RequestError):
        await client.request_raw(subject=Subjects.tools_call(), payload=b"x", timeout=timedelta(seconds=1))
    assert issubclass(NoRespondersError, RequestError)
    assert issubclass(RequestTimeoutError, RequestError)


@pytest.mark.asyncio
async def test_the_typed_request_path_also_raises_the_subclasses() -> None:
    """`request(subject=, message=, response_type=)` goes through request_raw, so
    it must carry the same distinction -- that is the path agent registration uses."""
    from nats.errors import NoRespondersError as NatsNoResponders

    class _NoRespondersClient(_FakeNatsPyClient):
        async def request(self, subject: str, payload: bytes, timeout: float) -> _FakeMsg:
            raise NatsNoResponders()

    client = NatsClient(raw=_NoRespondersClient(), namespace="3tears", client_name="t")  # type: ignore[arg-type]
    with pytest.raises(NoRespondersError):
        await client.request(
            subject=Subjects.tools_call(),
            message=_Hello(greeting="x", count=1),
            response_type=_Reply,
        )
