"""Slack relay: interactive prompts follow the turn's thread stamp.

The threading MODE (flat DM vs thread-per-message) is decided in exactly ONE
place: run.py's ``_resolve_progress_thread_id``, which reads
``platforms.slack.extra.reply_in_thread`` and encodes the verdict into the
outbound ``metadata`` stamp:

  * flat mode  -> the synthetic self-anchor is suppressed in run.py, so prompt
    metadata arrives with NO ``thread_id`` and the card posts at the DM root;
  * thread-per-message (default) -> ``metadata.thread_id`` is stamped for the
    whole turn; on the FIRST turn it legitimately equals the triggering
    message's ts (the synthetic root IS the thread).

The prompt lane must TRUST that stamp, like ``_resolve_reply_to_for_send``
does. Re-deriving the mode here (the old unconditional
``thread_id == message_id`` strip) exiled the approval card and its
resolved-state swap to the DM root while progress bubbles honoured the thread
(the 2026-07-27 mixed-placement report).

These are behaviour-contract tests: they assert how the outbound ``prompt``
frame relates to the inherited thread metadata (the invariant the connector
depends on), not a snapshot. They drive the REAL ``RelayAdapter`` +
``StubConnector`` end to end.
"""

from __future__ import annotations

import pytest

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.session import SessionSource

from tests.gateway.relay.stub_connector import StubConnector

FULL_OPS = ("send", "edit", "typing", "get_chat_info", "send_media", "prompt", "react")


def _slack_desc(**kw) -> CapabilityDescriptor:
    base = dict(
        contract_version=CONTRACT_VERSION,
        platform="slack",
        label="Slack",
        max_message_length=4000,
        supports_draft_streaming=False,
        supports_edit=True,
        supports_threads=True,
        markdown_dialect="mrkdwn",
        len_unit="chars",
        supported_ops=FULL_OPS,
    )
    base.update(kw)
    return CapabilityDescriptor(**base)


def _wire(
    chat_id: str,
    chat_type: str,
    *,
    user_id="U1",
    scope_id=None,
    platform=Platform.SLACK,
):
    """A RelayAdapter fronting Slack, with inbound scope + chat_type captured."""
    stub = StubConnector(_slack_desc())
    adapter = RelayAdapter(PlatformConfig(), _slack_desc(), transport=stub)
    src = SessionSource(
        platform=platform,
        chat_id=chat_id,
        chat_type=chat_type,
        user_id=user_id,
        scope_id=scope_id,
    )
    adapter._capture_scope(
        MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
    )
    return adapter, stub


def _last_prompt(stub) -> dict:
    prompts = [f for f in stub.sent if f["op"] == "prompt"]
    assert prompts, "expected a prompt op on the wire"
    return prompts[-1]


# ---------------------------------------------------------------------------
# Flat mode: run.py stamps NO thread_id -> the card posts at the DM root.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_exec_approval_flat_mode_posts_at_dm_root():
    """Flat-DM turn (reply_in_thread=false): run.py suppressed the synthetic
    anchor upstream, so prompt metadata has no thread_id and none appears on
    the wire — the card posts at the DM root."""
    adapter, stub = _wire("D1", "dm", scope_id="T1")
    md = {"message_id": "1700000000.000100", "scope_id": "T1"}
    result = await adapter.send_exec_approval(
        "D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md
    )
    assert result.success is True
    frame = _last_prompt(stub)
    meta = frame["metadata"] or {}
    assert "thread_id" not in meta
    assert "thread_ts" not in meta
    # reply_to on the outbound action stays unset — a root-level post.
    assert frame["reply_to"] is None
    # Tenant scope is preserved untouched (egress routing must not break).
    assert meta.get("scope_id") == "T1"


# ---------------------------------------------------------------------------
# Thread-per-message mode, end-to-end placement contract: run.py stamps the
# turn's thread (first turn: the triggering message's own ts) and the adapter
# forwards prompt metadata UNTOUCHED — no re-derivation, no strip. Mixed
# placement (progress threaded, card at root) was the 2026-07-27 regression.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_exec_approval_forwards_run_py_thread_stamp_untouched():
    """The adapter must forward run.py's thread stamp verbatim: the approval
    card posts INTO the stamped thread. Any adapter-side re-derivation or
    strip exiled the card to the home channel (2026-07-27 report)."""
    adapter, stub = _wire("D1", "dm", scope_id="T1")
    md = {
        "thread_id": "1700000000.000100",
        "message_id": "1700000000.000100",
        "scope_id": "T1",
    }
    result = await adapter.send_exec_approval(
        "D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md
    )
    assert result.success is True
    frame = _last_prompt(stub)
    meta = frame["metadata"] or {}
    assert meta.get("thread_id") == "1700000000.000100", (
        "first-turn self-anchor is the thread root; the prompt must honour it"
    )
    assert meta.get("scope_id") == "T1"


@pytest.mark.asyncio
async def test_clarify_forwards_run_py_thread_stamp_untouched():
    adapter, stub = _wire("D1", "dm", scope_id="T1")
    md = {
        "thread_id": "1700000000.000200",
        "message_id": "1700000000.000200",
        "scope_id": "T1",
    }
    result = await adapter.send_clarify(
        "D1", "Which env?", ["prod", "staging"], "cl-1", "sess:1", metadata=md
    )
    assert result.success is True
    frame = _last_prompt(stub)
    meta = frame["metadata"] or {}
    assert meta.get("thread_id") == "1700000000.000200"
    assert meta.get("scope_id") == "T1"


@pytest.mark.asyncio
async def test_slash_confirm_forwards_run_py_thread_stamp_untouched():
    """The forward-untouched rule covers every prompt surface (single
    _send_prompt choke point)."""
    adapter, stub = _wire("D1", "dm")
    md = {"thread_id": "1700000000.000300", "message_id": "1700000000.000300"}
    await adapter.send_slash_confirm(
        "D1", "Reload MCP", "invalidates cache", "s", "cf-1", metadata=md
    )
    frame = _last_prompt(stub)
    assert (frame["metadata"] or {}).get("thread_id") == "1700000000.000300"


# ---------------------------------------------------------------------------
# Regression guards: a REAL thread and non-DM / non-Slack chats are untouched
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_exec_approval_in_real_thread_keeps_thread_id():
    """A DM prompt raised inside a REAL thread (thread_id distinct from the
    triggering message ts) stays in that thread."""
    adapter, stub = _wire("D1", "dm", scope_id="T1")
    md = {
        "thread_id": "1699000000.999000",
        "message_id": "1700000000.000100",
        "scope_id": "T1",
    }
    await adapter.send_exec_approval("D1", "cmd", "s", metadata=md)
    frame = _last_prompt(stub)
    assert frame["metadata"]["thread_id"] == "1699000000.999000"


@pytest.mark.asyncio
async def test_channel_approval_keeps_thread_id():
    """A Slack CHANNEL prompt keeps its thread_id (autoThread / real thread)."""
    adapter, stub = _wire("C1", "channel", scope_id="T1")
    md = {
        "thread_id": "1700000000.000400",
        "message_id": "1700000000.000400",
        "scope_id": "T1",
    }
    await adapter.send_exec_approval("C1", "cmd", "s", metadata=md)
    frame = _last_prompt(stub)
    assert frame["metadata"]["thread_id"] == "1700000000.000400"


@pytest.mark.asyncio
async def test_non_slack_dm_approval_keeps_thread_id():
    """A non-Slack relay DM keeps thread_id (its connector owns its own
    threading semantics)."""
    adapter, stub = _wire("dc1", "dm", platform=Platform.DISCORD)
    md = {"thread_id": "9000", "message_id": "9000"}
    await adapter.send_exec_approval("dc1", "cmd", "s", metadata=md)
    frame = _last_prompt(stub)
    assert frame["metadata"]["thread_id"] == "9000"


# ---------------------------------------------------------------------------
# Rich status: the relay advertises Slack's text status line and carries
# the live per-tool phrase on the typing frame (native set_status_text parity).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_slack_relay_advertises_status_text():
    adapter, _stub = _wire("D1", "dm")
    assert adapter.supports_status_text is True


@pytest.mark.asyncio
async def test_non_slack_relay_does_not_advertise_status_text():
    stub = StubConnector(_slack_desc(platform="discord"))
    adapter = RelayAdapter(
        PlatformConfig(), _slack_desc(platform="discord"), transport=stub
    )
    assert adapter.supports_status_text is False


@pytest.mark.asyncio
async def test_typing_carries_live_status_phrase():
    """set_status_text() -> the next typing frame carries the phrase as
    content; clearing it (None) reverts to a content-less heartbeat frame
    (never an empty string, which is Slack's explicit clear)."""
    adapter, stub = _wire("D1", "dm", scope_id="T1")
    adapter.set_status_text("D1", "is running pytest…")
    await adapter.send_typing("D1", metadata={"scope_id": "T1"})
    typing = [f for f in stub.sent if f["op"] == "typing"]
    assert typing and typing[-1].get("content") == "is running pytest…"

    adapter.set_status_text("D1", None)
    await adapter.send_typing("D1", metadata={"scope_id": "T1"})
    typing = [f for f in stub.sent if f["op"] == "typing"]
    assert "content" not in typing[-1], (
        "cleared phrase must omit content (empty string means CLEAR on Slack)"
    )


# ---------------------------------------------------------------------------
# Status thread anchor: typing frames synthesize the per-message thread
# root in thread-per-message mode (the status line is thread-only on Slack).
# ---------------------------------------------------------------------------
def _wire_with_ts(chat_id, chat_type, message_id, **kw):
    adapter, stub = _wire(chat_id, chat_type, **kw)
    src = SessionSource(
        platform=Platform.SLACK, chat_id=chat_id, chat_type=chat_type,
        user_id="U1", scope_id=kw.get("scope_id"),
    )
    ev = MessageEvent(
        text="hi", source=src, message_type=MessageType.TEXT, message_id=message_id
    )
    adapter._capture_scope(ev)
    return adapter, stub


@pytest.mark.asyncio
async def test_typing_synthesizes_thread_anchor_in_thread_mode():
    """Top-level DM turn, thread-per-message mode: the typing frame gains the
    triggering ts as thread_id so the connector's setStatus targets the
    per-message thread instead of no-oping threadless."""
    adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
    await adapter.send_typing("D1", metadata=None)
    typing = [f for f in stub.sent if f["op"] == "typing"]
    assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"


@pytest.mark.asyncio
async def test_typing_flat_mode_status_anchors_to_trigger_ts_by_default():
    """Flat-DM liveliness: the STATUS still anchors to the triggering ts
    (renders in the footer space, no message artifact) while replies stay
    flat — the send lane strips its anchors, so placement cannot inherit this."""
    adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
    adapter.config.extra = {"reply_in_thread": False}
    await adapter.send_typing("D1", metadata=None)
    typing = [f for f in stub.sent if f["op"] == "typing"]
    assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"


@pytest.mark.asyncio
async def test_typing_anchors_unconditionally_in_both_modes():
    """Liveliness is not a preference: the status anchors whenever an inbound
    ts exists, regardless of reply_in_thread. Placement safety comes from the
    send-side anchor strip, not from suppressing the status."""
    for extra in ({}, {"slack": {"reply_in_thread": False}}):
        adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
        adapter.config.extra = extra
        await adapter.send_typing("D1", metadata=None)
        typing = [f for f in stub.sent if f["op"] == "typing"]
        assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"


@pytest.mark.asyncio
async def test_flat_mode_sends_stay_flat_with_status_anchor_active():
    """The liveliness anchor must NOT leak into reply placement: sends in
    flat mode still strip the synthetic anchor (send-lane contract)."""
    adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
    adapter.config.extra = {"reply_in_thread": False}
    await adapter.send_typing("D1", metadata=None)
    await adapter.send("D1", "the answer", reply_to="1700.0042")
    frame = [f for f in stub.sent if f["op"] == "send"][-1]
    assert frame["reply_to"] is None
    assert "thread_id" not in (frame["metadata"] or {})


@pytest.mark.asyncio
async def test_typing_honours_real_thread_anchor():
    """Metadata that already names a thread wins over the synthetic cache."""
    adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
    await adapter.send_typing("D1", metadata={"thread_id": "1699.9000"})
    typing = [f for f in stub.sent if f["op"] == "typing"]
    assert typing[-1]["metadata"]["thread_id"] == "1699.9000"


@pytest.mark.asyncio
async def test_stop_typing_clear_targets_same_synthesized_thread():
    """The clear frame targets the same synthesized thread as the heartbeat
    (else the status line sticks)."""
    adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
    await adapter.send_typing("D1", metadata=None)
    await adapter.stop_typing("D1", metadata=None)
    clears = [
        f for f in stub.sent if f["op"] == "typing" and f.get("content") == ""
    ]
    assert clears and clears[-1]["metadata"].get("thread_id") == "1700.0042"


# ---------------------------------------------------------------------------
# Session keying: a top-level Slack DM message gets its own ts stamped as
# source.thread_id (native inbound parity) so each message keys a FRESH
# session in thread-per-message mode; flat mode and real threads untouched.
# ---------------------------------------------------------------------------
def _inbound_event(chat_id="D1", message_id="1700.0100", thread_id=None):
    src = SessionSource(
        platform=Platform.SLACK, chat_id=chat_id, chat_type="dm",
        user_id="U1", scope_id="T1", thread_id=thread_id,
    )
    return MessageEvent(
        text="hi", source=src, message_type=MessageType.TEXT,
        message_id=message_id,
    )


def test_top_level_dm_gets_session_thread_stamp():
    adapter, _ = _wire("D1", "dm")
    ev = _inbound_event(message_id="1700.0100")
    adapter._stamp_slack_session_thread(ev)
    assert ev.source.thread_id == "1700.0100"


def test_two_top_level_messages_key_distinct_sessions():
    from gateway.session import build_session_key
    adapter, _ = _wire("D1", "dm")
    e1 = _inbound_event(message_id="1700.0100")
    e2 = _inbound_event(message_id="1700.0200")
    adapter._stamp_slack_session_thread(e1)
    adapter._stamp_slack_session_thread(e2)
    k1 = build_session_key(e1.source)
    k2 = build_session_key(e2.source)
    assert k1 != k2, "each top-level message must be its own session"


def test_real_thread_reply_keeps_its_thread_session():
    adapter, _ = _wire("D1", "dm")
    ev = _inbound_event(message_id="1700.0300", thread_id="1700.0100")
    adapter._stamp_slack_session_thread(ev)
    assert ev.source.thread_id == "1700.0100", (
        "an in-thread reply must keep resolving to its thread's session"
    )


def test_flat_mode_keeps_shared_dm_session():
    adapter, _ = _wire("D1", "dm")
    adapter.config.extra = {"reply_in_thread": False}
    ev = _inbound_event(message_id="1700.0400")
    adapter._stamp_slack_session_thread(ev)
    assert ev.source.thread_id is None, (
        "flat mode: shared rolling DM session (steer/queue) is intended UX"
    )


def test_nested_relay_slack_config_subset_wins():
    """Enterprise knob shape: platforms.relay.extra.slack.reply_in_thread."""
    adapter, _ = _wire("D1", "dm")
    adapter.config.extra = {"slack": {"reply_in_thread": False}}
    assert adapter._effective_reply_in_thread() is False
    adapter.config.extra = {"slack": {"reply_in_thread": True}}
    assert adapter._effective_reply_in_thread() is True
    # Legacy flat key still honoured when no nested object exists.
    adapter.config.extra = {"reply_in_thread": False}
    assert adapter._effective_reply_in_thread() is False
    # Default: thread-per-message.
    adapter.config.extra = {}
    assert adapter._effective_reply_in_thread() is True


# ---------------------------------------------------------------------------
# Cross-module boundary pin (review 2026-07-28): the adapter deliberately has
# NO prompt-side strip — flat-mode placement depends entirely on run.py's
# _resolve_progress_thread_id suppressing the synthetic self-anchor upstream.
# If that suppression regresses, prompt cards silently thread again. These
# tests pin the boundary in BOTH modes so the coupling is load-bearing.
# ---------------------------------------------------------------------------
def test_run_py_suppresses_self_anchor_in_flat_mode():
    from gateway.run import _resolve_progress_thread_id

    # Flat mode + synthetic self-anchor (thread_id == own message id) => None:
    # prompt/progress metadata arrives at the adapter with NO thread anchor.
    assert (
        _resolve_progress_thread_id(
            "slack", "1700.001", "1700.001", reply_in_thread=False
        )
        is None
    )
    # Flat mode + REAL thread (ids differ) => the real thread survives.
    assert (
        _resolve_progress_thread_id(
            "slack", "1699.000", "1700.001", reply_in_thread=False
        )
        == "1699.000"
    )


def test_run_py_keeps_self_anchor_in_thread_mode():
    from gateway.run import _resolve_progress_thread_id

    # Thread-per-message mode: the first-turn self-anchor IS the thread root
    # and must flow through to the adapter unchanged.
    assert (
        _resolve_progress_thread_id(
            "slack", "1700.001", "1700.001", reply_in_thread=True
        )
        == "1700.001"
    )
    # No source thread at all: Slack synthesizes the root from the message id.
    assert (
        _resolve_progress_thread_id("slack", None, "1700.001", reply_in_thread=True)
        == "1700.001"
    )


# ---------------------------------------------------------------------------
# Native parity escape hatch: platforms.relay.extra.slack.
# dm_top_level_threads_as_sessions=false keeps threaded replies but ONE
# rolling DM session (mirrors native SlackAdapter._dm_top_level_threads_as_sessions).
# Without the knob, reply_in_thread alone couples placement AND session
# keying — a posture native operators can express and relay ones could not.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_stamp_opt_out_keeps_rolling_dm_session():
    adapter, stub = _wire("D1", "dm")
    adapter.config.extra = {
        "slack": {
            "reply_in_thread": True,
            "dm_top_level_threads_as_sessions": False,
        }
    }
    event = _inbound_event("D1", message_id="1700.0001", thread_id=None)
    adapter._stamp_slack_session_thread(event)
    assert getattr(event.source, "thread_id", None) is None, (
        "opt-out: top-level DM must NOT be stamped — one rolling session"
    )


@pytest.mark.asyncio
async def test_session_stamp_default_remains_per_message():
    adapter, stub = _wire("D1", "dm")
    adapter.config.extra = {"slack": {"reply_in_thread": True}}
    event = _inbound_event("D1", message_id="1700.0002", thread_id=None)
    adapter._stamp_slack_session_thread(event)
    assert getattr(event.source, "thread_id", None) == "1700.0002", (
        "default (native parity): per-message sessions stay on"
    )
