"""Unit tests for `app.services.tgbot.bridge` — чистая логика роутинга.

Coverage (per task spec + review follow-up):
- user → topic (личка клиента зеркалится в support-топик, с шапкой на первое
  сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
  после истечения окна — #6 review)
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
- реплай оператора → веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
  в web-тред (скоуп по (topic_message_id, support_chat_id) — review M1), ответ
  пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай (в т.ч. фото С
  ПОДПИСЬЮ) на веб-зеркало — отказ ЦЕЛИКОМ + уведомление оператору в топике
  (review M2, никакой частичной доставки одной подписи); зеркало от ЧУЖОГО/
  устаревшего support_chat_id — не матчится (ротация группы); NULL
  support_chat_id (легаси) — wildcard-матч; совпадение ОБЕИХ сторон
  одновременно (tg И web) — громкий отказ (logger.error), а не молчаливый
  выбор tg-пути (152-ФЗ misroute risk)
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
  реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
  (#4 review)
- дедуп update_id (<=offset — skip без side-effects; poison-pill апдейт всё
  равно сдвигает offset, чтобы не подвесить весь поток)
- сбой БД (SQLAlchemyError) во время обработки → rollback() ПЕРЕД save_offset,
  offset всё равно сдвигается — без этого следующий поллинг переиграл бы тот
  же апдейт и задублировал зеркало клиента в топике (#3 review)
- /start → приветствие без зеркалирования
- Telegram 403 на доставку оператору → is_blocked + уведомление в топике
- TELEGRAM_SUPPORT_CHAT_ID не задан → клиенту уходит "сервис недоступен"
  вместо тихой потери сообщения (#5 review)

NEVER calls real Telegram API — все HTTP-запросы mock'аются через
httpx.MockTransport (consistent с tests/services/test_dadata.py).
"""

from __future__ import annotations

import logging
import os
from typing import Any

import httpx
import pytest
from sqlalchemy.exc import SQLAlchemyError

# DATABASE_URL required by app.core.config before any app import (см. test_dadata.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.services.tgbot import bridge
from app.services.tgbot.client import TelegramClient

SUPPORT_CHAT_ID = -100123456789
SUPPORT_TOPIC_ID = 42


# ── Fake in-memory storage (БД не нужна) ─────────────────────────────────────
class FakeBridgeStorage:
    """In-memory `BridgeStorage` — никакой реальной БД, чистая логика роутинга.

    `clock_s` — управляемые тестом "фейковые часы" (просто float, продвигается
    вручную через `storage.clock_s += ...`), чтобы честно проверить throttle-окно
    в `had_recent_inbound` (#6 review) без реального `time.sleep`/datetime-моков.

    `fail_next_record_message` — если True, следующий вызов `record_message`
    кидает `SQLAlchemyError` (симулирует обрыв коннекта к БД) и сбрасывается в
    False — для теста rollback-пути в `process_update` (#3 review).
    """

    def __init__(self, offset: int = 0) -> None:
        self._offset = offset
        self.users: dict[int, dict[str, Any]] = {}
        self.messages: list[dict[str, Any]] = []
        self.blocked: set[int] = set()
        self.commits = 0
        self.rollbacks = 0
        self._next_id = 1
        self.clock_s: float = 0.0
        self.fail_next_record_message = False
        # #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
        # (thread_id, support_chat_id) — второй элемент моделирует колонку
        # web_support_messages.support_chat_id (review M1); None = легаси wildcard.
        # + журнал outbound-записей, записанных через реплай оператора.
        self.web_topic_to_thread: dict[int, tuple[int, int | None]] = {}
        self.web_out_messages: list[dict[str, Any]] = []

    def get_offset(self) -> int:
        return self._offset

    def save_offset(self, update_id: int) -> None:
        self._offset = update_id

    def commit(self) -> None:
        self.commits += 1

    def rollback(self) -> None:
        self.rollbacks += 1

    def upsert_user(
        self,
        *,
        chat_id: int,
        username: str | None,
        first_name: str | None,
        last_name: str | None,
        language_code: str | None,
    ) -> None:
        self.users[chat_id] = {
            "username": username,
            "first_name": first_name,
            "last_name": last_name,
            "language_code": language_code,
        }

    def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
        return any(
            m["chat_id"] == chat_id
            and m["direction"] == "in"
            and (self.clock_s - m["recorded_at_s"]) < window_seconds
            for m in self.messages
        )

    def record_message(
        self,
        *,
        chat_id: int,
        direction: str,
        tg_message_id: int | None,
        topic_message_id: int | None,
        kind: str,
        text_body: str | None,
        operator_tg_id: int | None,
        support_chat_id: int | None = None,
    ) -> int:
        if self.fail_next_record_message:
            self.fail_next_record_message = False
            raise SQLAlchemyError("simulated DB failure (deploy connection reset)")
        row_id = self._next_id
        self._next_id += 1
        self.messages.append(
            {
                "id": row_id,
                "chat_id": chat_id,
                "direction": direction,
                "tg_message_id": tg_message_id,
                "topic_message_id": topic_message_id,
                "kind": kind,
                "text_body": text_body,
                "operator_tg_id": operator_tg_id,
                "support_chat_id": support_chat_id,
                "recorded_at_s": self.clock_s,
            }
        )
        return row_id

    def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
        """support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
        support_chat_id не матчится — None (легаси/дефолт) матчится всегда."""
        for m in reversed(self.messages):
            if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
                continue
            entry_chat_id = m.get("support_chat_id")
            if entry_chat_id is not None and entry_chat_id != support_chat_id:
                continue
            return m["chat_id"]
        return None

    def mark_blocked(self, chat_id: int) -> None:
        self.blocked.add(chat_id)

    # ── #tgsupport-web ────────────────────────────────────────────────────
    def find_web_thread_by_topic_message(
        self, topic_message_id: int, support_chat_id: int
    ) -> int | None:
        """Тот же support_chat_id-скоуп, что и `find_chat_by_topic_message` (review M1)."""
        entry = self.web_topic_to_thread.get(topic_message_id)
        if entry is None:
            return None
        thread_id, entry_chat_id = entry
        if entry_chat_id is not None and entry_chat_id != support_chat_id:
            return None
        return thread_id

    def record_web_out_message(
        self, *, thread_id: int, text_body: str, operator_tg_id: int | None
    ) -> None:
        self.web_out_messages.append(
            {
                "thread_id": thread_id,
                "text_body": text_body,
                "operator_tg_id": operator_tg_id,
            }
        )


# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
_REAL_ASYNC_CLIENT = httpx.AsyncClient


def _method_from_url(url: httpx.URL) -> str:
    return str(url).rsplit("/", 1)[-1]


def _make_client(
    responses: dict[str, Any], calls: list[tuple[str, dict[str, Any]]]
) -> TelegramClient:
    """TelegramClient wired to a MockTransport. `responses[method]` may be a dict
    (returned as Bot API `result`), an int (HTTP error status), or a callable
    `(payload) -> dict`. Every request is recorded into `calls`."""

    def handler(request: httpx.Request) -> httpx.Response:
        import json as _json

        method = _method_from_url(request.url)
        payload = _json.loads(request.content.decode("utf-8")) if request.content else {}
        calls.append((method, payload))

        canned = responses.get(method)
        if isinstance(canned, int):
            return httpx.Response(
                canned, json={"ok": False, "error_code": canned, "description": "mocked error"}
            )
        if callable(canned):
            canned = canned(payload)
        result = canned if canned is not None else {"message_id": 999}
        return httpx.Response(200, json={"ok": True, "result": result})

    transport = httpx.MockTransport(handler)

    def factory(*_: object, **__: object) -> httpx.AsyncClient:
        return _REAL_ASYNC_CLIENT(transport=transport)

    client = TelegramClient(token="fake-token")
    import unittest.mock as mock

    # Патчим httpx.AsyncClient ТОЛЬКО внутри client-модуля — не трогаем глобальный httpx.
    patcher = mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory)
    patcher.start()
    return client


@pytest.fixture(autouse=True)
def _support_chat_settings(monkeypatch: pytest.MonkeyPatch) -> None:
    """Все тесты по умолчанию считают support-группу/топик настроенными."""
    monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", SUPPORT_CHAT_ID)
    monkeypatch.setattr(bridge.settings, "telegram_support_topic_id", SUPPORT_TOPIC_ID)


@pytest.fixture(autouse=True)
def _reset_flood_limiters(monkeypatch: pytest.MonkeyPatch) -> None:
    """F) `_flood_limiter`/`_flood_notify_limiter` — module-level singletons (тот же
    паттерн, что `_send_limiter` в app/api/v1/support.py); большинство тестов в
    этом файле шлют сообщения от одного и того же chat_id=555, поэтому без сброса
    накопленные хиты одного теста бы протекали в следующий и ломали его
    предположения (тест флуда должен видеть ЧИСТЫЙ бюджет)."""
    monkeypatch.setattr(
        bridge,
        "_flood_limiter",
        bridge.SlidingWindowLimiter(limit=bridge._FLOOD_LIMIT, window_s=bridge._FLOOD_WINDOW_S),
    )
    monkeypatch.setattr(
        bridge,
        "_flood_notify_limiter",
        bridge.SlidingWindowLimiter(limit=1, window_s=bridge._FLOOD_WINDOW_S),
    )


@pytest.fixture(autouse=True)
def _stop_patches():
    """Останавливает httpx.AsyncClient monkeypatch после каждого теста (unittest.mock.patch.start()
    без контекст-менеджера требует явного stop, чтобы не утекать в соседние тесты)."""
    import unittest.mock as mock

    yield
    mock.patch.stopall()


def _private_message(
    *,
    message_id: int = 1,
    chat_id: int = 555,
    text: str | None = "Здравствуйте, вопрос по trade-in",
    username: str | None = "client_ivan",
    first_name: str | None = "Иван",
    last_name: str | None = "Петров",
) -> dict[str, Any]:
    msg: dict[str, Any] = {
        "message_id": message_id,
        "chat": {"id": chat_id, "type": "private"},
        "from": {
            "id": chat_id,
            "username": username,
            "first_name": first_name,
            "last_name": last_name,
            "language_code": "ru",
        },
    }
    if text is not None:
        msg["text"] = text
    return msg


def _group_reply_message(
    *,
    message_id: int = 200,
    reply_to_message_id: int | None = 100,
    text: str = "Ответ оператора",
    operator_id: int = 777,
) -> dict[str, Any]:
    msg: dict[str, Any] = {
        "message_id": message_id,
        "chat": {"id": SUPPORT_CHAT_ID, "type": "supergroup"},
        "from": {"id": operator_id, "username": "operator1"},
        "text": text,
    }
    if reply_to_message_id is not None:
        msg["reply_to_message"] = {"message_id": reply_to_message_id}
    return msg


# ── A) user → topic ──────────────────────────────────────────────────────────


async def test_private_message_mirrors_to_topic_with_header_on_first_contact() -> None:
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 555}}, calls)
    storage = FakeBridgeStorage()

    update = {"update_id": 10, "message": _private_message()}
    await bridge.process_update(update, client, storage)

    methods = [m for m, _ in calls]
    # Первое сообщение за окно → шапка ПЕРЕД зеркалом контента.
    assert methods == ["sendMessage", "copyMessage"]

    header_call = calls[0][1]
    assert header_call["chat_id"] == SUPPORT_CHAT_ID
    assert header_call["message_thread_id"] == SUPPORT_TOPIC_ID
    assert "Иван Петров" in header_call["text"]
    assert "@client_ivan" in header_call["text"]

    mirror_call = calls[1][1]
    assert mirror_call["from_chat_id"] == 555
    assert mirror_call["chat_id"] == SUPPORT_CHAT_ID
    assert mirror_call["message_id"] == 1
    assert mirror_call["message_thread_id"] == SUPPORT_TOPIC_ID

    assert len(storage.messages) == 1
    rec = storage.messages[0]
    assert rec["direction"] == "in"
    assert rec["chat_id"] == 555
    assert rec["topic_message_id"] == 555  # copyMessage result.message_id
    assert rec["kind"] == "text"
    assert rec["text_body"] == "Здравствуйте, вопрос по trade-in"

    assert storage.get_offset() == 10
    assert storage.commits == 1
    assert 555 in storage.users


async def test_private_message_second_message_within_window_skips_header() -> None:
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 556}}, calls)
    storage = FakeBridgeStorage()
    # Симулируем уже существующее inbound-сообщение за последний час.
    storage.record_message(
        chat_id=555,
        direction="in",
        tg_message_id=0,
        topic_message_id=100,
        kind="text",
        text_body="первое сообщение",
        operator_tg_id=None,
    )

    update = {"update_id": 11, "message": _private_message(message_id=2)}
    await bridge.process_update(update, client, storage)

    methods = [m for m, _ in calls]
    # Шапка НЕ отправляется повторно — только зеркало.
    assert methods == ["copyMessage"]
    assert len(storage.messages) == 2


async def test_private_message_header_resent_after_window_expires() -> None:
    """#6 review: throttle-окно (3600с) реально проверяется по времени — после
    истечения окна шапка отправляется заново (не одна на весь чат навсегда)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 557}}, calls)
    storage = FakeBridgeStorage()
    storage.record_message(
        chat_id=555,
        direction="in",
        tg_message_id=0,
        topic_message_id=100,
        kind="text",
        text_body="первое сообщение (час назад)",
        operator_tg_id=None,
    )
    # Продвигаем фейковые часы за throttle-окно (3600с).
    storage.clock_s += bridge._HEADER_THROTTLE_WINDOW_S + 1

    update = {"update_id": 13, "message": _private_message(message_id=3)}
    await bridge.process_update(update, client, storage)

    methods = [m for m, _ in calls]
    assert methods == ["sendMessage", "copyMessage"]  # шапка снова отправлена
    assert len(storage.messages) == 2


async def test_private_message_start_sends_greeting_without_mirroring() -> None:
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    update = {"update_id": 12, "message": _private_message(text="/start")}
    await bridge.process_update(update, client, storage)

    methods = [m for m, _ in calls]
    assert methods == ["sendMessage"]
    greeting_call = calls[0][1]
    assert greeting_call["chat_id"] == 555
    assert "МЕРА" in greeting_call["text"]
    # /start не зеркалируется и не попадает в лог переписки.
    assert storage.messages == []
    assert storage.get_offset() == 12


async def test_private_message_notifies_client_when_support_chat_unset(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """#5 review: TELEGRAM_SUPPORT_CHAT_ID не задан → клиент получает "сервис
    недоступен" вместо того, чтобы молча ждать ответа, который никогда не придёт."""
    monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", 0)
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    update = {"update_id": 14, "message": _private_message()}
    await bridge.process_update(update, client, storage)

    methods = [m for m, _ in calls]
    assert methods == ["sendMessage"]
    notify_call = calls[0][1]
    assert notify_call["chat_id"] == 555
    assert notify_call["text"] == bridge.SERVICE_UNAVAILABLE_TEXT
    # Ничего не зеркалируется и не пишется в лог переписки — support-группа не настроена.
    assert storage.messages == []
    assert storage.get_offset() == 14


# ── F) флуд-лимит на отправителя (низкий приоритет) ─────────────────────────


async def test_private_message_flood_limit_blocks_excess_and_notifies_once() -> None:
    """Больше `_FLOOD_LIMIT` сообщений от ОДНОГО chat_id за окно — зеркалирование
    сверх лимита отключается (никакого copyMessage, никакой записи в
    tg_support_messages — маршрутизировать ответ всё равно нечего без
    topic_message_id). Клиент получает уведомление о недоставке РОВНО один раз
    за окно, а не на каждое следующее превышение — иначе само уведомление стало
    бы вторым источником флуда."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 900}}, calls)
    storage = FakeBridgeStorage()

    update_id = 100
    for i in range(bridge._FLOOD_LIMIT):
        update = {"update_id": update_id, "message": _private_message(message_id=i + 1)}
        await bridge.process_update(update, client, storage)
        update_id += 1

    # Ровно _FLOOD_LIMIT сообщений прошли мирроринг: первое — шапка + зеркало,
    # остальные — только зеркало.
    mirrored_calls = [m for m, _ in calls if m == "copyMessage"]
    assert len(mirrored_calls) == bridge._FLOOD_LIMIT
    assert len(storage.messages) == bridge._FLOOD_LIMIT

    calls.clear()
    over_limit_update = {
        "update_id": update_id,
        "message": _private_message(message_id=bridge._FLOOD_LIMIT + 1),
    }
    await bridge.process_update(over_limit_update, client, storage)
    update_id += 1

    # Сверх лимита — НЕ зеркалируется, НЕ пишется в лог переписки, клиент
    # получает уведомление о недоставке (не тихий игнор — клиент не должен
    # решить, что оператор получил сообщение).
    assert len(calls) == 1
    method, payload = calls[0]
    assert method == "sendMessage"
    assert payload["chat_id"] == 555
    assert payload["text"] == bridge.FLOOD_LIMITED_TEXT
    assert len(storage.messages) == bridge._FLOOD_LIMIT

    calls.clear()
    second_over_limit_update = {
        "update_id": update_id,
        "message": _private_message(message_id=bridge._FLOOD_LIMIT + 2),
    }
    await bridge.process_update(second_over_limit_update, client, storage)

    # Повторное превышение в ТОМ ЖЕ окне — уведомление подавлено (не второй
    # источник флуда), никаких Telegram-вызовов вообще.
    assert calls == []
    assert len(storage.messages) == bridge._FLOOD_LIMIT


async def test_private_message_flood_limit_does_not_block_other_client() -> None:
    """Флуд-лимит — per-chat_id: клиент А исчерпал свой бюджет, но клиент Б
    (другой chat_id) продолжает получать зеркалирование как обычно — один
    флудящий клиент не блокирует доставку сообщений остальным (сама суть
    задачи — воркер однопоточный, но лимит не даёт флудеру монополизировать
    его через Telegram 429)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 901}}, calls)
    storage = FakeBridgeStorage()

    flooding_chat_id = 555
    update_id = 300
    for i in range(bridge._FLOOD_LIMIT + 2):
        update = {
            "update_id": update_id,
            "message": _private_message(chat_id=flooding_chat_id, message_id=i + 1),
        }
        await bridge.process_update(update, client, storage)
        update_id += 1

    calls.clear()

    other_chat_id = 777001
    other_update = {
        "update_id": update_id,
        "message": _private_message(chat_id=other_chat_id, message_id=1, username="another_client"),
    }
    await bridge.process_update(other_update, client, storage)

    methods = [m for m, _ in calls]
    # Другой клиент получает шапку (первое обращение) + зеркало как обычно —
    # флуд первого клиента на него не влияет.
    assert methods == ["sendMessage", "copyMessage"]
    mirror_call = calls[1][1]
    assert mirror_call["from_chat_id"] == other_chat_id


# ── B) реплай оператора → user ──────────────────────────────────────────────


async def test_group_reply_delivers_to_client_and_records_outbound() -> None:
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 42}}, calls)
    storage = FakeBridgeStorage()
    storage.record_message(
        chat_id=555,
        direction="in",
        tg_message_id=1,
        topic_message_id=100,
        kind="text",
        text_body="вопрос клиента",
        operator_tg_id=None,
    )

    update = {
        "update_id": 20,
        "message": _group_reply_message(reply_to_message_id=100),
    }
    await bridge.process_update(update, client, storage)

    methods = [m for m, _ in calls]
    assert methods == ["copyMessage"]
    delivery = calls[0][1]
    assert delivery["chat_id"] == 555
    assert delivery["from_chat_id"] == SUPPORT_CHAT_ID

    assert len(storage.messages) == 2
    out_rec = storage.messages[-1]
    assert out_rec["direction"] == "out"
    assert out_rec["chat_id"] == 555
    assert out_rec["operator_tg_id"] == 777
    assert out_rec["text_body"] == "Ответ оператора"
    assert storage.get_offset() == 20


async def test_group_reply_not_a_reply_is_ignored() -> None:
    """Обычное сообщение в топике (не реплай) — тихий игнор, никаких Telegram-вызовов."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    update = {
        "update_id": 21,
        "message": _group_reply_message(reply_to_message_id=None),
    }
    await bridge.process_update(update, client, storage)

    assert calls == []
    assert storage.messages == []
    # offset всё равно сдвигается — апдейт "обработан" (даже если ничего не сделано).
    assert storage.get_offset() == 21


async def test_group_reply_to_unknown_message_is_ignored() -> None:
    """Реплай на сообщение, которого нет в tg_support_messages как зеркало клиента —
    тихий игнор (обычная болтовня в топике на постороннее сообщение, не от бота)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    update = {
        "update_id": 22,
        "message": _group_reply_message(reply_to_message_id=999999),
    }
    await bridge.process_update(update, client, storage)

    assert calls == []
    assert storage.messages == []
    assert storage.get_offset() == 22


async def test_group_reply_to_bot_message_without_record_logs_orphaned_mirror_warning(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """#4 review: реплай на сообщение БОТА, которого нет в tg_support_messages, —
    вероятное осиротевшее зеркало (крах между copyMessage и commit). WARNING, не
    тихий игнор — оператор иначе решит, что ответ клиенту доставлен."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    message = _group_reply_message(reply_to_message_id=100)
    message["reply_to_message"]["from"] = {"id": 999, "is_bot": True, "username": "MERAsupport_bot"}
    update = {"update_id": 24, "message": message}

    with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
        await bridge.process_update(update, client, storage)

    assert calls == []  # ответ НЕ доставлен — routing-ключ потерян
    assert "осиротевшее" in caplog.text
    assert storage.get_offset() == 24


async def test_group_reply_to_non_bot_message_without_record_stays_silent(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Обычный реплай на сообщение ДРУГОГО ЧЕЛОВЕКА (не бота) в топике — реальная
    болтовня, никакого WARNING (дискриминатор `is_bot` работает в обе стороны)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    message = _group_reply_message(reply_to_message_id=101)
    message["reply_to_message"]["from"] = {"id": 42, "is_bot": False, "username": "colleague"}
    update = {"update_id": 25, "message": message}

    with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
        await bridge.process_update(update, client, storage)

    assert calls == []
    assert caplog.text == ""


async def test_group_reply_403_marks_blocked_and_notifies_topic() -> None:
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": 403}, calls)
    storage = FakeBridgeStorage()
    storage.record_message(
        chat_id=555,
        direction="in",
        tg_message_id=1,
        topic_message_id=100,
        kind="text",
        text_body="вопрос клиента",
        operator_tg_id=None,
    )

    update = {
        "update_id": 23,
        "message": _group_reply_message(reply_to_message_id=100),
    }
    await bridge.process_update(update, client, storage)

    methods = [m for m, _ in calls]
    assert methods == ["copyMessage", "sendMessage"]
    notify_call = calls[1][1]
    assert notify_call["chat_id"] == SUPPORT_CHAT_ID
    assert "заблокирован" in notify_call["text"]

    assert 555 in storage.blocked
    # Неудачная доставка НЕ должна создавать фейковую запись 'out'.
    assert len(storage.messages) == 1
    assert storage.get_offset() == 23


# ── B') реплай оператора → веб-чат (#tgsupport-web) ──────────────────────────


async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
    """Реплай на зеркало веб-сообщения (не найдено в tg_support_messages, найдено
    среди web_support_messages) → записывается в веб-тред, БЕЗ Telegram-доставки
    (у веб-клиента нет личного чата с ботом)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()
    # topic_message_id=300 -> thread_id=42, под ТЕКУЩИМ support_chat_id.
    storage.web_topic_to_thread[300] = (42, SUPPORT_CHAT_ID)

    update = {
        "update_id": 60,
        "message": _group_reply_message(reply_to_message_id=300, text="Ответ по веб-чату"),
    }
    await bridge.process_update(update, client, storage)

    # Никакого Telegram API вызова — веб-клиент не имеет личного чата с ботом.
    assert calls == []
    assert len(storage.web_out_messages) == 1
    rec = storage.web_out_messages[0]
    assert rec["thread_id"] == 42
    assert rec["text_body"] == "Ответ по веб-чату"
    assert rec["operator_tg_id"] == 777
    # tg-путь тоже не тронут — ни одной записи в tg_support_messages.
    assert storage.messages == []
    assert storage.get_offset() == 60


async def test_group_reply_to_web_mirror_with_null_support_chat_id_matches_current_chat() -> None:
    """Легаси-строка (до 187/188, support_chat_id=None) — лениентный wildcard,
    матчится под ЛЮБЫМ текущим support_chat_id (review M1)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()
    storage.web_topic_to_thread[305] = (46, None)

    update = {
        "update_id": 63,
        "message": _group_reply_message(reply_to_message_id=305, text="Ответ по легаси-зеркалу"),
    }
    await bridge.process_update(update, client, storage)

    assert len(storage.web_out_messages) == 1
    assert storage.web_out_messages[0]["thread_id"] == 46


async def test_group_reply_to_web_mirror_from_stale_support_chat_is_not_matched() -> None:
    """#tgsupport-web review M1: зеркало, записанное под ДРУГИМ (не текущим,
    не None) support_chat_id — исторический артефакт ротации группы, НЕ валидный
    маршрут сегодня. Не матчится → падает в orphan-check (не-bot реплай — тихий
    игнор, никакой доставки в чужой/устаревший тред)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()
    stale_chat_id = -999999999999
    storage.web_topic_to_thread[306] = (47, stale_chat_id)

    update = {
        "update_id": 64,
        "message": _group_reply_message(reply_to_message_id=306),
    }
    await bridge.process_update(update, client, storage)

    assert calls == []
    assert storage.web_out_messages == []  # НЕ доставлено в устаревший тред


async def test_group_reply_to_web_mirror_without_text_is_refused_with_operator_notice(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
    не может быть доставлен — WARNING в лог И явное уведомление оператору в топике
    (review M2: раньше был тихий игнор, оператор был уверен что ответил)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()
    storage.web_topic_to_thread[301] = (43, SUPPORT_CHAT_ID)

    message = _group_reply_message(reply_to_message_id=301, message_id=201)
    del message["text"]  # медиа-реплай без текста/caption
    message["voice"] = {"file_id": "x"}
    update = {"update_id": 61, "message": message}

    with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
        await bridge.process_update(update, client, storage)

    assert storage.web_out_messages == []
    assert "не текст" in caplog.text
    assert storage.get_offset() == 61

    methods = [m for m, _ in calls]
    assert methods == ["sendMessage"]
    notice_call = calls[0][1]
    assert notice_call["chat_id"] == SUPPORT_CHAT_ID
    assert notice_call["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
    assert notice_call["reply_to_message_id"] == 201


async def test_group_reply_to_web_mirror_with_photo_and_caption_is_refused_not_partial() -> None:
    """Фото С ПОДПИСЬЮ на веб-зеркало — НЕ доставляем только подпись молча
    (клиент решил бы, что подпись — весь ответ): отказ целиком, как и без caption
    (review M2)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()
    storage.web_topic_to_thread[302] = (44, SUPPORT_CHAT_ID)

    message = _group_reply_message(reply_to_message_id=302, message_id=202)
    del message["text"]
    message["photo"] = [{"file_id": "x"}]
    message["caption"] = "Смотрите скриншот"
    update = {"update_id": 65, "message": message}

    await bridge.process_update(update, client, storage)

    assert storage.web_out_messages == []  # подпись НЕ доставлена как "весь ответ"
    methods = [m for m, _ in calls]
    assert methods == ["sendMessage"]
    assert calls[0][1]["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT


async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """#tgsupport-web review M1: если topic_message_id одновременно резолвится и в
    tg_support_messages, И в web_support_messages (под ОДНИМ и тем же
    support_chat_id — целостность нарушена) — ГРОМКИЙ отказ (logger.error), НИКАКОЙ
    доставки ни в Telegram-личку, ни в веб-тред. Раньше tg-путь выбирался молча —
    misroute постороннему Telegram-клиенту (152-ФЗ risk)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 999}}, calls)
    storage = FakeBridgeStorage()
    storage.record_message(
        chat_id=555,
        direction="in",
        tg_message_id=1,
        topic_message_id=400,
        kind="text",
        text_body="вопрос клиента",
        operator_tg_id=None,
        support_chat_id=SUPPORT_CHAT_ID,
    )
    storage.web_topic_to_thread[400] = (99, SUPPORT_CHAT_ID)

    update = {
        "update_id": 62,
        "message": _group_reply_message(reply_to_message_id=400),
    }
    with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
        await bridge.process_update(update, client, storage)

    assert calls == []  # ничего не доставлено НИ В ОДНУ сторону
    assert storage.web_out_messages == []
    assert len(storage.messages) == 1  # только исходное 'in', никакого 'out'
    assert "ОДНОВРЕМЕННО" in caplog.text
    assert storage.get_offset() == 62


# ── C) дедуп ──────────────────────────────────────────────────────────────────


async def test_dedup_update_id_leq_offset_is_skipped_without_side_effects() -> None:
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage(offset=50)

    update = {"update_id": 50, "message": _private_message()}
    await bridge.process_update(update, client, storage)

    assert calls == []
    assert storage.messages == []
    assert storage.commits == 0  # ранний return — offset уже актуален, коммитить нечего
    assert storage.get_offset() == 50

    update_older = {"update_id": 10, "message": _private_message()}
    await bridge.process_update(update_older, client, storage)
    assert calls == []
    assert storage.get_offset() == 50


async def test_poison_pill_update_still_advances_offset() -> None:
    """Апдейт, на котором обработчик упал (например, message без chat), не должен
    подвесить весь поток — offset сдвигается даже при исключении внутри handler'а."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    malformed_message = {"message_id": 1, "chat": {"type": "private"}}  # нет chat.id
    update = {"update_id": 30, "message": malformed_message}
    await bridge.process_update(update, client, storage)

    assert storage.get_offset() == 30
    assert storage.commits == 1
    assert storage.messages == []


async def test_db_error_during_processing_rolls_back_and_still_advances_offset() -> None:
    """#3 review: SQLAlchemyError (напр. обрыв коннекта к БД при деплое) во время
    `record_message` → storage.rollback() ПЕРЕД save_offset, offset всё равно
    сдвигается. Без rollback() save_offset сам кинул бы PendingRollbackError →
    process_update вылетел бы без сохранения offset'а → следующая итерация
    переиграла бы тот же апдейт → copyMessage задублировал бы зеркало в топике
    на каждый повтор поллинга (воспроизведено ревьюером)."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({"copyMessage": {"message_id": 558}}, calls)
    storage = FakeBridgeStorage()
    storage.fail_next_record_message = True

    update = {"update_id": 15, "message": _private_message()}
    await bridge.process_update(update, client, storage)

    # copyMessage успел уйти в Telegram (реальная утечка мирроринга при DB-сбое —
    # известное ограничение атомарности между внешним API и БД, вне scope этого фикса),
    # но rollback() отработал, offset сдвинут, commit вызван РОВНО один раз (в finally).
    assert storage.rollbacks == 1
    assert storage.commits == 1
    assert storage.get_offset() == 15
    # Запись сообщения НЕ попала в storage (record_message упал до append).
    assert storage.messages == []

    # Повторный вызов с тем же update_id теперь корректно дедупится — НЕ переигрывается.
    calls.clear()
    await bridge.process_update(update, client, storage)
    assert calls == []
    assert storage.get_offset() == 15


async def test_update_without_update_id_is_ignored() -> None:
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    await bridge.process_update({"message": _private_message()}, client, storage)

    assert calls == []
    assert storage.commits == 0
    assert storage.get_offset() == 0


# ── kind inference ────────────────────────────────────────────────────────────


@pytest.mark.parametrize(
    ("message_extra", "expected_kind"),
    [
        ({"text": "hi"}, "text"),
        ({"photo": [{"file_id": "x"}]}, "photo"),
        ({"document": {"file_id": "x"}}, "document"),
        ({"video": {"file_id": "x"}}, "video"),
        ({"voice": {"file_id": "x"}}, "voice"),
        ({"sticker": {"file_id": "x"}}, "other"),
        ({"location": {"latitude": 1, "longitude": 2}}, "other"),
        ({}, "other"),
    ],
)
def test_infer_kind(message_extra: dict[str, Any], expected_kind: str) -> None:
    message = {"message_id": 1, "chat": {"id": 1, "type": "private"}, **message_extra}
    assert bridge._infer_kind(message) == expected_kind


# ── unrelated chat types ─────────────────────────────────────────────────────


async def test_update_from_unrelated_chat_is_ignored_but_offset_advances() -> None:
    """Апдейт не из личного чата и не из support-группы (например, другой чат/канал)
    — молча игнорируется, offset всё равно сдвигается."""
    calls: list[tuple[str, dict[str, Any]]] = []
    client = _make_client({}, calls)
    storage = FakeBridgeStorage()

    message = {
        "message_id": 1,
        "chat": {"id": -999, "type": "group"},
        "from": {"id": 1},
        "text": "болтовня в постороннем чате",
    }
    update = {"update_id": 40, "message": message}
    await bridge.process_update(update, client, storage)

    assert calls == []
    assert storage.messages == []
    assert storage.get_offset() == 40
