"""Repro tests for the WeCom native-streaming "同一回复发两条" duplicate.

Root cause (see docs/rca-wecom-stream-final-ack-timeout-duplicate.md and
/tmp/claude-wecom-dup-report.md): a **timeout inversion race** between two
independent timers.

  * The consumer's got_done finalize blocks on the finalize frame's ack,
    bounded by ``adapter._REPLY_ACK_TIMEOUT``.
  * The gateway's ``finally`` block joins the ``stream_task`` with a hardcoded
    ``timeout=5.0`` (``gateway/run.py:20749``) and then ``stream_task.cancel()``
    (``run.py:20751``).

When the ack is slower than the gateway's join window, the gateway cancels the
consumer *before* it finishes finalizing. The finalize frame's bytes, however,
were already written to the wire by an **independent control-worker task**
(``_control_send_worker`` / ``_enqueue_chat_send(is_control=True)``) and WeCom
has rendered them. But because the consumer was cancelled mid-await, the
``self._final_content_delivered = True`` line (``stream_consumer.py:2061`` /
``1016``) never runs. The gateway then reads ``final_content_delivered=False``
(``run.py:20801-20803``), does NOT suppress the normal final send
(``run.py:20819``), and emits a second, duplicate bubble.

These tests exercise the **real** ``GatewayStreamConsumer.run()`` lifecycle
against the **real** ``WeComAdapter`` (only the websocket byte-writer and the
ack timing are controlled), so the async interaction between the gateway join /
cancel and the consumer finalize / flag-set actually happens — this is what the
previous PR (#62861) failed to test when it mocked out ``handle_message``.

Assertions target observable causality:
  * whether the finalize frame's bytes reached the wire (WeCom rendered it);
  * the value of ``consumer.final_content_delivered`` (the flag the gateway
    reads);
  * whether the gateway's suppression predicate fires (i.e. whether a second
    normal send would go out).

The suppression predicate below is a faithful mirror of the (non-importable,
nested) logic in ``gateway/run.py:20793-20819`` — it reads the *real* consumer
properties, it does not re-implement the delivery lifecycle.
"""

from __future__ import annotations

import asyncio
from unittest.mock import MagicMock

import pytest

from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig


CHAT_ID = "chat-1"
REQ_ID = "req-1"


# ---------------------------------------------------------------------------
# Faithful mirrors of the real gateway control flow (cite source lines).
# ---------------------------------------------------------------------------


def _gateway_suppresses_normal_send(consumer, final_text: str) -> bool:
    """Mirror of gateway/run.py:20793-20819 suppression decision.

    Reads the REAL consumer properties (``final_response_sent`` /
    ``final_content_delivered``) — the same attributes ``run.py`` consults —
    and returns True when the gateway would set ``already_sent`` and skip the
    normal final send. When it returns False the gateway emits a second bubble.
    """
    _final = final_text or ""
    _is_empty_sentinel = not _final or _final == "(empty)"
    # run.py:20814 _stream_confirmed_final_delivery(...) with previewed=False
    # collapses to final_response_sent for a non-previewed response.
    _streamed = bool(getattr(consumer, "final_response_sent", False))
    _content_delivered = bool(getattr(consumer, "final_content_delivered", False))
    _transformed = False  # no plugin transform in these scenarios
    return (
        not _is_empty_sentinel
        and not _transformed
        and (_streamed or _content_delivered)
    )


async def _gateway_join_and_cancel(stream_task: asyncio.Task, join_timeout: float) -> None:
    """Faithful copy of gateway/run.py:20748-20755 finally-block join.

    Joins the stream task with a bounded timeout; on timeout it cancels the
    task (exactly what the gateway does when the consumer has not finished
    finalizing within the join window).
    """
    try:
        await asyncio.wait_for(stream_task, timeout=join_timeout)
    except (asyncio.TimeoutError, asyncio.CancelledError):
        stream_task.cancel()
        try:
            await stream_task
        except asyncio.CancelledError:
            pass


# ---------------------------------------------------------------------------
# Real WeComAdapter with a controllable websocket + ack timing.
# ---------------------------------------------------------------------------


def _make_real_wecom_adapter(*, resolve_finalize_ack: bool):
    """Build a real ``WeComAdapter`` whose only fakes are the WS byte-writer
    and the ack timing.

    ``_send_json`` (the actual byte-writer) records every frame so we can prove
    the finalize frame reached the wire. The ack for **non-final** frames (seed
    / intermediate) is always resolved immediately so the finalize's
    pre-drain (``_send_reply_queued`` is_final branch) doesn't block on the
    seed's ack. The finalize frame's own ack is resolved immediately only when
    ``resolve_finalize_ack`` is True.

      * ``resolve_finalize_ack=False`` → finalize ack stays pending → the
        consumer blocks in its got_done finalize (the exact window where the
        gateway's join fires and cancels it). This is the bug scenario.
      * ``resolve_finalize_ack=True``  → finalize ack returns at once → the
        consumer finishes finalize and sets its flags before any join fires.
    """
    from plugins.platforms.wecom.adapter import WeComAdapter
    from gateway.config import PlatformConfig

    adapter = WeComAdapter(PlatformConfig(enabled=True))
    adapter._ws = MagicMock(closed=False)
    adapter._last_chat_req_ids[CHAT_ID] = REQ_ID

    frames: list[dict] = []

    async def _fake_send_json(payload: dict) -> None:
        # This is the real byte-writer boundary: reaching here means the frame
        # was put on the wire and WeCom will render it.
        frames.append(payload)
        stream = payload.get("body", {}).get("stream", {})
        finish = bool(stream.get("finish"))
        req = payload.get("headers", {}).get("req_id")

        should_resolve = (not finish) or resolve_finalize_ack
        if should_resolve:
            queue = adapter._reply_queues.get(req)
            if queue and queue.pending_ack and not queue.pending_ack.future.done():
                # Simulate WeCom's ack coming back on the same WS.
                queue.pending_ack.future.set_result({"body": {"errcode": 0}})

    adapter._send_json = _fake_send_json
    adapter._recorded_frames = frames
    return adapter


def _finalize_frames_on_wire(adapter) -> list[dict]:
    """Return the finish=true stream frames that reached the byte-writer."""
    out = []
    for payload in adapter._recorded_frames:
        stream = payload.get("body", {}).get("stream", {})
        if stream.get("finish") is True:
            out.append(payload)
    return out


async def _cleanup_adapter(adapter) -> None:
    """Cancel any lingering control/normal workers so the event loop is clean."""
    for task in list(adapter._control_workers.values()) + list(adapter._chat_workers.values()):
        task.cancel()
        try:
            await task
        except (asyncio.CancelledError, Exception):
            pass


# ===========================================================================
# Test group 1: timeout-inversion race (core reproduction)
# ===========================================================================


class TestTimeoutInversionDoubleSend:
    """The finalize ack being slower than the gateway join window must not
    strand ``final_content_delivered=False`` while WeCom already rendered the
    finalize frame — that produces a duplicate bubble."""

    @pytest.mark.asyncio
    async def test_slow_ack_beyond_gateway_join_causes_double_send(self):
        """ack slower than the gateway join → consumer cancelled mid-finalize.

        Timeline (deterministic, no wall-clock sleeps):
          * seed + finalize frame bytes are written to the wire;
          * the finalize ack never returns within the join window
            (``resolve_finalize_ack=False``, ``_REPLY_ACK_TIMEOUT`` > join);
          * the gateway join (0.1s) fires and cancels the consumer;
          * the consumer's got_done finalize await is cancelled BEFORE the
            ``final_content_delivered = True`` line runs.

        The finalize frame is on the wire (rendered), yet the gateway reads
        ``final_content_delivered=False`` and would send a normal duplicate.

        This test asserts the DESIRED post-fix state, so it FAILS today
        (exposing the double send) and PASSES once the flag reflects the
        rendered finalize frame.
        """
        adapter = _make_real_wecom_adapter(resolve_finalize_ack=False)
        # Inversion: ack window (0.3s) is LARGER than the gateway join (0.1s),
        # so the join fires while the finalize ack is still pending.
        adapter._REPLY_ACK_TIMEOUT = 0.3
        join_timeout = 0.1

        cfg = StreamConsumerConfig(
            chat_type="dm", cursor="", edit_interval=0.01, buffer_threshold=5,
        )
        consumer = GatewayStreamConsumer(adapter, CHAT_ID, cfg)

        final_text = "这是模型这一轮生成的最终回答，需要作为流式 finalize 帧发出。"
        consumer.on_delta(final_text)
        consumer.finish()

        stream_task = asyncio.create_task(consumer.run())
        try:
            # Gateway finally-block join (run.py:20748-20755) fires while the
            # finalize ack is still pending, cancelling the consumer.
            await _gateway_join_and_cancel(stream_task, join_timeout)

            finalize_frames = _finalize_frames_on_wire(adapter)

            # The finalize frame reached the wire → WeCom rendered it. This is
            # the first (and should be ONLY) user-visible bubble.
            assert len(finalize_frames) >= 1, (
                "finalize frame never reached the wire — repro precondition "
                "not met (WeCom must have rendered the streamed final)"
            )
            assert final_text in finalize_frames[-1]["body"]["stream"]["content"]

            gateway_would_send_normal = not _gateway_suppresses_normal_send(
                consumer, final_text
            )
            total_user_visible = len(finalize_frames) + (
                1 if gateway_would_send_normal else 0
            )

            # DESIRED post-fix invariants (currently violated == bug reproduced):
            assert consumer.final_content_delivered is True, (
                "BUG: consumer was cancelled mid-finalize; the finalize frame "
                "was rendered by WeCom but final_content_delivered stayed False, "
                "so the gateway will not suppress the normal send"
            )
            assert _gateway_suppresses_normal_send(consumer, final_text) is True, (
                "BUG: gateway does not suppress the normal final send → duplicate"
            )
            assert total_user_visible == 1, (
                f"BUG: user sees {total_user_visible} bubbles for one reply "
                f"(finalize frame rendered + normal send fired)"
            )
        finally:
            await _cleanup_adapter(adapter)

    @pytest.mark.asyncio
    async def test_fast_ack_within_join_window_single_send(self):
        """Control: ack returns within the join window → single bubble.

        The finalize ack resolves immediately, so the consumer completes its
        got_done finalize, sets ``final_content_delivered=True``, and the run
        finishes before the gateway join needs to cancel anything. The gateway
        then suppresses the normal send. Passes today and after the fix
        (regression guard).
        """
        adapter = _make_real_wecom_adapter(resolve_finalize_ack=True)
        adapter._REPLY_ACK_TIMEOUT = 5.0
        join_timeout = 0.5

        cfg = StreamConsumerConfig(
            chat_type="dm", cursor="", edit_interval=0.01, buffer_threshold=5,
        )
        consumer = GatewayStreamConsumer(adapter, CHAT_ID, cfg)

        final_text = "这是一个能在 join 窗口内正常拿到 ack 的回答。"
        consumer.on_delta(final_text)
        consumer.finish()

        stream_task = asyncio.create_task(consumer.run())
        try:
            await _gateway_join_and_cancel(stream_task, join_timeout)

            finalize_frames = _finalize_frames_on_wire(adapter)
            assert len(finalize_frames) == 1

            assert consumer.final_content_delivered is True
            assert _gateway_suppresses_normal_send(consumer, final_text) is True

            gateway_would_send_normal = not _gateway_suppresses_normal_send(
                consumer, final_text
            )
            total_user_visible = len(finalize_frames) + (
                1 if gateway_would_send_normal else 0
            )
            assert total_user_visible == 1
        finally:
            await _cleanup_adapter(adapter)


# ===========================================================================
# Test group 2: best-effort finalize "DO NOT mark" path
# ===========================================================================


class TestBestEffortFinalizeDoubleSend:
    """stream_consumer.py:2064-2094: when native streaming fails mid-turn, the
    consumer sends a best-effort finalize frame to close the bubble but
    deliberately does NOT set ``final_content_delivered`` (the "DO NOT mark"
    comment at 2085-2088). If WeCom actually renders that finalize frame, the
    subsequent fallback send delivers the same content a second time.

    This uses a controllable ``BasePlatformAdapter`` subclass (the accepted
    pattern in tests/gateway/test_wecom_per_turn.py) because the trigger is a
    frame-level failure, not an ack timing race — no wall-clock timing needed.
    """

    @pytest.mark.xfail(
        reason="KNOWN LOW-FREQUENCY ISSUE (accepted, not yet fixed): the "
        "best-effort finalize path (stream_consumer.py:2104-2128) renders the "
        "content AND falls through to a fallback send(), delivering the same "
        "answer twice. Only triggers when native streaming fails MID-STREAM "
        "(846608 expired / 846609 subscription lost / errcode 6000 / network "
        "error) — rare. The duplicate is a SAFE-direction failure (user sees "
        "one extra bubble, no data loss). A clean fix requires reconciling "
        "'do not drop post-finalize increments' with 'do not re-send the "
        "first-send path', which the first-send path (L2378) does not consult "
        "the delivery flag for; deferred over risking increment loss on a rare "
        "path. The high-frequency duplicate (timeout inversion) IS fixed — see "
        "TestTimeoutInversionDoubleSend + commit 7ba739818 (B2).",
        strict=True,
    )
    @pytest.mark.asyncio
    async def test_best_effort_finalize_renders_but_flag_unset_double_send(self):
        """native content frame fails → best-effort finalize renders + fallback send.

        Ordering matters: the delta is pushed and ``run()`` is allowed to
        process a mid-stream **content** frame BEFORE ``finish()`` is queued.
        That content frame fails (returns False), which disables native
        streaming and triggers the best-effort finalize at
        ``stream_consumer.py:2077`` — WeCom renders it — and then the consumer
        falls through to a fallback ``send()`` that delivers the same content a
        second time (the "DO NOT mark" comment at 2085-2088 is what leaves the
        gateway free to re-send).

        The text is > ``_MIN_NEW_VISIBLE_CHARS`` (60) so the mid-stream frame is
        actually attempted rather than buffered.

        Asserts the DESIRED single-delivery state, so it FAILS today (content
        rendered by the best-effort finalize AND re-sent by the fallback) and
        PASSES once the double delivery is closed.
        """
        from gateway.platforms.base import BasePlatformAdapter, SendResult

        class FlakyNativeAdapter(BasePlatformAdapter):
            MAX_MESSAGE_LENGTH = 4096
            SUPPORTS_MESSAGE_EDITING = False
            SUPPORTS_NATIVE_STREAMING = True

            def __init__(self):
                self._typing_paused = set()
                self._fatal_error_message = None
                # Records of everything that reached the user.
                self.rendered_stream_frames: list[dict] = []  # incl. finalize
                self.fallback_sends: list[str] = []
                self._content_frames_seen = 0

            def supports_native_streaming(self, chat_type=None, metadata=None):
                return True

            async def send_stream_frame(
                self, text, *, finalize=False, chat_id=None, reply_to=None, **kwargs
            ):
                # Seed frame (empty, non-finalize) opens the bubble.
                if text == "" and not finalize:
                    self.rendered_stream_frames.append({"text": text, "finalize": False})
                    return True
                if finalize:
                    # Best-effort finalize frame — WeCom DOES render it.
                    self.rendered_stream_frames.append({"text": text, "finalize": True})
                    return True
                # First real content frame fails → disables native streaming
                # and triggers the best-effort finalize path.
                self._content_frames_seen += 1
                return False

            async def send(self, chat_id, content, reply_to=None, metadata=None):
                # The gateway/consumer fallback send — a SECOND delivery of the
                # same content.
                self.fallback_sends.append(content)
                return SendResult(success=True, message_id="fallback-msg")

        FlakyNativeAdapter.__abstractmethods__ = frozenset()
        adapter = FlakyNativeAdapter()

        cfg = StreamConsumerConfig(
            chat_type="dm", cursor="", edit_interval=0.01, buffer_threshold=5,
        )
        consumer = GatewayStreamConsumer(adapter, CHAT_ID, cfg)

        # > 60 visible chars so the mid-stream content frame is actually sent.
        final_text = (
            "需要交付给用户的最终答案。这一段内容刻意写得足够长，"
            "以便越过 stream_consumer 的 _MIN_NEW_VISIBLE_CHARS=60 阈值，"
            "从而在 got_done 之前触发一次真正的流式内容帧发送。"
        )
        assert len(final_text) >= 60

        consumer.on_delta(final_text)
        task = asyncio.create_task(consumer.run())
        # Let run() process the mid-stream content frame (which fails) BEFORE
        # the turn-final _DONE arrives.
        await asyncio.sleep(0.1)
        consumer.finish()
        await task

        best_effort_finalize = [
            f for f in adapter.rendered_stream_frames if f["finalize"] and f["text"]
        ]

        # Count content-bearing user-visible deliveries:
        #   * best-effort finalize frame(s) that WeCom rendered, plus
        #   * fallback send(s).
        content_deliveries = len(best_effort_finalize) + len(adapter.fallback_sends)

        # DESIRED post-fix invariant (currently violated == bug reproduced):
        # the same answer must reach the user exactly once. Today the
        # best-effort finalize renders the content AND the fallback send
        # delivers it again.
        assert content_deliveries == 1, (
            f"BUG: content delivered {content_deliveries} times — best-effort "
            f"finalize rendered {len(best_effort_finalize)} frame(s) and "
            f"fallback send fired {len(adapter.fallback_sends)} time(s) "
            f"(stream_consumer.py:2085-2088 'DO NOT mark' path)"
        )


# ===========================================================================
# Test group 3: orphan-queue ack routing race (adapter-level)
# ===========================================================================


def _make_manual_ack_adapter():
    """Real WeComAdapter whose ``_send_json`` records frames but NEVER
    auto-resolves any ack, so the test can orchestrate the exact interleaving
    of intermediate-ack arrival vs. finalize registration by hand.
    """
    from plugins.platforms.wecom.adapter import WeComAdapter
    from gateway.config import PlatformConfig

    adapter = WeComAdapter(PlatformConfig(enabled=True))
    adapter._ws = MagicMock(closed=False)
    adapter._last_chat_req_ids[CHAT_ID] = REQ_ID

    frames: list[dict] = []

    async def _fake_send_json(payload: dict) -> None:
        frames.append(payload)

    adapter._send_json = _fake_send_json
    adapter._recorded_frames = frames
    return adapter


class TestOrphanQueueAckRouting:
    """The finalize frame shares the inbound req_id with the intermediate
    frames. While the finalize awaits the pending intermediate ack to drain,
    that intermediate ack can arrive and ``_resolve_reply_ack`` pops the WHOLE
    queue out of ``_reply_queues``. Before the fix, the finalize then
    registered its ``pending_ack`` on the now-orphaned queue object (detached
    from the dict), so its own ack landed in Unrouted and the finalize hit a
    15s timeout. The fix re-attaches the queue to the dict before registering.
    """

    @pytest.mark.asyncio
    async def test_intermediate_ack_mid_drain_does_not_orphan_finalize_queue(self):
        adapter = _make_manual_ack_adapter()
        adapter._REPLY_ACK_TIMEOUT = 0.3  # snappy: bounds the failure mode

        # 1) Fire an intermediate frame (skip_if_pending) — occupies pending_ack.
        await adapter._send_reply_queued(
            REQ_ID,
            {"msgtype": "stream", "stream": {"id": "s1", "finish": False, "content": "hi"}},
            is_final=False, skip_if_pending=True,
        )
        inter_queue = adapter._reply_queues[REQ_ID]
        assert inter_queue.pending_ack is not None

        # 2) Start the finalize: it enters the is_final drain branch and yields
        #    at `await shield(pending_frame.future)`.
        fin_task = asyncio.create_task(adapter._send_reply_queued(
            REQ_ID,
            {"msgtype": "stream", "stream": {"id": "s1", "finish": True, "content": "done"}},
            is_final=True, skip_if_pending=False,
        ))
        await asyncio.sleep(0)  # let finalize reach the drain await

        # 3) Deliver the intermediate ack MID-DRAIN. _resolve_reply_ack resolves
        #    the drain future AND pops the whole queue out of _reply_queues.
        await adapter._dispatch_payload(
            {"headers": {"req_id": REQ_ID}, "body": {"errcode": 0}}
        )
        assert REQ_ID not in adapter._reply_queues, (
            "precondition: intermediate ack should have popped the queue"
        )

        # 4) Let finalize resume: it must clear the drained pending_ack, create
        #    its own frame, re-attach the queue, register pending_ack, and write
        #    bytes. Resuming through the shield/wait_for wrapper takes several
        #    event-loop iterations, so yield until the finalize has re-registered
        #    (bounded, to avoid a hang if the fix regresses).
        for _ in range(20):
            await asyncio.sleep(0)
            q = adapter._reply_queues.get(REQ_ID)
            if q is not None and q.pending_ack is not None:
                break

        # FIX ASSERTION: the finalize must have re-attached its queue to the
        # dict, so its ack is routable. Without the fix the queue would still
        # be absent here (orphaned) and the finalize ack would go Unrouted.
        assert REQ_ID in adapter._reply_queues, (
            "orphan-queue bug: finalize registered pending_ack on a queue "
            "detached from _reply_queues — its ack would be Unrouted"
        )
        assert adapter._reply_queues[REQ_ID].pending_ack is not None

        # 5) Deliver the finalize's own ack — it must route and resolve.
        await adapter._dispatch_payload(
            {"headers": {"req_id": REQ_ID}, "body": {"errcode": 0}}
        )
        resp = await asyncio.wait_for(fin_task, timeout=1.0)

        # Routed successfully → NOT the synthetic timeout-assumed response.
        assert resp.get("errmsg") != "ack_timeout_assumed_delivered", (
            "finalize ack should have been routed, not timed out"
        )

        await _cleanup_adapter(adapter)
