from __future__ import annotations

import asyncio
import mimetypes
import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import httpx
import pytest

import nanobot.channels.whatsapp.runtime as whatsapp_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.whatsapp.runtime import (
    WhatsAppChannel,
    _legacy_bridge_config_fields,
    _NeonizeAPI,
)


class _Proto:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)

    def HasField(self, name: str) -> bool:  # noqa: N802 - protobuf compatibility
        return _is_set(getattr(self, name, None))

    def ListFields(self):  # noqa: N802 - protobuf compatibility
        return [
            (SimpleNamespace(name=name), value)
            for name, value in self.__dict__.items()
            if _is_set(value)
        ]


def _is_set(value) -> bool:
    if value is None:
        return False
    if isinstance(value, (str, bytes, list, tuple, dict, set)):
        return bool(value)
    return True


def _jid(user: str, server: str) -> _Proto:
    return _Proto(User=user, Server=server, IsEmpty=False)


def _event(
    *,
    message: _Proto,
    message_id: str = "m1",
    chat: _Proto | None = None,
    sender: _Proto | None = None,
    sender_alt: _Proto | None = None,
    is_group: bool = False,
    timestamp: int = 1,
    is_from_me: bool = False,
) -> _Proto:
    source = _Proto(
        Chat=chat or _jid("15551234567", "s.whatsapp.net"),
        Sender=sender,
        SenderAlt=sender_alt,
        IsGroup=is_group,
        IsFromMe=is_from_me,
    )
    return _Proto(
        Info=_Proto(ID=message_id, Timestamp=timestamp, MessageSource=source),
        Message=message,
    )


def _make_channel(config: dict | None = None) -> WhatsAppChannel:
    merged = {"enabled": True, "allowFrom": ["*"]}
    if config:
        merged.update(config)
    ch = WhatsAppChannel(merged, MagicMock())
    ch._started_at = 0
    return ch


def _make_send_client() -> SimpleNamespace:
    return SimpleNamespace(
        send_message=AsyncMock(),
        send_image=AsyncMock(),
        send_video=AsyncMock(),
        send_audio=AsyncMock(),
        send_document=AsyncMock(),
    )


def _patch_neonize_api(monkeypatch, detect_mime=None, detect_buffer=None) -> None:
    detect_mime = detect_mime or (
        lambda path, *, mime: mimetypes.guess_type(path)[0] or "application/octet-stream"
    )
    detect_buffer = detect_buffer or (lambda data, *, mime: "application/octet-stream")
    monkeypatch.setattr(
        whatsapp_module,
        "_NEONIZE_API",
        _NeonizeAPI(
            NewAClient=object,
            ConnectedEv=object(),
            DisconnectedEv=object(),
            MessageEv=object(),
            PairStatusEv=object(),
            build_jid=lambda user, server="s.whatsapp.net": (user, server),
            detect_mime=detect_mime,
            detect_buffer=detect_buffer,
        ),
    )


def _patch_receipt_type(monkeypatch):
    neonize = types.ModuleType("neonize")
    utils = types.ModuleType("neonize.utils")
    enum = types.ModuleType("neonize.utils.enum")

    class ReceiptType:
        READ = "read"

    enum.ReceiptType = ReceiptType
    neonize.utils = utils
    utils.enum = enum
    monkeypatch.setitem(sys.modules, "neonize", neonize)
    monkeypatch.setitem(sys.modules, "neonize.utils", utils)
    monkeypatch.setitem(sys.modules, "neonize.utils.enum", enum)
    return ReceiptType


class _FakeLoginClient:
    def __init__(self) -> None:
        self.handlers = {}
        self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid"))
        self.stop = AsyncMock()

    def event(self, event_type):
        def register(func):
            self.handlers[event_type] = func
            return func

        return register

    def qr(self, func):
        self.qr_handler = func
        return func

    async def connect(self) -> None:
        await self.handlers[whatsapp_module._NEONIZE_API.ConnectedEv](self, _Proto())


class _FailingConnectLoginClient(_FakeLoginClient):
    async def connect(self) -> asyncio.Task[None]:
        async def fail() -> None:
            raise RuntimeError("dial failed")

        return asyncio.create_task(fail())


def test_default_config_has_no_bridge_fields() -> None:
    config = WhatsAppChannel.default_config()

    assert "bridgeUrl" not in config
    assert "bridgeToken" not in config
    assert config["databasePath"] == ""


def test_legacy_bridge_config_fields_are_detected() -> None:
    assert _legacy_bridge_config_fields({"bridgeUrl": "ws://localhost:3001"}) == ["bridgeUrl"]
    assert _legacy_bridge_config_fields({"bridgeToken": "secret"}) == ["bridgeToken"]


@pytest.mark.asyncio
async def test_login_succeeds_when_connected(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch)
    client = _FakeLoginClient()
    ch = _make_channel()
    ch._new_client = MagicMock(return_value=client)

    assert await ch.login() is True
    assert ch._self_jids == {"bot@s.whatsapp.net", "bot", "BOTLID@lid", "BOTLID"}
    client.stop.assert_awaited_once()


@pytest.mark.asyncio
async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch)
    client = _FailingConnectLoginClient()
    ch = _make_channel()
    ch._new_client = MagicMock(return_value=client)

    assert await ch.login() is False
    client.stop.assert_awaited_once()


@pytest.mark.asyncio
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch)
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    await ch.send(OutboundMessage(channel="whatsapp", chat_id="12345@s.whatsapp.net", content="hi"))

    client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi")


@pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch)
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    await ch.send(
        OutboundMessage(
            channel="whatsapp",
            chat_id="12345@s.whatsapp.net",
            content="",
            media=["photo.jpg", "clip.mp4", "voice.mp3", "report.pdf"],
        )
    )

    jid = ("12345", "s.whatsapp.net")
    client.send_image.assert_awaited_once_with(jid, "photo.jpg")
    client.send_video.assert_awaited_once_with(jid, "clip.mp4")
    client.send_audio.assert_awaited_once_with(jid, "voice.mp3")
    client.send_document.assert_awaited_once_with(
        jid,
        "report.pdf",
        filename="report.pdf",
        mimetype="application/pdf",
    )


@pytest.mark.asyncio
async def test_send_mislabeled_audio_as_document(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/x-wav")
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    await ch.send(
        OutboundMessage(
            channel="whatsapp",
            chat_id="12345@s.whatsapp.net",
            content="",
            media=["recording.mpeg"],
        )
    )

    jid = ("12345", "s.whatsapp.net")
    client.send_document.assert_awaited_once_with(
        jid,
        "recording.mpeg",
        filename="recording.mpeg",
        mimetype="audio/x-wav",
    )
    client.send_video.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_remote_mislabeled_audio_as_document(monkeypatch) -> None:
    payload = b"remote wav payload"
    media_url = "https://cdn.example/recording.mpeg?token=secret"

    def handle_request(request: httpx.Request) -> httpx.Response:
        assert str(request.url) == media_url
        return httpx.Response(200, content=payload)

    monkeypatch.setattr(
        whatsapp_module,
        "PinnedDNSAsyncTransport",
        lambda: httpx.MockTransport(handle_request),
    )

    def detect_buffer(data: bytes, *, mime: bool) -> str:
        assert data == payload
        assert mime is True
        return "audio/x-wav"

    _patch_neonize_api(
        monkeypatch,
        detect_buffer=detect_buffer,
    )
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    await ch.send(
        OutboundMessage(
            channel="whatsapp",
            chat_id="12345@s.whatsapp.net",
            content="",
            media=[media_url],
        )
    )

    jid = ("12345", "s.whatsapp.net")
    client.send_document.assert_awaited_once_with(
        jid,
        payload,
        filename="recording.mpeg",
        mimetype="audio/x-wav",
    )
    client.send_video.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_remote_media_blocks_private_url(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch)
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    with pytest.raises(httpx.RequestError, match="private/internal"):
        await ch.send(
            OutboundMessage(
                channel="whatsapp",
                chat_id="12345@s.whatsapp.net",
                content="",
                media=["http://127.0.0.1/recording.mpeg"],
            )
        )

    client.send_video.assert_not_awaited()
    client.send_document.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_remote_media_enforces_download_limit(monkeypatch) -> None:
    monkeypatch.setattr(whatsapp_module, "_REMOTE_MEDIA_MAX_BYTES", 3)
    monkeypatch.setattr(
        whatsapp_module,
        "PinnedDNSAsyncTransport",
        lambda: httpx.MockTransport(lambda request: httpx.Response(200, content=b"1234")),
    )
    _patch_neonize_api(monkeypatch)
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    with pytest.raises(ValueError, match="exceeds the 3-byte limit"):
        await ch.send(
            OutboundMessage(
                channel="whatsapp",
                chat_id="12345@s.whatsapp.net",
                content="",
                media=["https://cdn.example/recording.mpeg"],
            )
        )

    client.send_video.assert_not_awaited()
    client.send_document.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_unsupported_ogg_audio_as_document(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/ogg")
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    await ch.send(
        OutboundMessage(
            channel="whatsapp",
            chat_id="12345@s.whatsapp.net",
            content="",
            media=["voice.ogg"],
        )
    )

    jid = ("12345", "s.whatsapp.net")
    client.send_document.assert_awaited_once_with(
        jid,
        "voice.ogg",
        filename="voice.ogg",
        mimetype="audio/ogg",
    )
    client.send_audio.assert_not_awaited()


@pytest.mark.parametrize(
    ("detected_mimetype", "filename"),
    [
        ("audio/x-m4a", "recording.m4a"),
        ("audio/x-hx-aac-adts", "recording.aac"),
    ],
)
@pytest.mark.asyncio
async def test_send_supported_audio_magic_aliases_inline(
    monkeypatch, detected_mimetype: str, filename: str
) -> None:
    _patch_neonize_api(
        monkeypatch,
        detect_mime=lambda path, *, mime: detected_mimetype,
    )
    client = _make_send_client()
    ch = _make_channel()
    ch._client = client
    ch._connected = True

    await ch.send(
        OutboundMessage(
            channel="whatsapp",
            chat_id="12345@s.whatsapp.net",
            content="",
            media=[filename],
        )
    )

    client.send_audio.assert_awaited_once_with(("12345", "s.whatsapp.net"), filename)
    client.send_document.assert_not_awaited()


@pytest.mark.asyncio
async def test_send_when_disconnected_raises() -> None:
    ch = _make_channel()

    with pytest.raises(RuntimeError, match="not connected"):
        await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi"))


@pytest.mark.asyncio
async def test_group_policy_mention_skips_unmentioned_group_message() -> None:
    ch = _make_channel({"groupPolicy": "mention"})
    ch._self_jids = {"bot@s.whatsapp.net", "bot"}
    ch._handle_message = AsyncMock()

    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=_Proto(conversation="hello group"),
            chat=_jid("120363000", "g.us"),
            sender=_jid("SENDERLID", "lid"),
            is_group=True,
        ),
    )

    ch._handle_message.assert_not_called()


@pytest.mark.asyncio
async def test_group_policy_mention_accepts_mention_and_prefers_phone_sender() -> None:
    ch = _make_channel({"groupPolicy": "mention"})
    ch._self_jids = {"bot@s.whatsapp.net", "bot"}
    ch._handle_message = AsyncMock()
    context = _Proto(mentionedJID=["bot@s.whatsapp.net"])
    message = _Proto(extendedTextMessage=_Proto(text="hello @bot", contextInfo=context))

    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=message,
            chat=_jid("120363000", "g.us"),
            sender=_jid("LID99", "lid"),
            sender_alt=_jid("15559998888", "s.whatsapp.net"),
            is_group=True,
        ),
    )

    kwargs = ch._handle_message.await_args.kwargs
    assert kwargs["sender_id"] == "15559998888"
    assert kwargs["chat_id"] == "120363000@g.us"
    assert kwargs["metadata"]["lid"] == "LID99"
    assert kwargs["metadata"]["phone"] == "15559998888"


@pytest.mark.asyncio
async def test_group_policy_mention_accepts_reply_to_bot() -> None:
    ch = _make_channel({"groupPolicy": "mention"})
    ch._self_jids = {"bot@s.whatsapp.net", "bot"}
    ch._handle_message = AsyncMock()
    context = _Proto(participant="bot@s.whatsapp.net")
    message = _Proto(extendedTextMessage=_Proto(text="reply", contextInfo=context))

    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=message,
            chat=_jid("120363000", "g.us"),
            sender=_jid("SENDERLID", "lid"),
            is_group=True,
        ),
    )

    kwargs = ch._handle_message.await_args.kwargs
    assert kwargs["metadata"]["is_reply_to_bot"] is True


@pytest.mark.asyncio
async def test_group_sender_id_uses_participant_not_group_jid() -> None:
    ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock())
    ch._started_at = 0
    ch._handle_message = AsyncMock()

    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=_Proto(conversation="hi"),
            chat=_jid("120363000", "g.us"),
            sender=_jid("SENDERLID", "lid"),
            is_group=True,
        ),
    )

    kwargs = ch._handle_message.await_args.kwargs
    assert kwargs["sender_id"] == "SENDERLID"
    assert kwargs["metadata"]["participant"] == "SENDERLID@lid"


@pytest.mark.parametrize("allowed_group", ["120363000@g.us", "120363000"])
@pytest.mark.asyncio
async def test_group_allow_from_accepts_group_jid_or_bare_id(allowed_group: str) -> None:
    bus = MessageBus()
    ch = WhatsAppChannel({"enabled": True, "allowFrom": [allowed_group]}, bus)
    ch._started_at = 0

    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=_Proto(conversation="hi"),
            chat=_jid("120363000", "g.us"),
            sender=_jid("SENDERLID", "lid"),
            is_group=True,
        ),
    )

    assert bus.inbound_size == 1
    msg = await bus.consume_inbound()
    assert msg.sender_id == "SENDERLID"
    assert msg.chat_id == "120363000@g.us"
    assert msg.content == "hi"
    assert msg.metadata["participant"] == "SENDERLID@lid"


@pytest.mark.asyncio
async def test_group_allow_from_does_not_allow_same_participant_in_other_group() -> None:
    bus = MessageBus()
    ch = WhatsAppChannel({"enabled": True, "allowFrom": ["120363000"]}, bus)
    ch._started_at = 0

    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=_Proto(conversation="hi"),
            chat=_jid("120363999", "g.us"),
            sender=_jid("SENDERLID", "lid"),
            is_group=True,
        ),
    )

    assert bus.inbound_size == 0


@pytest.mark.asyncio
async def test_read_receipt_is_requested_once_after_dedup() -> None:
    ch = _make_channel()
    ch._send_read_receipt = AsyncMock()
    ch._handle_message = AsyncMock()
    client = SimpleNamespace(download_any=AsyncMock())
    event = _event(
        message=_Proto(conversation="hi"),
        sender=_jid("15551234567", "s.whatsapp.net"),
    )

    await ch._handle_neonize_message(client, event)
    await ch._handle_neonize_message(client, event)

    ch._send_read_receipt.assert_awaited_once_with(
        client,
        event.Info.MessageSource,
        "m1",
    )
    ch._handle_message.assert_awaited_once()


@pytest.mark.asyncio
async def test_send_read_receipt_uses_mark_read_and_swallows_failures(monkeypatch) -> None:
    receipt_type = _patch_receipt_type(monkeypatch)
    ch = _make_channel()
    source = _event(
        message=_Proto(conversation="hi"),
        sender=_jid("15551234567", "s.whatsapp.net"),
    ).Info.MessageSource
    client = SimpleNamespace(
        mark_read=AsyncMock(),
        download_any=AsyncMock(),
    )

    await ch._send_read_receipt(client, source, "m1")

    client.mark_read.assert_awaited_once_with(
        "m1",
        chat=source.Chat,
        sender=source.Sender,
        receipt=receipt_type.READ,
    )

    failing_client = SimpleNamespace(
        mark_read=AsyncMock(side_effect=RuntimeError("boom")),
        download_any=AsyncMock(),
    )

    await ch._send_read_receipt(failing_client, source, "m2")

    failing_client.mark_read.assert_awaited_once()


@pytest.mark.asyncio
async def test_lid_to_phone_cache_resolves_lid_only_messages() -> None:
    ch = _make_channel()
    ch._handle_message = AsyncMock()

    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=_Proto(conversation="first"),
            message_id="c1",
            chat=_jid("LID99", "lid"),
            sender=_jid("LID99", "lid"),
            sender_alt=_jid("5559999", "s.whatsapp.net"),
        ),
    )
    await ch._handle_neonize_message(
        SimpleNamespace(download_any=AsyncMock()),
        _event(
            message=_Proto(conversation="second"),
            message_id="c2",
            chat=_jid("LID99", "lid"),
            sender=_jid("LID99", "lid"),
        ),
    )

    assert ch._handle_message.await_args_list[1].kwargs["sender_id"] == "5559999"


def test_lid_mappings_from_config() -> None:
    ch = WhatsAppChannel(
        {"enabled": True, "lidMappings": {"123456789012345": "15551234567"}},
        MagicMock(),
    )

    assert ch._lid_to_phone == {"123456789012345": "15551234567"}


@pytest.mark.asyncio
async def test_image_media_is_downloaded_and_forwarded(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
    ch = _make_channel()
    ch._handle_message = AsyncMock()
    client = SimpleNamespace(download_any=AsyncMock())
    message = _Proto(
        imageMessage=_Proto(
            caption="look",
            mimetype="image/jpeg",
        )
    )

    await ch._handle_neonize_message(
        client,
        _event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
    )

    client.download_any.assert_awaited_once()
    kwargs = ch._handle_message.await_args.kwargs
    assert kwargs["content"].startswith("look\n[image: ")
    assert len(kwargs["media"]) == 1
    assert kwargs["media"][0].endswith(".jpg")


@pytest.mark.asyncio
async def test_voice_message_transcribes_and_drops_media_when_successful(
    monkeypatch, tmp_path
) -> None:
    monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
    ch = _make_channel()
    ch._handle_message = AsyncMock()
    ch.transcribe_audio = AsyncMock(return_value="Hello from audio")
    client = SimpleNamespace(download_any=AsyncMock())
    message = _Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True))

    await ch._handle_neonize_message(
        client,
        _event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
    )

    ch.transcribe_audio.assert_awaited_once()
    kwargs = ch._handle_message.await_args.kwargs
    assert kwargs["content"] == "Hello from audio"
    assert kwargs["media"] == []


@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_download_or_transcribe(
    monkeypatch, tmp_path
) -> None:
    monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
    ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
    ch._started_at = 0
    ch._handle_message = AsyncMock()
    ch.transcribe_audio = AsyncMock(return_value="blocked audio")
    client = SimpleNamespace(download_any=AsyncMock())

    await ch._handle_neonize_message(
        client,
        _event(
            message=_Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)),
            chat=_jid("blocked", "s.whatsapp.net"),
            sender=_jid("blocked", "s.whatsapp.net"),
        ),
    )

    client.download_any.assert_not_awaited()
    ch.transcribe_audio.assert_not_awaited()
    ch._handle_message.assert_awaited_once()
    kwargs = ch._handle_message.await_args.kwargs
    assert kwargs["sender_id"] == "blocked"
    assert kwargs["content"] == ""
    assert kwargs["media"] == []
    assert kwargs["is_dm"] is True


@pytest.mark.asyncio
async def test_unauthorized_dm_uses_base_pairing_flow(monkeypatch) -> None:
    _patch_neonize_api(monkeypatch)
    monkeypatch.setattr("nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH")
    monkeypatch.setattr("nanobot.channels.base.is_approved", lambda _ch, _sid: False)
    client = SimpleNamespace(send_message=AsyncMock(), download_any=AsyncMock())
    ch = WhatsAppChannel({"enabled": True, "allowFrom": []}, MagicMock())
    ch._client = client
    ch._connected = True
    ch._started_at = 0

    await ch._handle_neonize_message(
        client,
        _event(
            message=_Proto(conversation="hello"),
            chat=_jid("blocked", "s.whatsapp.net"),
            sender=_jid("blocked", "s.whatsapp.net"),
        ),
    )

    client.download_any.assert_not_awaited()
    client.send_message.assert_awaited_once()
    assert client.send_message.await_args.args[0] == ("blocked", "s.whatsapp.net")
    assert "ABCD-EFGH" in client.send_message.await_args.args[1]


def test_reset_database_removes_sqlite_sidecars(tmp_path) -> None:
    db = tmp_path / "neonize.db"
    wal = tmp_path / "neonize.db-wal"
    shm = tmp_path / "neonize.db-shm"
    for path in (db, wal, shm):
        path.write_text("x", encoding="utf-8")

    WhatsAppChannel._reset_database(db)

    assert not db.exists()
    assert not wal.exists()
    assert not shm.exists()
