"""UI-Refresh cross-cutting acceptance — the merge gate (UI-REFRESH-VERIFICATION
"Cross-cutting acceptance, end-to-end, via FakeSlack").

One scripted scenario that exercises Phases 1-4 together against the **real** SlackAdapter /
slack_bolt stack driven by FakeSlack (no network, no tokens, no Slack app console):

1. Connect Slack against FakeSlack through the manager's gateway; allow a user; subscribe an
   Ops "incident" session to a channel.
2. Post a channel message -> it reaches the session as a structured connector message: the
   persisted user message carries a `source` with **resolved** channel/sender names (Phase 2),
   the live turn_start carries that same source, and the provider gets framed text WITHOUT
   `source`.
3. The agent proposes a tool needing approval; the (Unattended) session routes approvals to the
   channel -> FakeSlack receives a Block Kit card; injecting the Approve button click resumes the
   turn and a reply posts back to the origin channel.
4. Mute Slack for the session -> a further channel post does NOT wake it (but is still buffered).
5. `GET /v1/sessions/{id}/connections` `attention` == the persona's unconnected recommends count.

State is fully isolated: `COWORKER_STATE_DIR` redirects the SecretStore and the manager's data
dir lives under `tmp_path`, so the machine-global secrets/config are never touched.
"""

from __future__ import annotations

import asyncio
import time

from fastapi.testclient import TestClient

from coworker.interactions import decode
from coworker.providers import (
    AssistantTurn,
    ModelCapabilities,
    ProviderClient,
    ToolCall,
)
from coworker.server import create_app
from coworker.server.manager import SessionManager
from coworker.sessions import SessionRecord

SID = "incident"
CHANNEL = "C_OPS"
CHANNEL_NAME = "ops-incidents"
USER = "U1"
SENDER_DISPLAY = "Alice Display"
ALERT = "\U0001f6a8 deploy to prod failed at 14:03"
REPLY = "Acknowledged - investigating the deploy now."


class E2EProvider(ProviderClient):
    """Returns queued turns and records the messages handed to it on each call, so the test can
    assert exactly what the provider saw (framed text, never a `source` sidecar)."""

    def __init__(self, turns):
        self._turns = list(turns)
        self.calls: list[list[dict]] = []

    def complete(self, *, model, messages, tools=None, **settings):
        self.calls.append([dict(m) for m in messages])
        return self._turns.pop(0)

    def capabilities(self, model):
        return ModelCapabilities()


def _tool(name, args, call_id):
    return AssistantTurn(tool_calls=[ToolCall(id=call_id, name=name, arguments=args)])


def _text(text):
    return AssistantTurn(text=text, finish_reason="stop")


async def _wait_until(predicate, *, timeout: float = 8.0, interval: float = 0.02):
    """Poll `predicate` until it returns truthy (or the timeout elapses); return the last value."""
    deadline = time.monotonic() + timeout
    val = predicate()
    while not val and time.monotonic() < deadline:
        await asyncio.sleep(interval)
        val = predicate()
    return val


def _find_card(outbound):
    """The mirrored approval card = a chat.postMessage carrying Block Kit blocks."""
    for o in outbound:
        if o["method"] == "chat.postMessage" and o.get("blocks"):
            return o
    return None


def _approve_button(card):
    """The first action button (`ocw_0` = Approve) of a mirrored approval card."""
    for block in card.get("blocks") or []:
        if block.get("type") == "actions":
            for el in block.get("elements") or []:
                if el.get("action_id") == "ocw_0":
                    return el
    return None


def _find_reply(outbound, channel, text):
    """A plain (no-blocks) chat.postMessage = the agent's send_message reply."""
    return [
        o
        for o in outbound
        if o["method"] == "chat.postMessage"
        and not o.get("blocks")
        and o["channel"] == channel
        and o["text"] == text
    ]


async def test_ui_refresh_cross_cutting_e2e(fake_slack, tmp_path, monkeypatch):
    # Isolate the SecretStore (machine-global otherwise) so "is slack connected?" is decided only
    # by what this test writes; the manager's own data dir lives under tmp_path/.coworker.
    monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))

    ws = tmp_path / "ops_ws"
    ws.mkdir()

    provider = E2EProvider(
        [
            # turn 1: a write needing approval -> mirrored to the channel as a Block Kit card.
            _tool(
                "write_file",
                {"path": str(ws / "incident-note.txt"), "content": "ok"},
                "call_w",
            ),
            # turn 2: reply to the origin channel (pre-allowed so it doesn't ask a second time).
            _tool(
                "send_message", {"target": f"slack:{CHANNEL}", "text": REPLY}, "call_s"
            ),
            # turn 3: wrap up.
            _text("Posted the acknowledgement to the channel."),
        ]
    )

    mgr = SessionManager(workspace=tmp_path, provider=provider)
    try:
        # -- Step 1: connect Slack (real adapter -> FakeSlack via the manager's gateway) ----------
        mgr.secrets.put(
            "slack:default",
            {"bot_token": "xoxb-test", "app_token": "xapp-test", "enabled": True},
        )
        live = (
            await mgr.start_gateway()
        )  # builds the gateway + connects the SlackAdapter
        assert "slack" in live
        await fake_slack.wait_socket()

        # register the user + channel in FakeSlack so users.info / conversations.info resolve names
        fake_slack.add_user(USER, "alice", display_name=SENDER_DISPLAY)
        fake_slack.add_channel(CHANNEL, CHANNEL_NAME)
        # allow-list: the inbound gate drops unknown senders unless allowed
        assert mgr.allow_user("slack", USER)["ok"] is True
        assert mgr.set_slack_approval_owner(USER, add=True)["ok"] is True

        # an Ops "incident" session subscribed to the channel, Unattended, approvals -> the channel
        mgr.session_store.save(
            SessionRecord(
                session_id=SID,
                workspace=str(ws),
                model="gpt-5.5",
                mode="interactive",
                agent="ops",
            )
        )
        mgr.subscriptions.subscribe(SID, f"slack:{CHANNEL}")
        assert mgr.set_inbox_binding(
            "ops-incidents", channel="slack", target=CHANNEL
        )["ok"]
        mgr.inbox_routing.set_session_override(SID, "ops-incidents")
        mgr.unattended.set(SID, True)

        # pre-build the engine + pre-allow the reply tool so the reply (step 3) doesn't itself ask.
        engine = mgr.get_engine(SID)
        assert engine is not None
        engine.permissions.allow_tool_for_session("send_message")

        # observe the live turn stream for this session (the "card shows live" assertion).
        ws_events: list[dict] = []

        async def _client_cb(msg):
            ws_events.append(msg)

        mgr.register_session_client(SID, _client_cb)

        client = TestClient(
            create_app(mgr)
        )  # not entered as a CM -> no second gateway start

        # -- Step 2 + 3a: post a channel message; it becomes a structured connector message and the
        #    agent's approval is mirrored as a Block Kit card --------------------------------------
        await fake_slack.inbound(channel=CHANNEL, user=USER, text=ALERT)
        card = await _wait_until(lambda: _find_card(fake_slack.outbound()))
        assert (
            card is not None
        ), f"approval card never mirrored: {fake_slack.outbound()}"

        # (Step 2) persisted user message carries the structured, RESOLVED source sidecar.
        msgs = client.get(f"/v1/sessions/{SID}/messages").json()["messages"]
        user_msgs = [m for m in msgs if m.get("role") == "user"]
        assert user_msgs, msgs
        last = user_msgs[-1]
        src = last["source"]
        assert src["connector"] == "slack"
        assert src["kind"] == "channel"
        assert src["channel_id"] == CHANNEL
        assert (
            src["channel_name"] == CHANNEL_NAME
        )  # resolved via conversations.info (Phase 2)
        assert src["sender_id"] == USER
        assert src["sender_name"] == SENDER_DISPLAY  # resolved via users.info (Phase 2)
        assert src["text"] == ALERT  # the RAW message (what the card renders)
        # the model-facing content is the FRAMED text, not the raw message
        assert "subscribed" in last["content"] and last["content"] != ALERT

        # (Step 2) the provider saw the framed text and NO source / unknown keys, on every call.
        assert provider.calls, "provider was never invoked"
        first_users = [m for m in provider.calls[0] if m.get("role") == "user"]
        assert first_users
        sent = first_users[-1]
        assert "source" not in sent
        assert "subscribed" in sent["content"]
        assert all("source" not in m for call in provider.calls for m in call)

        # (Step 2) the live turn_start carried the same resolved source (card shows live).
        starts = [e for e in ws_events if e.get("type") == "turn_start"]
        assert starts, ws_events
        live_src = starts[0]["data"]["source"]
        assert live_src["channel_name"] == CHANNEL_NAME
        assert live_src["sender_name"] == SENDER_DISPLAY
        assert live_src["text"] == ALERT

        # (Step 3a) the card is a real Block Kit approval, posted to the origin channel.
        assert card["channel"] == CHANNEL
        assert "write_file" in (card["text"] or "")
        button = _approve_button(card)
        assert button is not None and button["value"]
        item_id, resolution = decode(button["value"])
        assert resolution == "allow"
        item = mgr.inbox.get(item_id)
        assert item is not None and item.kind == "approval" and item.state == "pending"

        # -- Step 3b: inject the Approve click -> turn resumes, reply posts to the origin channel --
        await fake_slack.interaction(
            channel=CHANNEL,
            user=USER,
            username="alice",
            message_ts=card["ts"],
            action_id=button["action_id"],
            value=button["value"],
        )
        replies = await _wait_until(
            lambda: _find_reply(fake_slack.outbound(), CHANNEL, REPLY)
        )
        assert replies, f"reply never posted back: {fake_slack.outbound()}"
        assert await _wait_until(lambda: not mgr.is_running(SID))
        assert mgr.inbox.get(item_id).state == "resolved"

        # -- Step 4: mute Slack for the session -> a further post does NOT wake it, still buffered -
        msgcount_before = len(mgr.session_messages(SID))
        calls_before = len(provider.calls)
        resp = client.post(
            f"/v1/sessions/{SID}/connections",
            json={"connector": "slack", "enabled": False},
        ).json()
        assert resp["ok"] is True
        assert "slack" not in mgr.effective_connectors(SID, "ops")

        muted_text = "second alert while muted"
        await fake_slack.inbound(channel=CHANNEL, user=USER, text=muted_text)
        # the message is buffered for catch-up even though it's not delivered.
        assert await _wait_until(
            lambda: any(
                m["text"] == muted_text
                for m in mgr.channel_buffer.recent(f"slack:{CHANNEL}")
            )
        ), "muted message was not buffered"
        # the skip-delivery decision is synchronous with the buffering above (no await between);
        # a short settle guards against any stray scheduled delivery, then assert nothing woke.
        await asyncio.sleep(0.1)
        assert len(mgr.session_messages(SID)) == msgcount_before  # no new turn/message
        assert len(provider.calls) == calls_before  # provider not re-invoked
        assert not mgr.is_running(SID)

        # -- Step 5: attention == the persona's account-unconnected connector recommends ----------
        detail = client.get("/v1/personas/ops").json()
        unconnected = [
            r
            for r in detail["recommends"]
            if r["kind"] == "connector" and not r["connected"]
        ]
        view = client.get(f"/v1/sessions/{SID}/connections").json()
        assert view["attention"] == len(unconnected)
        # only slack is account-connected here -> github/datadog/pagerduty remain
        assert view["attention"] == 3
        assert {r["connector"] for r in view["recommended"]} == {
            "github",
            "datadog",
            "pagerduty",
        }
    finally:
        await mgr.aclose()

    # State-dir isolation held: the SecretStore resolved to the tmp_path-scoped path, never the
    # machine-global ~/.config/coworker (so this run cannot mutate the real secrets hash).
    assert str(tmp_path) in str(mgr.secrets.path)
