"""Tests for the messaging connector core (C2 increment 1): targets, the send_message
tool, settings/authorization, and the gateway inbound loop — all offline via FakeAdapter.
"""

from __future__ import annotations

import asyncio

import pytest

from coworker.connectors import (
    ConnectorSettings,
    FakeAdapter,
    Gateway,
    MessageEvent,
    SessionSource,
    format_target,
    is_authorized,
    make_send_message_tool,
    parse_target,
)
from coworker.connectors.base import SendResult
from coworker.secrets import SecretStore


# -- target tokens -------------------------------------------------------------
def test_target_round_trip():
    assert format_target("telegram", "12345") == "telegram:12345"
    assert format_target("slack", "C1", "168.9") == "slack:C1:168.9"
    assert parse_target("telegram:12345") == ("telegram", "12345", None)
    assert parse_target("slack:C1:168.9") == ("slack", "C1", "168.9")


def test_target_invalid():
    for bad in ("", "telegram", "telegram:", ":123"):
        with pytest.raises(ValueError):
            parse_target(bad)


def test_session_source_target_and_label():
    s = SessionSource(
        platform="telegram", chat_id="42", user_name="Alice", chat_type="dm"
    )
    assert s.target == "telegram:42"
    assert "Alice" in s.label() and "telegram" in s.label()


def test_message_tagged_text_carries_reply_handle():
    s = SessionSource(
        platform="slack", chat_id="C9", user_name="Bob", chat_type="channel"
    )
    ev = MessageEvent(text="ship it", source=s)
    tag = ev.tagged_text()
    assert "reply→slack:C9" in tag and "ship it" in tag


# -- send_message tool ---------------------------------------------------------
def _fake_senders(record):
    def sender(token, chat_id, text, thread_id=None):
        record.append(
            {"token": token, "chat_id": chat_id, "text": text, "thread_id": thread_id}
        )
        return SendResult(True, message_id="99")

    return {"telegram": sender, "slack": sender}


def test_send_message_success(tmp_path):
    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put("telegram:default", {"type": "token", "bot_token": "T0K"})
    record = []
    tool = make_send_message_tool(secrets, senders=_fake_senders(record))

    out = tool(target="telegram:12345", text="hello")
    assert out == {"ok": True, "message_id": "99", "target": "telegram:12345"}
    assert record == [
        {"token": "T0K", "chat_id": "12345", "text": "hello", "thread_id": None}
    ]
    # tool carries gating metadata + an explicit schema
    assert tool.__aisuite_tool_metadata__.requires_approval is True
    assert tool.__coworker_schema__["function"]["name"] == "send_message"


def test_send_message_missing_token(tmp_path):
    secrets = SecretStore(tmp_path / "secrets.json")
    tool = make_send_message_tool(secrets, senders=_fake_senders([]))
    assert "error" in tool(target="telegram:1", text="x")


def test_send_message_unknown_platform(tmp_path):
    tool = make_send_message_tool(
        SecretStore(tmp_path / "secrets.json"), senders=_fake_senders([])
    )
    assert "unknown platform" in tool(target="discord:1", text="x")["error"]


def test_send_message_bad_target(tmp_path):
    tool = make_send_message_tool(
        SecretStore(tmp_path / "secrets.json"), senders=_fake_senders([])
    )
    assert "error" in tool(target="nonsense", text="x")


# -- settings / authorization --------------------------------------------------
def test_is_authorized():
    s = ConnectorSettings(platform="telegram", allowed_users={"u1"})
    assert is_authorized(s, SessionSource("telegram", "c", user_id="u1"))
    assert not is_authorized(s, SessionSource("telegram", "c", user_id="u2"))
    # empty allowlist = nobody
    assert not is_authorized(
        ConnectorSettings("telegram"), SessionSource("telegram", "c", user_id="u1")
    )
    # allow_all opens it
    assert is_authorized(
        ConnectorSettings("telegram", allow_all=True),
        SessionSource("telegram", "c", user_id="x"),
    )


def test_load_settings_from_secretstore(tmp_path, monkeypatch):
    monkeypatch.delenv("TELEGRAM_ALLOWED_USERS", raising=False)
    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put(
        "telegram:default", {"type": "token", "bot_token": "T", "allowed_users": ["u1"]}
    )
    settings = __import__(
        "coworker.connectors.config", fromlist=["load_settings"]
    ).load_settings(secrets)
    assert settings["telegram"].enabled is True
    assert settings["telegram"].allowed_users == {"u1"}
    assert settings["slack"].enabled is False  # no token


def test_load_settings_env_allowlist(tmp_path, monkeypatch):
    monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "a, b ,c")
    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put("telegram:default", {"bot_token": "T"})
    settings = __import__(
        "coworker.connectors.config", fromlist=["load_settings"]
    ).load_settings(secrets)
    assert settings["telegram"].allowed_users == {"a", "b", "c"}


# -- gateway inbound loop (FakeAdapter) ----------------------------------------
async def test_gateway_dispatches_authorized():
    received: list[MessageEvent] = []

    async def handler(ev: MessageEvent) -> None:
        received.append(ev)

    settings = {"fake": ConnectorSettings("fake", enabled=True, allowed_users={"u1"})}
    gw = Gateway(settings=settings, handler=handler)
    fake = FakeAdapter()
    gw.register(fake)
    live = await gw.start()
    assert live == ["fake"] and fake.connected

    await fake.inject("hi", user_id="u1")
    assert len(received) == 1 and received[0].text == "hi"

    await fake.inject("nope", user_id="intruder")  # not in allowlist
    assert len(received) == 1  # dropped

    await gw.stop()
    assert not fake.connected


async def test_gateway_deliver_via_adapter():
    gw = Gateway(
        settings={"fake": ConnectorSettings("fake", enabled=True, allow_all=True)}
    )
    fake = FakeAdapter()
    gw.register(fake)
    result = await gw.deliver("fake:c9", "pong")
    assert result.ok
    assert fake.outbox == [{"chat_id": "c9", "text": "pong", "thread_id": None}]


async def test_gateway_full_echo_loop():
    """Inbound → handler replies via deliver → lands in the adapter outbox."""
    gw = Gateway(
        settings={"fake": ConnectorSettings("fake", enabled=True, allow_all=True)}
    )
    fake = FakeAdapter()

    async def echo(ev: MessageEvent) -> None:
        await gw.deliver(ev.source.target, f"echo: {ev.text}")

    gw.set_handler(echo)
    gw.register(fake)
    await fake.inject("ping", chat_id="c1", user_id="u1")
    assert fake.outbox == [{"chat_id": "c1", "text": "echo: ping", "thread_id": None}]


# -- engine integration: send_message appears only when a connector is configured ----
class _StubProvider:
    """Minimal ProviderClient stand-in (build_engine never calls it)."""

    def complete(self, **_kw):  # pragma: no cover - never invoked at build time
        from coworker.providers import AssistantTurn

        return AssistantTurn()

    def capabilities(self, _model):  # pragma: no cover
        from coworker.providers.base import ModelCapabilities

        return ModelCapabilities()

    def stream(self, **_kw):  # pragma: no cover
        from coworker.providers.base import StreamChunk

        yield StreamChunk(turn=self.complete())


def test_engine_connector_tools_are_cowork_scoped(tmp_path):
    from coworker.agent import build_engine
    from coworker.agents import chat_agent, code_agent, cowork_agent, myhelper_agent

    secrets = SecretStore(tmp_path / "secrets.json")
    eng = build_engine(agent=chat_agent(), provider=_StubProvider(), secrets=secrets)
    assert "send_message" not in eng.registry.names()  # no connector yet
    assert "browser_read_url" not in eng.registry.names()

    secrets.put("telegram:default", {"bot_token": "T"})
    chat = build_engine(agent=chat_agent(), provider=_StubProvider(), secrets=secrets)
    code = build_engine(
        agent=code_agent(),
        workspace=tmp_path,
        provider=_StubProvider(),
        secrets=secrets,
    )
    cowork = build_engine(
        agent=cowork_agent(),
        workspace=tmp_path,
        provider=_StubProvider(),
        secrets=secrets,
    )
    helper = build_engine(
        agent=myhelper_agent(),
        workspace=tmp_path,
        provider=_StubProvider(),
        secrets=secrets,
    )

    assert "send_message" not in chat.registry.names()
    assert "send_message" not in code.registry.names()
    assert "browser_read_url" not in chat.registry.names()
    assert "browser_read_url" not in code.registry.names()

    assert "send_message" in cowork.registry.names()
    assert "browser_read_url" in cowork.registry.names()
    assert "browser_open_url" in cowork.registry.names()
    assert "browser_click" in cowork.registry.names()
    assert "browser_type" in cowork.registry.names()
    assert "github_search" not in cowork.registry.names()
    assert "send_message" in helper.registry.names()
    assert "browser_read_url" not in helper.registry.names()
    assert "browser_open_url" not in helper.registry.names()

    # §36: browser READS (registry kind) are free; interactions still gate.
    assert cowork.registry.get("browser_open_url").metadata.requires_approval is False
    assert cowork.registry.get("browser_snapshot").metadata.requires_approval is False
    assert cowork.registry.get("browser_click").metadata.requires_approval is True
    assert cowork.registry.get("browser_type").metadata.requires_approval is True
    cowork.permissions.allow_tool_for_session("browser_click")
    decision = cowork.permissions.evaluate(
        "browser_click",
        {"target": "button"},
        cowork.registry.get("browser_click").metadata,
    )
    assert decision.needs_user is True

    secrets.put("github:default", {"token": "ghp_test", "enabled": True})
    cowork_with_github = build_engine(
        agent=cowork_agent(),
        workspace=tmp_path,
        provider=_StubProvider(),
        secrets=secrets,
    )
    assert "github_search" in cowork_with_github.registry.names()
    # §36: github_search is a registry READ — free; the write sibling still gates.
    assert (
        cowork_with_github.registry.get("github_search").metadata.requires_approval
        is False
    )
    assert (
        cowork_with_github.registry.get(
            "github_create_issue"
        ).metadata.requires_approval
        is True
    )


# -- connector setup (descriptors / connect / disconnect / list) ---------------
def test_connector_list_descriptors(tmp_path):
    from coworker.connectors import connector_list

    by_name = {
        c["name"]: c for c in connector_list(SecretStore(tmp_path / "secrets.json"))
    }
    assert (
        by_name["telegram"]["two_way"] is True
        and by_name["telegram"]["connected"] is False
    )
    # channels (chat capability) is narrower than two_way: GitHub is two-way via the
    # relay (inbound mentions) but sessions can't subscribe to "GitHub channels".
    assert by_name["telegram"]["channels"] is True
    assert by_name["slack"]["channels"] is True
    assert (
        by_name["github"]["two_way"] is True and by_name["github"]["channels"] is False
    )
    assert (
        by_name["gmail"]["available"] is True and by_name["gmail"]["connected"] is False
    )
    assert (
        by_name["browser"]["available"] is True
        and by_name["browser"]["connected"] is True
    )
    assert (
        by_name["github"]["available"] is True
        and by_name["github"]["connected"] is False
    )
    assert any(
        t["name"] == "browser_open_url" and t["requires_approval"]
        for t in by_name["browser"]["tools"]
    )
    # telegram exposes a bot_token field + setup instructions
    keys = {f["key"] for f in by_name["telegram"]["fields"]}
    assert "bot_token" in keys and by_name["telegram"]["instructions"]


def test_connector_list_pre_connect_copy(tmp_path):
    """Every connectable connector ships Access bullets for the pre-connect
    detail page (UX-DECISIONS §38) — an empty Access section would render as
    'this app tells you nothing about what it can do'."""
    from coworker.connectors import connector_list
    from coworker.connectors.catalog_copy import ACCESS
    from coworker.connectors.descriptors import list_descriptors

    for c in connector_list(SecretStore(tmp_path / "secrets.json")):
        assert isinstance(c["about"], str)
        assert c["access"] and all(
            isinstance(line, str) and line for line in c["access"]
        ), f"{c['name']} has no access copy"
    # Curated (non-fallback) copy is required for every AVAILABLE connector —
    # the fallback line is only a net for experimental/placeholder entries.
    missing = [
        d.name
        for d in list_descriptors()
        if d.available and not d.experimental and d.name not in ACCESS
    ]
    assert not missing, f"connectors missing curated access copy: {missing}"


def test_connector_list_connected_for_required_profiles(tmp_path):
    from coworker.connectors import (
        connect_connector,
        connector_list,
        update_connector_tools,
    )

    secrets = SecretStore(tmp_path / "secrets.json")
    assert (
        connect_connector(secrets, "github", {"token": "ghp_test"}, validate=False)[
            "ok"
        ]
        is True
    )
    assert (
        connect_connector(
            secrets,
            "jira",
            {
                "base_url": "https://example.atlassian.net",
                "email": "me@example.com",
                "api_token": "tok",
            },
            validate=False,
        )["ok"]
        is True
    )

    by_name = {c["name"]: c for c in connector_list(secrets)}
    assert (
        by_name["github"]["connected"] is True and by_name["github"]["enabled"] is True
    )
    assert by_name["jira"]["connected"] is True and by_name["jira"]["enabled"] is True

    assert (
        update_connector_tools(secrets, "github", {"github_search": False})["ok"]
        is True
    )
    by_name = {c["name"]: c for c in connector_list(secrets)}
    gh_tools = {t["name"]: t for t in by_name["github"]["tools"]}
    assert gh_tools["github_search"]["enabled"] is False
    assert gh_tools["github_get_issue"]["enabled"] is True


def test_connect_disconnect_no_validate(tmp_path):
    from coworker.connectors import (
        connect_connector,
        connector_list,
        disconnect_connector,
    )

    secrets = SecretStore(tmp_path / "secrets.json")
    res = connect_connector(
        secrets,
        "telegram",
        {"bot_token": "T0K", "allowed_users": "u1, u2"},
        validate=False,
    )
    assert res["ok"] is True
    profile = secrets.get("telegram:default")
    assert profile["bot_token"] == "T0K" and profile["allowed_users"] == ["u1", "u2"]
    assert profile["enabled"] is True

    listed = {c["name"]: c for c in connector_list(secrets)}["telegram"]
    assert (
        listed["connected"] is True
        and listed["enabled"] is True
        and listed["allowed_users"] == ["u1", "u2"]
    )

    assert disconnect_connector(secrets, "telegram")["ok"] is True
    assert secrets.get("telegram:default") is None


def test_reconnect_does_not_clobber_secret_or_allowlist(tmp_path):
    # Regression: a re-submit carrying the masked placeholder (or a blank allow-list) must not
    # overwrite a stored real token / wipe the live allow-list.
    from coworker.connectors import connect_connector
    from coworker.connectors.descriptors import get_descriptor

    secrets = SecretStore(tmp_path / "secrets.json")
    placeholder = next(
        f.placeholder for f in get_descriptor("telegram").fields if f.key == "bot_token"
    )
    connect_connector(
        secrets,
        "telegram",
        {"bot_token": "REAL-TOKEN-123", "allowed_users": "u1, u2"},
        validate=False,
    )

    # Re-submit with the field's mask + an empty allow-list → both must be preserved.
    connect_connector(
        secrets,
        "telegram",
        {"bot_token": placeholder, "allowed_users": ""},
        validate=False,
    )
    prof = secrets.get("telegram:default")
    assert prof["bot_token"] == "REAL-TOKEN-123"  # not reset to the placeholder
    assert prof["allowed_users"] == ["u1", "u2"]  # not wiped

    # A genuinely new token still updates.
    connect_connector(
        secrets, "telegram", {"bot_token": "NEW-TOKEN-999"}, validate=False
    )
    assert secrets.get("telegram:default")["bot_token"] == "NEW-TOKEN-999"
    assert secrets.get("telegram:default")["allowed_users"] == [
        "u1",
        "u2",
    ]  # still preserved


def test_connect_missing_required_field(tmp_path):
    from coworker.connectors import connect_connector

    secrets = SecretStore(tmp_path / "secrets.json")
    res = connect_connector(
        secrets, "slack", {"bot_token": "xoxb"}, validate=False
    )  # app_token missing
    assert res["ok"] is False and "missing" in res["error"]


def test_manual_slack_reconnect_preserves_approval_owners(tmp_path):
    from coworker.connectors import connect_connector

    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put(
        "slack:default",
        {
            "bot_token": "xoxb-old",
            "app_token": "xapp-old",
            "allowed_users": ["U_OWNER"],
            "approval_owner_ids": ["U_OWNER"],
        },
    )
    result = connect_connector(
        secrets,
        "slack",
        {"bot_token": "xoxb-new", "app_token": "xapp-new"},
        validate=False,
    )
    assert result["ok"] is True
    profile = secrets.get("slack:default")
    assert profile["approval_owner_ids"] == ["U_OWNER"]
    assert profile["allowed_users"] == ["U_OWNER"]


def test_connect_validation_runs(tmp_path):
    from coworker.connectors import connect_connector
    from coworker.connectors.descriptors import ValidationResult, get_descriptor

    secrets = SecretStore(tmp_path / "secrets.json")
    desc = get_descriptor("telegram")
    orig = desc.validate
    desc.validate = lambda creds: ValidationResult(True, identity="@mybot")
    try:
        res = connect_connector(
            secrets, "telegram", {"bot_token": "T"}
        )  # validate=True
    finally:
        desc.validate = orig
    assert res == {"ok": True, "account": "@mybot"}
    assert secrets.get("telegram:default")["account"] == "@mybot"


def test_connectors_rest(tmp_path, monkeypatch):
    from fastapi.testclient import TestClient

    from coworker.connectors.descriptors import ValidationResult, get_descriptor
    from coworker.server.app import create_app
    from coworker.server.manager import SessionManager

    monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
    desc = get_descriptor("telegram")
    monkeypatch.setattr(
        desc, "validate", lambda creds: ValidationResult(True, identity="@testbot")
    )

    client = TestClient(create_app(SessionManager(data_dir=tmp_path / "data")))

    listed = client.get("/v1/connectors").json()["connectors"]
    assert any(c["name"] == "telegram" for c in listed)

    r = client.post(
        "/v1/connectors/telegram/connect",
        json={"fields": {"bot_token": "T0K", "allowed_users": "u1"}},
    )
    assert r.json() == {"ok": True, "account": "@testbot"}

    tg = {c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]}[
        "telegram"
    ]
    assert tg["connected"] is True and tg["account"] == "@testbot"
    # secrets never leak over REST
    assert "T0K" not in client.get("/v1/connectors").text

    assert client.post("/v1/connectors/telegram/disconnect").json()["ok"] is True
    assert {c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]}[
        "telegram"
    ]["connected"] is False


# -- inbound: event mappers ----------------------------------------------------
def test_telegram_message_mapper():
    from types import SimpleNamespace

    from coworker.connectors import telegram_message_to_event

    msg = SimpleNamespace(
        text="hello",
        message_id=7,
        chat=SimpleNamespace(id=12345, type="private"),
        from_user=SimpleNamespace(id=99, full_name="Alice"),
        message_thread_id=None,
    )
    ev = telegram_message_to_event(msg)
    assert ev.text == "hello" and ev.source.target == "telegram:12345"
    assert ev.source.user_id == "99" and ev.source.chat_type == "dm"
    # non-text (e.g. a sticker) maps to None
    assert (
        telegram_message_to_event(
            SimpleNamespace(text=None, chat=SimpleNamespace(id=1, type="private"))
        )
        is None
    )


def test_slack_event_mapper_and_loop_guard():
    from coworker.connectors import slack_event_to_event

    ev = slack_event_to_event(
        {
            "text": "ship it",
            "channel": "C9",
            "user": "U1",
            "channel_type": "channel",
            "ts": "1.2",
        },
        "BOT",
    )
    assert (
        ev.text == "ship it"
        and ev.source.target == "slack:C9"
        and ev.source.chat_type == "channel"
    )
    # bot echo / edits / empty → dropped (reply-loop guard)
    assert slack_event_to_event({"text": "x", "user": "BOT"}, "BOT") is None
    assert slack_event_to_event({"text": "x", "bot_id": "B1"}, None) is None
    assert (
        slack_event_to_event({"subtype": "message_changed", "text": "x"}, None) is None
    )


def test_make_adapter():
    from coworker.connectors import SlackAdapter, TelegramAdapter, make_adapter

    assert isinstance(make_adapter("telegram", {"bot_token": "T"}), TelegramAdapter)
    assert isinstance(
        make_adapter("slack", {"bot_token": "x", "app_token": "y"}), SlackAdapter
    )
    assert make_adapter("slack", {"bot_token": "x"}) is None  # app_token missing
    assert make_adapter("telegram", {}) is None


async def test_slack_resolves_and_caches_display_name():
    from coworker.connectors import SlackAdapter

    calls: list[str] = []

    class _Client:
        async def users_info(self, user):
            calls.append(user)
            return {"user": {"name": "ann", "profile": {"display_name": "Ann"}}}

    class _App:
        client = _Client()

    a = SlackAdapter("b", "x")
    a._app = _App()
    assert await a._display_name("U1") == "Ann"
    assert await a._display_name("U1") == "Ann"  # served from cache
    assert calls == ["U1"]  # only one API round-trip

    class _BadClient:
        async def users_info(self, user):
            raise RuntimeError("nope")

    a._app.client = _BadClient()
    assert (
        await a._display_name("U2") is None
    )  # failure → None (caller falls back to the id)
    assert await a._display_name("") is None  # no id → no call


async def test_slack_resolve_channel_name():
    from coworker.connectors import SlackAdapter

    calls: list[str] = []

    class _Client:
        async def conversations_info(self, channel):
            calls.append(channel)
            return {"channel": {"id": channel, "name": "ocw-test"}}

    class _App:
        client = _Client()

    a = SlackAdapter("b", "x")
    a._app = _App()
    assert await a._channel_name("C1") == "ocw-test"
    assert await a._channel_name("C1") == "ocw-test"  # served from cache
    assert calls == ["C1"]  # only one API round-trip
    # public §2.1 wrapper delegates to the cached resolver (no extra call)
    assert await a.resolve_channel_name("C1") == "ocw-test"
    assert calls == ["C1"]

    class _BadClient:
        async def conversations_info(self, channel):
            raise RuntimeError("nope")

    a._app.client = _BadClient()
    assert (
        await a._channel_name("C2") is None
    )  # failure → None (caller falls back to the id)
    assert await a._channel_name("") is None  # no id → no call


# -- chat-ID auto-capture + connector allow-list -------------------------------
async def test_gateway_records_recent_senders():
    gw = Gateway(
        settings={"fake": ConnectorSettings("fake", enabled=True, allowed_users={"u1"})}
    )
    fake = FakeAdapter()
    gw.register(fake)
    await fake.inject(
        "hi", user_id="u2", user_name="Bob"
    )  # unauthorized → dropped but captured
    await fake.inject("yo", user_id="u1", user_name="Al")  # authorized
    recent = gw.recent_senders()
    assert [r["user_id"] for r in recent] == ["u1", "u2"]  # most-recent first
    assert recent[1]["user_name"] == "Bob"
    # same sender again de-dupes and moves to front
    await fake.inject("again", user_id="u2")
    assert [r["user_id"] for r in gw.recent_senders("fake")] == ["u2", "u1"]


def test_manager_allow_disallow(tmp_path, monkeypatch):
    from coworker.connectors import connect_connector
    from coworker.server.manager import SessionManager

    monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
    m = SessionManager(data_dir=tmp_path / "data")
    connect_connector(m.secrets, "telegram", {"bot_token": "T"}, validate=False)

    assert m.allow_user("telegram", "12345")["allowed_users"] == ["12345"]
    assert m.secrets.get("telegram:default")["allowed_users"] == ["12345"]
    assert m.disallow_user("telegram", "12345")["allowed_users"] == []
    assert m.allow_user("slack", "x")["ok"] is False  # slack not connected


# -- new tool connectors (linear / gitlab / discord / stripe / asana / hubspot /
#    dropbox / box) --------------------------------------------------------------
_NEW_CONNECTORS = {
    "linear": {"api_key": "lin_api_x"},
    "gitlab": {"token": "glpat-x"},
    "discord": {"bot_token": "B0T"},
    "stripe": {"api_key": "rk_test_x"},
    "asana": {"token": "asana_pat"},
    "hubspot": {"token": "pat-x"},
    "dropbox": {"access_token": "dbx"},
    "box": {"access_token": "boxtok"},
    "quickbooks": {"access_token": "qbo", "realm_id": "9341453"},
    "whatsapp": {"access_token": "wa_tok", "phone_number_id": "555111"},
    # Batch-2 account-patterned connectors: stored as legacy flat :default
    # profiles on purpose — the accounts layer migrates them lazily, so these
    # also regression-pin the migration on the tool path.
    "posthog": {"api_key": "phx_x", "project_id": "77"},
    "mixpanel": {"username": "svc.user", "secret": "mp_sec", "project_id": "88"},
    "amplitude": {"api_key": "amp_key_abc123", "secret_key": "amp_sec"},
    "apollo": {"api_key": "apo_x"},
    "hunter": {"api_key": "hun_x"},
    "notion": {"access_token": "ntn_x"},
    "attio": {"access_token": "attio_x"},
    # Batch 3.
    "clickup": {"api_token": "pk_x"},
    "close": {"api_key": "api_close_x"},
    "figma": {"access_token": "figd_x"},
    "google_drive": {"access_token": "ya29.x"},
    # account_id/base_uri pre-cached so routing tests skip userinfo discovery
    # (discovery itself is covered by test_docusign_account_discovery_caches).
    "docusign": {
        "access_token": "ds_x",
        "account_id": "acc-1",
        "base_uri": "https://demo.docusign.net",
    },
    "canva": {"access_token": "cnv_x"},
}


def test_new_connector_descriptors_listed(tmp_path):
    from coworker.connectors import connector_list

    by_name = {
        c["name"]: c for c in connector_list(SecretStore(tmp_path / "secrets.json"))
    }
    for name in _NEW_CONNECTORS:
        assert by_name[name]["available"] is True
        assert by_name[name]["connected"] is False
        assert by_name[name]["two_way"] is False
        assert by_name[name]["instructions"]
        assert by_name[name]["tools"], f"{name} has no tools in the catalog"
    # stripe and quickbooks are deliberately read-only
    assert all(t["kind"] == "read" for t in by_name["stripe"]["tools"])
    assert all(t["kind"] == "read" for t in by_name["dropbox"]["tools"])
    assert all(t["kind"] == "read" for t in by_name["box"]["tools"])
    assert all(t["kind"] == "read" for t in by_name["quickbooks"]["tools"])
    # google_drive (scope discipline) and canva (exports are renders) too
    assert all(t["kind"] == "read" for t in by_name["google_drive"]["tools"])
    assert all(t["kind"] == "read" for t in by_name["canva"]["tools"])


def test_new_connectors_connect_and_gate_tools(tmp_path):
    from coworker.connectors import connect_connector, connector_list
    from coworker.connectors.integration_tools import make_integration_tools

    secrets = SecretStore(tmp_path / "secrets.json")
    for name, fields in _NEW_CONNECTORS.items():
        assert connect_connector(secrets, name, fields, validate=False)["ok"] is True

    by_name = {c["name"]: c for c in connector_list(secrets)}
    for name in _NEW_CONNECTORS:
        assert by_name[name]["connected"] is True and by_name[name]["enabled"] is True

    # only the enabled connectors' tools survive the filter
    tools = make_integration_tools(secrets, enabled_connectors={"linear", "box"})
    names = {t.__name__ for t in tools}
    assert "linear_search_issues" in names and "box_search" in names
    assert "gitlab_search" not in names and "stripe_list_charges" not in names


def test_new_tools_error_when_not_connected(tmp_path):
    from coworker.connectors.integration_tools import make_integration_tools

    tools = {
        t.__name__: t
        for t in make_integration_tools(SecretStore(tmp_path / "secrets.json"))
    }
    assert "not connected" in tools["linear_search_issues"](query="q")["error"]
    assert "not connected" in tools["gitlab_search"](query="q")["error"]
    assert "not connected" in tools["discord_send_message"]("1", "hi")["error"]
    assert "not connected" in tools["stripe_search_customers"]("e:'a'")["error"]
    assert "not connected" in tools["asana_get_task"]("1")["error"]
    assert "not connected" in tools["hubspot_search"]("acme")["error"]
    assert "not connected" in tools["posthog_query"]("SELECT 1")["error"]
    assert "not connected" in tools["mixpanel_top_events"]()["error"]
    assert (
        "not connected"
        in tools["amplitude_active_users"]("20260701", "20260707")["error"]
    )
    assert "not connected" in tools["apollo_enrich_company"]("acme.io")["error"]
    assert "not connected" in tools["hunter_verify_email"]("a@b.co")["error"]
    assert "not connected" in tools["dropbox_list_folder"]()["error"]
    assert "not connected" in tools["box_read_file"]("1")["error"]
    assert "not connected" in tools["quickbooks_query"]("SELECT * FROM Bill")["error"]
    assert "not connected" in tools["clickup_list_teams"]()["error"]
    assert "not connected" in tools["close_search_leads"]("acme")["error"]
    assert "not connected" in tools["figma_get_file"]("KEY1")["error"]
    assert "not connected" in tools["drive_search_files"]("plan")["error"]
    assert "not connected" in tools["docusign_list_envelopes"]()["error"]
    assert "not connected" in tools["canva_list_designs"]()["error"]


def _connected_tools(tmp_path, monkeypatch, calls):
    """All new connectors connected + _request recorded instead of hitting the network."""
    import coworker.connectors.integration_tools as it

    secrets = SecretStore(tmp_path / "secrets.json")
    for name, fields in _NEW_CONNECTORS.items():
        secrets.put(f"{name}:default", {**fields, "enabled": True})

    def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
        calls.append(
            {
                "method": method,
                "url": url,
                "headers": headers or {},
                "params": params,
                "json": json,
                "auth": auth,
            }
        )
        return {"ok": True, "data": {}}

    monkeypatch.setattr(it, "_request", fake_request)
    return {t.__name__: t for t in it.make_integration_tools(secrets)}


def test_new_tools_request_routing(tmp_path, monkeypatch):
    calls = []
    tools = _connected_tools(tmp_path, monkeypatch, calls)

    tools["linear_search_issues"]("crash", max_results=5)
    assert calls[-1]["url"] == "https://api.linear.app/graphql"
    assert calls[-1]["headers"]["Authorization"] == "lin_api_x"  # raw key, no Bearer
    assert calls[-1]["json"]["variables"] == {"term": "crash", "first": 5}

    tools["gitlab_get_issue"]("group/repo", 7)
    assert (
        calls[-1]["url"] == "https://gitlab.com/api/v4/projects/group%2Frepo/issues/7"
    )
    assert calls[-1]["headers"]["PRIVATE-TOKEN"] == "glpat-x"

    tools["discord_send_message"]("123", "x" * 3000)
    assert calls[-1]["url"] == "https://discord.com/api/v10/channels/123/messages"
    assert calls[-1]["headers"]["Authorization"] == "Bot B0T"
    assert len(calls[-1]["json"]["content"]) == 2000  # discord hard limit

    tools["stripe_list_charges"](customer_id="cus_1", max_results=99)
    assert calls[-1]["params"] == {"limit": 20, "customer": "cus_1"}

    tools["asana_search_tasks"]("WS1", "report")
    assert "workspaces/WS1/typeahead" in calls[-1]["url"]

    tools["hubspot_search"]("acme", object_type="bogus")
    assert calls[-1]["url"].endswith("/crm/v3/objects/contacts/search")  # fallback

    tools["dropbox_list_folder"]("Docs")
    assert calls[-1]["json"] == {"path": "/Docs"}  # leading slash added
    tools["dropbox_list_folder"]()
    assert calls[-1]["json"] == {"path": ""}  # root stays empty

    tools["dropbox_read_file"]("/a.txt")
    assert "Dropbox-API-Arg" in calls[-1]["headers"]

    tools["box_read_file"]("f1")
    assert calls[-1]["url"] == "https://api.box.com/2.0/files/f1/content"

    tools["quickbooks_query"]("SELECT * FROM Invoice", max_results=5)
    assert (
        calls[-1]["url"] == "https://quickbooks.api.intuit.com/v3/company/9341453/query"
    )
    assert calls[-1]["params"]["query"] == "SELECT * FROM Invoice MAXRESULTS 5"
    tools["quickbooks_query"]("SELECT * FROM Bill MAXRESULTS 3")
    assert (
        calls[-1]["params"]["query"] == "SELECT * FROM Bill MAXRESULTS 3"
    )  # untouched

    tools["quickbooks_get_report"]("ProfitAndLoss", start_date="2026-01-01")
    assert calls[-1]["url"].endswith("/reports/ProfitAndLoss")
    assert calls[-1]["params"] == {"start_date": "2026-01-01"}

    tools["whatsapp_send_message"]("15551234567", "x" * 5000)
    assert calls[-1]["url"] == "https://graph.facebook.com/v21.0/555111/messages"
    assert calls[-1]["json"]["messaging_product"] == "whatsapp"
    assert len(calls[-1]["json"]["text"]["body"]) == 4096  # whatsapp hard limit

    tools["whatsapp_send_template"]("15551234567", "order_update")
    assert calls[-1]["json"]["template"] == {
        "name": "order_update",
        "language": {"code": "en_US"},
    }


def test_registry_has_no_duplicate_names():
    """A new full descriptor once coexisted with a stale placeholder (both
    named "notion") — the Connectors page showed the connector twice and the
    tool registry carried colliding tool names. Guard both registries."""
    from coworker.connectors.descriptors import DESCRIPTORS
    from coworker.connectors.tool_defs import TOOL_DEFS

    names = [d.name for d in DESCRIPTORS]
    assert len(names) == len(set(names)), sorted(n for n in names if names.count(n) > 1)
    tools = [t.name for t in TOOL_DEFS]
    assert len(tools) == len(set(tools)), sorted(t for t in tools if tools.count(t) > 1)


def test_batch2_tools_request_routing(tmp_path, monkeypatch):
    """The five key-based batch-2 connectors: right endpoints, right auth style,
    and every result stamped with the serving account (legacy flat profiles
    migrate on first use — see _NEW_CONNECTORS)."""
    calls = []
    tools = _connected_tools(tmp_path, monkeypatch, calls)

    out = tools["posthog_query"]("SELECT event FROM events")
    assert calls[-1]["url"] == "https://us.posthog.com/api/projects/77/query"
    assert calls[-1]["headers"]["Authorization"] == "Bearer phx_x"
    assert calls[-1]["json"]["query"]["kind"] == "HogQLQuery"
    assert out["account"] == "77"  # stamped for approvals/transcripts

    tools["posthog_list_insights"]("signups", max_results=5)
    assert calls[-1]["url"].endswith("/api/projects/77/insights")
    assert calls[-1]["params"] == {"limit": 5, "search": "signups"}

    tools["mixpanel_segmentation"]("purchase", "2026-07-01", "2026-07-07", unit="bogus")
    assert calls[-1]["url"] == "https://mixpanel.com/api/query/segmentation"
    assert calls[-1]["params"]["project_id"] == "88"
    assert calls[-1]["params"]["unit"] == "day"  # bogus unit falls back

    tools["mixpanel_top_events"]()
    assert calls[-1]["url"].endswith("/api/query/events/top")

    tools["amplitude_active_users"]("2026-07-01", "2026-07-07", metric="new")
    assert calls[-1]["url"] == "https://amplitude.com/api/2/users"
    assert calls[-1]["params"]["start"] == "20260701"  # dashes normalized
    assert calls[-1]["params"]["m"] == "new"

    tools["amplitude_event_totals"]("signup", "20260701", "20260707")
    assert calls[-1]["url"].endswith("/api/2/events/segmentation")
    assert '"event_type": "signup"' in calls[-1]["params"]["e"]

    tools["apollo_enrich_person"](email="maya@acme.io")
    assert calls[-1]["url"] == "https://api.apollo.io/api/v1/people/match"
    assert calls[-1]["headers"]["X-Api-Key"] == "apo_x"
    assert "provide an email" in tools["apollo_enrich_person"]()["error"]

    tools["apollo_enrich_company"]("acme.io")
    assert calls[-1]["params"] == {"domain": "acme.io"}

    tools["apollo_search_people"]("VP engineering fintech", max_results=7)
    assert calls[-1]["json"]["per_page"] == 7

    tools["hunter_domain_search"]("acme.io", max_results=3)
    assert calls[-1]["url"] == "https://api.hunter.io/v2/domain-search"
    assert calls[-1]["params"] == {"domain": "acme.io", "limit": 3, "api_key": "hun_x"}

    tools["hunter_find_email"]("acme.io", "Maya", "Chen")
    assert calls[-1]["params"]["first_name"] == "Maya"

    tools["hunter_verify_email"]("maya@acme.io")
    assert calls[-1]["url"].endswith("/email-verifier")

    tools["notion_search"]("roadmap", max_results=5)
    assert calls[-1]["url"] == "https://api.notion.com/v1/search"
    assert calls[-1]["headers"]["Notion-Version"] == "2022-06-28"
    assert calls[-1]["json"] == {"query": "roadmap", "page_size": 5}

    tools["notion_query_database"]("db1")
    assert calls[-1]["url"].endswith("/v1/databases/db1/query")
    assert (
        "must be a Notion filter"
        in tools["notion_query_database"]("db1", "nope")["error"]
    )

    tools["notion_create_page"]("pg1", "Weekly notes", "line one\n\nline two")
    assert calls[-1]["json"]["parent"] == {"page_id": "pg1"}
    assert len(calls[-1]["json"]["children"]) == 2  # blank lines dropped

    tools["attio_list_objects"]()
    assert calls[-1]["url"] == "https://api.attio.com/v2/objects"
    assert calls[-1]["headers"]["Authorization"] == "Bearer attio_x"

    tools["attio_query_records"]("companies", max_results=50)
    assert calls[-1]["url"].endswith("/v2/objects/companies/records/query")
    assert calls[-1]["json"] == {"limit": 50}

    tools["attio_get_record"]("people", "r1")
    assert calls[-1]["url"].endswith("/v2/objects/people/records/r1")

    tools["attio_create_note"]("companies", "r1", "Call notes", "went well")
    assert calls[-1]["url"] == "https://api.attio.com/v2/notes"
    assert calls[-1]["json"]["data"]["parent_record_id"] == "r1"
    assert calls[-1]["json"]["data"]["format"] == "plaintext"


def test_batch3_tools_request_routing(tmp_path, monkeypatch):
    calls = []
    tools = _connected_tools(tmp_path, monkeypatch, calls)

    # ClickUp: raw personal token in Authorization (no Bearer).
    tools["clickup_list_teams"]()
    assert calls[-1]["url"] == "https://api.clickup.com/api/v2/team"
    assert calls[-1]["headers"]["Authorization"] == "pk_x"

    tools["clickup_list_tasks"]("l-9", include_closed=True)
    assert calls[-1]["url"] == "https://api.clickup.com/api/v2/list/l-9/task"
    assert calls[-1]["params"]["include_closed"] == "true"

    tools["clickup_create_task"]("l-9", "Ship logos", "brand marks")
    assert calls[-1]["method"] == "POST"
    assert calls[-1]["json"] == {"name": "Ship logos", "description": "brand marks"}

    tools["clickup_update_task"]("t-1", status="done")
    assert calls[-1]["method"] == "PUT"
    assert calls[-1]["json"] == {"status": "done"}
    assert "nothing to update" in tools["clickup_update_task"]("t-1")["error"]

    tools["clickup_add_comment"]("t-1", "on it")
    assert calls[-1]["url"].endswith("/task/t-1/comment")
    assert calls[-1]["json"] == {"comment_text": "on it"}

    # Close: basic auth (key as username, blank password).
    tools["close_search_leads"]("status:potential acme", max_results=5)
    assert calls[-1]["url"] == "https://api.close.com/api/v1/lead/"
    assert calls[-1]["auth"] == ("api_close_x", "")
    assert calls[-1]["params"] == {"query": "status:potential acme", "_limit": 5}

    tools["close_get_lead"]("lead_1")
    assert calls[-1]["url"] == "https://api.close.com/api/v1/lead/lead_1/"

    tools["close_list_opportunities"](lead_id="lead_1")
    assert calls[-1]["params"]["lead_id"] == "lead_1"

    tools["close_create_lead"]("Acme", contact_name="Ada", contact_email="a@acme.io")
    assert calls[-1]["json"]["contacts"] == [
        {"name": "Ada", "emails": [{"email": "a@acme.io"}]}
    ]

    tools["close_update_opportunity"]("opp_1", status_id="stat_won")
    assert calls[-1]["url"].endswith("/opportunity/opp_1/")
    assert "nothing to update" in tools["close_update_opportunity"]("opp_1")["error"]

    tools["close_log_note"]("lead_1", "call went well")
    assert calls[-1]["url"] == "https://api.close.com/api/v1/activity/note/"

    # Figma: PAT in X-Figma-Token.
    tools["figma_get_comments"]("KEY1")
    assert calls[-1]["url"] == "https://api.figma.com/v1/files/KEY1/comments"
    assert calls[-1]["headers"]["X-Figma-Token"] == "figd_x"

    tools["figma_post_comment"]("KEY1", "looks good", reply_to="c9")
    assert calls[-1]["json"] == {"message": "looks good", "comment_id": "c9"}

    tools["figma_export_images"]("KEY1", "1:2,1:3", format="svg")
    assert calls[-1]["url"] == "https://api.figma.com/v1/images/KEY1"
    assert calls[-1]["params"]["ids"] == "1:2,1:3"

    # Drive: bearer token; quotes escaped into the q expression.
    tools["drive_search_files"]("Q3 plan's", max_results=5)
    assert calls[-1]["url"] == "https://www.googleapis.com/drive/v3/files"
    assert calls[-1]["headers"]["Authorization"] == "Bearer ya29.x"
    assert "Q3 plan\\'s" in calls[-1]["params"]["q"]
    assert "trashed=false" in calls[-1]["params"]["q"]

    tools["drive_list_folder"]()
    assert calls[-1]["params"]["q"] == "'root' in parents and trashed=false"

    # Docusign: cached account routes straight to the account's base_uri.
    tools["docusign_list_envelopes"](status="completed", since_days=7)
    assert calls[-1]["url"] == (
        "https://demo.docusign.net/restapi/v2.1/accounts/acc-1/envelopes"
    )
    assert calls[-1]["params"]["status"] == "completed"

    tools["docusign_get_envelope"]("env-1")
    assert calls[-1]["url"].endswith("/envelopes/env-1")
    assert calls[-1]["params"] == {"include": "recipients"}

    tools["docusign_send_from_template"]("tpl-1", "a@b.co", "Ada", subject="NDA")
    assert calls[-1]["json"]["templateRoles"] == [
        {"email": "a@b.co", "name": "Ada", "roleName": "Signer"}
    ]
    assert calls[-1]["json"]["status"] == "sent"

    # Canva: bearer token; export is a job POST.
    tools["canva_list_designs"]("deck", max_results=5)
    assert calls[-1]["url"] == "https://api.canva.com/rest/v1/designs"
    assert calls[-1]["headers"]["Authorization"] == "Bearer cnv_x"
    assert calls[-1]["params"] == {"limit": 5, "query": "deck"}

    tools["canva_export_design"]("d1", format="png")
    assert calls[-1]["url"] == "https://api.canva.com/rest/v1/exports"
    assert calls[-1]["json"] == {"design_id": "d1", "format": {"type": "png"}}

    tools["canva_get_export"]("exp1")
    assert calls[-1]["url"] == "https://api.canva.com/rest/v1/exports/exp1"


def test_docusign_account_discovery_caches(tmp_path, monkeypatch):
    import coworker.connectors.integration_tools as it

    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put("docusign:default", {"access_token": "ds_x", "enabled": True})
    calls = []

    def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
        calls.append(url)
        if url.endswith("/oauth/userinfo"):
            return {
                "ok": True,
                "data": {
                    "accounts": [
                        {
                            "account_id": "other",
                            "base_uri": "https://x",
                            "is_default": False,
                        },
                        {
                            "account_id": "acc-9",
                            "base_uri": "https://eu.docusign.net",
                            "is_default": True,
                        },
                    ]
                },
            }
        return {"ok": True, "data": {}}

    monkeypatch.setattr(it, "_request", fake_request)
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}

    tools["docusign_list_templates"]()
    assert calls[0] == "https://account.docusign.com/oauth/userinfo"
    assert calls[1] == "https://eu.docusign.net/restapi/v2.1/accounts/acc-9/templates"
    # Discovery result is cached on the profile — no second userinfo round-trip.
    assert secrets.get("docusign:default")["account_id"] == "acc-9"
    tools["docusign_list_envelopes"]()
    assert not any(u.endswith("/oauth/userinfo") for u in calls[2:])


def test_drive_read_file_exports_google_docs(tmp_path, monkeypatch):
    import coworker.connectors.integration_tools as it

    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put("google_drive:default", {"access_token": "ya29.x", "enabled": True})

    def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
        if url.endswith("/export"):
            return {"ok": True, "data": "Doc body text"}
        return {
            "ok": True,
            "data": {
                "id": "f1",
                "name": "Plan",
                "mimeType": "application/vnd.google-apps.document",
            },
        }

    monkeypatch.setattr(it, "_request", fake_request)
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
    out = tools["drive_read_file"]("f1")
    assert out["ok"] is True and out["content"] == "Doc body text"

    # Native Google types with no text export refuse instead of dumping binary.
    def fake_request_drawing(method, url, **kw):
        return {
            "ok": True,
            "data": {"mimeType": "application/vnd.google-apps.drawing"},
        }

    monkeypatch.setattr(it, "_request", fake_request_drawing)
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
    assert "cannot read" in tools["drive_read_file"]("f2")["error"]


def test_notion_read_page_flattens_blocks(tmp_path, monkeypatch):
    import coworker.connectors.integration_tools as it
    from coworker.connectors import accounts

    secrets = SecretStore(tmp_path / "secrets.json")
    accounts.add_account(secrets, "notion", "ws1", {"access_token": "t"})

    def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
        if "/blocks/" in url:
            return {
                "ok": True,
                "data": {
                    "results": [
                        {
                            "type": "heading_1",
                            "heading_1": {"rich_text": [{"plain_text": "Title"}]},
                        },
                        {
                            "type": "paragraph",
                            "paragraph": {"rich_text": [{"plain_text": "Body text"}]},
                        },
                        {"type": "divider", "divider": {}},
                    ]
                },
            }
        return {"ok": True, "data": {"properties": {"p": 1}, "url": "https://n/x"}}

    monkeypatch.setattr(it, "_request", fake_request)
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
    out = tools["notion_read_page"]("pg1")
    assert out["text"] == "Title\nBody text"
    assert out["account"] == "ws1" and out["url"] == "https://n/x"


def test_managed_callback_profile_keys_by_account_id(tmp_path):
    """Managed OAuth on an account-patterned connector: the broker's account_id
    keys the profile; a second workspace is a second account."""
    from coworker.cloud import managed_profile_from_callback
    from coworker.connectors import accounts
    from coworker.connectors.setup import managed_connect_connector

    secrets = SecretStore(tmp_path / "secrets.json")
    p1 = managed_profile_from_callback(
        {
            "access_token": "t1",
            "account": "Rohit's Workspace",
            "account_id": "ws-1",
            "provider": "notion",
            "connection_id": "c1",
        }
    )
    out = managed_connect_connector(secrets, "notion", p1)
    assert out["ok"] and out["account_id"] == "ws-1"
    p2 = managed_profile_from_callback(
        {"access_token": "t2", "account": "Ops Space", "account_id": "ws-2"}
    )
    managed_connect_connector(secrets, "notion", p2)
    assert [a for a, _ in accounts.list_accounts(secrets, "notion")] == ["ws-1", "ws-2"]
    # display names survive; default stays the first workspace
    rows = accounts.account_rows(secrets, "notion")
    assert rows[0]["name"] == "Rohit's Workspace" and rows[0]["default"]


def test_google_drive_multi_account_keys_by_email(tmp_path):
    """Managed Drive must add multiple accounts keyed by email — the same way
    Gmail does — not by the opaque Google `sub`. The broker sends both `account`
    (email) and `account_id` (sub); account_field="@identity" makes the email win."""
    from coworker.cloud import managed_profile_from_callback
    from coworker.connectors import accounts
    from coworker.connectors.setup import managed_connect_connector

    secrets = SecretStore(tmp_path / "secrets.json")
    p1 = managed_profile_from_callback(
        {
            "access_token": "t1",
            "account": "rohit@opencoworker.app",
            "account_id": "114835900000000000001",  # Google sub — must NOT be the key
            "provider": "google",
            "connection_id": "c1",
        }
    )
    managed_connect_connector(secrets, "google_drive", p1)
    p2 = managed_profile_from_callback(
        {
            "access_token": "t2",
            "account": "work@acme.com",
            "account_id": "114835900000000000002",
            "provider": "google",
        }
    )
    managed_connect_connector(secrets, "google_drive", p2)

    ids = [a for a, _ in accounts.list_accounts(secrets, "google_drive")]
    assert ids == ["rohit@opencoworker.app", "work@acme.com"], ids
    # The default resolves to the first email, and the account param selects the other.
    _, _, prof = accounts.resolve(secrets, "google_drive", "work@acme.com")
    assert prof["access_token"] == "t2"


def test_outlook_managed_multi_account_keys_by_email(tmp_path, monkeypatch):
    """Managed Outlook mirrors Gmail/Drive: broker `account` (email from the
    Microsoft id_token) keys each mailbox; tools take an account param."""
    import coworker.connectors.integration_tools as it
    from coworker.cloud import managed_profile_from_callback
    from coworker.connectors import accounts
    from coworker.connectors.setup import managed_connect_connector

    secrets = SecretStore(tmp_path / "secrets.json")
    for email, tok in (("rohit@openworker.com", "g1"), ("ops@acme.com", "g2")):
        managed_connect_connector(
            secrets,
            "outlook",
            managed_profile_from_callback(
                {"access_token": tok, "account": email, "provider": "microsoft"}
            ),
        )
    ids = [a for a, _ in accounts.list_accounts(secrets, "outlook")]
    assert ids == ["ops@acme.com", "rohit@openworker.com"], ids

    calls = []

    def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
        calls.append({"url": url, "headers": headers or {}})
        return {"ok": True, "data": {}}

    monkeypatch.setattr(it, "_request", fake_request)
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
    out = tools["outlook_search_messages"]("q", account="ops@acme.com")
    assert out["account"] == "ops@acme.com"
    assert calls[-1]["headers"]["Authorization"] == "Bearer g2"
    # default account = first connected (rohit@ was added first)
    out = tools["outlook_list_events"]()
    assert out["account"] == "rohit@openworker.com"
    # Bare list = the next-7-days calendarView (recurrences expanded), not /me/events.
    assert calls[-1]["url"] == "https://graph.microsoft.com/v1.0/me/calendarView"


def test_outlook_calendar_tools_hit_the_right_graph_endpoints(tmp_path, monkeypatch):
    """The calendar CRUD + invite-response tools map onto Microsoft Graph:
    create carries attendees/location/Teams flags, update PATCHes only the
    provided fields, respond posts to the accept/decline/tentativelyAccept
    action endpoints."""
    import coworker.connectors.integration_tools as it
    from coworker.cloud import managed_profile_from_callback
    from coworker.connectors.setup import managed_connect_connector

    secrets = SecretStore(tmp_path / "secrets.json")
    managed_connect_connector(
        secrets,
        "outlook",
        managed_profile_from_callback(
            {
                "access_token": "tok",
                "account": "rohit@openworker.com",
                "provider": "microsoft",
            }
        ),
    )

    calls = []

    def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
        calls.append({"method": method, "url": url, "params": params, "json": json})
        return {"ok": True, "data": {}}

    monkeypatch.setattr(it, "_request", fake_request)
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}

    tools["outlook_create_event"](
        "Sync",
        "2026-07-20T10:00:00",
        "2026-07-20T10:30:00",
        attendees="a@x.com, b@y.com",
        location="Room 4",
        teams_meeting=True,
    )
    payload = calls[-1]["json"]
    assert [a["emailAddress"]["address"] for a in payload["attendees"]] == [
        "a@x.com",
        "b@y.com",
    ]
    assert payload["location"] == {"displayName": "Room 4"}
    assert payload["isOnlineMeeting"] is True

    tools["outlook_update_event"]("ev1", subject="Moved", start="2026-07-21T10:00:00")
    assert calls[-1]["method"] == "PATCH"
    assert calls[-1]["url"].endswith("/me/events/ev1")
    # PATCH semantics: untouched fields stay out of the payload.
    assert set(calls[-1]["json"]) == {"subject", "start"}

    tools["outlook_delete_event"]("ev1")
    assert calls[-1]["method"] == "DELETE"
    assert calls[-1]["url"].endswith("/me/events/ev1")

    tools["outlook_respond_event"]("ev1", "tentative", comment="might be late")
    assert calls[-1]["url"].endswith("/me/events/ev1/tentativelyAccept")
    assert calls[-1]["json"] == {"comment": "might be late", "sendResponse": True}

    out = tools["outlook_respond_event"]("ev1", "maybe")
    assert "error" in out

    # A time-windowed list passes the window through to calendarView.
    tools["outlook_list_events"](
        start="2026-07-20T00:00:00Z", end="2026-07-22T00:00:00Z"
    )
    assert calls[-1]["params"]["startDateTime"] == "2026-07-20T00:00:00Z"
    assert calls[-1]["params"]["endDateTime"] == "2026-07-22T00:00:00Z"
    assert calls[-1]["params"]["$orderby"] == "start/dateTime"


def test_batch2_account_param_picks_the_profile(tmp_path, monkeypatch):
    """Two PostHog projects connected → the account param routes the call; the
    default pointer serves bare calls; unknown accounts fail closed."""
    import coworker.connectors.integration_tools as it
    from coworker.connectors import accounts

    calls = []
    secrets = SecretStore(tmp_path / "secrets.json")
    accounts.add_account(
        secrets, "posthog", "11", {"api_key": "k11", "project_id": "11"}
    )
    accounts.add_account(
        secrets, "posthog", "22", {"api_key": "k22", "project_id": "22"}
    )

    def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
        calls.append({"url": url, "headers": headers or {}})
        return {"ok": True, "data": {}}

    monkeypatch.setattr(it, "_request", fake_request)
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}

    out = tools["posthog_query"]("SELECT 1", account="22")
    assert "/projects/22/" in calls[-1]["url"]
    assert calls[-1]["headers"]["Authorization"] == "Bearer k22"
    assert out["account"] == "22"

    out = tools["posthog_query"]("SELECT 1")  # default = first added
    assert "/projects/11/" in calls[-1]["url"] and out["account"] == "11"

    out = tools["posthog_query"]("SELECT 1", account="99")
    assert "no posthog account matching" in out["error"]


def test_hubspot_search_properties_and_filters(tmp_path, monkeypatch):
    """Custom properties (VC-thesis fields etc.) are invisible to the search API
    unless requested, and unmatchable by free-text query — the properties/filters
    params are what make property-driven workflows possible at all."""
    calls = []
    tools = _connected_tools(tmp_path, monkeypatch, calls)

    tools["hubspot_search"](
        object_type="companies",
        properties="org_type, check_min,check_max",
        filters='[{"property":"org_type","operator":"EQ","value":"VC"}]',
        max_results=50,
    )
    body = calls[-1]["json"]
    assert body["properties"] == ["org_type", "check_min", "check_max"]
    assert body["filterGroups"] == [
        {"filters": [{"property": "org_type", "operator": "EQ", "value": "VC"}]}
    ]
    assert body["limit"] == 50 and "query" not in body

    tools["hubspot_search"]("acme")  # plain free-text still works
    assert calls[-1]["json"]["query"] == "acme"

    n = len(calls)  # none of the error paths below may reach the network
    assert "JSON array" in tools["hubspot_search"](filters="not json")["error"]
    assert "property" in tools["hubspot_search"](filters='[{"value":"x"}]')["error"]
    assert "query" in tools["hubspot_search"]()["error"]
    assert len(calls) == n

    tools["hubspot_get_object"](
        "deals", "42", properties="round,portfolio_company", associations="companies"
    )
    assert calls[-1]["params"] == {
        "properties": "round,portfolio_company",
        "associations": "companies",
    }
    tools["hubspot_get_object"]("deals", "42")  # no params → none sent
    assert calls[-1]["params"] is None


def test_new_write_tools_require_approval(tmp_path, monkeypatch):
    # §36: the tool registry's kind is law — connector WRITES gate, READS never do
    # (reads on a service the user explicitly connected are the point of connecting it).
    tools = _connected_tools(tmp_path, monkeypatch, [])
    for name in (
        "linear_create_issue",
        "gitlab_create_issue",
        "discord_send_message",
        "asana_create_task",
        "hubspot_create_contact",
        "whatsapp_send_message",
        "whatsapp_send_template",
        "notion_create_page",
        "attio_create_note",
        "clickup_create_task",
        "clickup_update_task",
        "clickup_add_comment",
        "close_create_lead",
        "close_update_opportunity",
        "close_log_note",
        "figma_post_comment",
        "docusign_send_from_template",
    ):
        assert tools[name].__aisuite_tool_metadata__.requires_approval is True, name
    for name in (
        "stripe_search_customers",
        "dropbox_read_file",
        "box_search",
        "drive_read_file",
        "figma_export_images",
        "docusign_list_envelopes",
        "canva_export_design",
    ):
        assert tools[name].__aisuite_tool_metadata__.requires_approval is False, name


def test_gitlab_self_hosted_base_url(tmp_path, monkeypatch):
    import coworker.connectors.integration_tools as it

    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put(
        "gitlab:default",
        {"token": "t", "base_url": "https://git.example.com/", "enabled": True},
    )
    calls = []
    monkeypatch.setattr(
        it, "_request", lambda m, url, **kw: calls.append(url) or {"ok": True}
    )
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
    tools["gitlab_search"]("q")
    assert calls[0] == "https://git.example.com/api/v4/search"


def test_quickbooks_sandbox_environment(tmp_path, monkeypatch):
    import coworker.connectors.integration_tools as it

    secrets = SecretStore(tmp_path / "secrets.json")
    secrets.put(
        "quickbooks:default",
        {"access_token": "t", "realm_id": "r1", "environment": "sandbox"},
    )
    calls = []
    monkeypatch.setattr(
        it, "_request", lambda m, url, **kw: calls.append(url) or {"ok": True}
    )
    tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
    tools["quickbooks_list_customers"]()
    assert calls[0] == "https://sandbox-quickbooks.api.intuit.com/v3/company/r1/query"


def test_new_connector_validators_wired():
    from coworker.connectors.descriptors import get_descriptor

    for name in (
        "linear",
        "gitlab",
        "discord",
        "asana",
        "hubspot",
        "dropbox",
        "box",
        "quickbooks",
        "whatsapp",
        "clickup",
        "close",
        "figma",
        "google_drive",
        "docusign",
        "canva",
    ):
        assert get_descriptor(name).validate is not None, name
    # stripe restricted-key permissions vary, so it has no whoami validator
    assert get_descriptor("stripe").validate is None


def test_validate_whoami_helper(monkeypatch):
    import coworker.connectors.descriptors as d

    class _Resp:
        def __init__(self, status, payload):
            self.status_code = status
            self._payload = payload

        def json(self):
            return self._payload

    def fake(status, payload):
        import httpx

        monkeypatch.setattr(httpx, "request", lambda *a, **k: _Resp(status, payload))

    fake(200, {"username": "alice"})
    res = d._validate_gitlab({"token": "t"})
    assert res.ok and res.identity == "@alice"

    fake(401, {"message": "401 Unauthorized"})
    res = d._validate_gitlab({"token": "bad"})
    assert not res.ok and "Unauthorized" in res.error

    # 200 but unexpected shape (e.g. GraphQL errors payload) must not pass
    fake(200, {"errors": [{"message": "auth"}]})
    res = d._validate_linear({"api_key": "bad"})
    assert not res.ok


# -- experimental connector gating ----------------------------------------------
@pytest.fixture
def experimental_descriptor():
    """Register a synthetic experimental connector through the same hook the
    experimental package uses, and clean it up afterwards."""
    import coworker.connectors.descriptors as d

    desc = d.ConnectorDescriptor(
        name="dangerzone",
        title="Danger Zone",
        icon="!",
        blurb="Test-only experimental connector.",
        auth="token",
        two_way=False,
        fields=[d.Field("token", "Token", secret=True)],
        instructions=["test only"],
        experimental=True,
        risk_notice="This may eat your laundry.",
    )
    d.register_descriptor(desc)
    yield desc
    d.DESCRIPTORS.remove(desc)
    d._BY_NAME.pop(desc.name, None)


def test_experimental_hidden_until_enabled(tmp_path, experimental_descriptor):
    from coworker.connectors import connector_list, set_experimental_enabled

    secrets = SecretStore(tmp_path / "secrets.json")
    assert "dangerzone" not in {c["name"] for c in connector_list(secrets)}

    assert set_experimental_enabled(secrets, True)["enabled"] is True
    listed = {c["name"]: c for c in connector_list(secrets)}
    assert listed["dangerzone"]["experimental"] is True
    assert "laundry" in listed["dangerzone"]["risk_notice"]
    # first-party connectors are never flagged
    assert listed["github"]["experimental"] is False
    assert listed["whatsapp"]["experimental"] is False

    set_experimental_enabled(secrets, False)
    assert "dangerzone" not in {c["name"] for c in connector_list(secrets)}


def test_experimental_connect_requires_optin_and_ack(tmp_path, experimental_descriptor):
    from coworker.connectors import (
        connect_connector,
        connector_list,
        set_experimental_enabled,
    )

    secrets = SecretStore(tmp_path / "secrets.json")
    res = connect_connector(secrets, "dangerzone", {"token": "t"}, validate=False)
    assert res["ok"] is False and "disabled" in res["error"]

    set_experimental_enabled(secrets, True)
    res = connect_connector(secrets, "dangerzone", {"token": "t"}, validate=False)
    assert res["ok"] is False and "acknowledgment" in res["error"]
    assert "laundry" in res["risk_notice"]  # surfaced so the UI can show it

    res = connect_connector(
        secrets, "dangerzone", {"token": "t"}, validate=False, acknowledged=True
    )
    assert res["ok"] is True

    # turning the setting off hides it (and gates its tools) even though connected
    set_experimental_enabled(secrets, False)
    assert "dangerzone" not in {c["name"] for c in connector_list(secrets)}


def test_experimental_does_not_gate_regular_connectors(tmp_path):
    from coworker.connectors import connect_connector

    secrets = SecretStore(tmp_path / "secrets.json")
    res = connect_connector(
        secrets, "github", {"token": "ghp_x"}, validate=False
    )  # no acknowledged flag
    assert res["ok"] is True


def test_experimental_rest_roundtrip(tmp_path, monkeypatch, experimental_descriptor):
    from fastapi.testclient import TestClient

    from coworker.server.app import create_app
    from coworker.server.manager import SessionManager

    monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
    client = TestClient(create_app(SessionManager(data_dir=tmp_path / "data")))

    assert client.get("/v1/settings").json()["experimental_connectors"] is False
    names = {c["name"] for c in client.get("/v1/connectors").json()["connectors"]}
    assert "dangerzone" not in names

    assert (
        client.post(
            "/v1/settings/experimental-connectors", json={"value": True}
        ).json()["enabled"]
        is True
    )
    assert client.get("/v1/settings").json()["experimental_connectors"] is True
    names = {c["name"] for c in client.get("/v1/connectors").json()["connectors"]}
    assert "dangerzone" in names

    r = client.post(
        "/v1/connectors/dangerzone/connect", json={"fields": {"token": "T"}}
    ).json()
    assert r["ok"] is False and "acknowledgment" in r["error"]
    r = client.post(
        "/v1/connectors/dangerzone/connect",
        json={"fields": {"token": "T"}, "acknowledge_risk": True},
    ).json()
    assert r["ok"] is True


def test_experimental_package_loads_cleanly():
    """The experimental package import hook is a no-op when the package is empty or absent."""
    from coworker.connectors.descriptors import DESCRIPTORS
    from coworker.connectors.experimental import EXPERIMENTAL_DESCRIPTORS

    assert EXPERIMENTAL_DESCRIPTORS == []
    assert all(d.experimental is False for d in DESCRIPTORS if d.name != "dangerzone")
