"""Реальное время доставки и счётчик в самой карточке вопроса.

Владелец заказал две вещи дословно: «в реальном времени» и «должен быть
обязательный счетчик актуальный». Обе прошли мимо соседних проверок, потому что
те дёргают `tick()` руками — а в жизни его дёргает цикл панели, и вот там-то
доставка стояла в очереди за длинным опросом.

**Задержка меряется, а не обещается.** Поддельный Телеграм здесь ведёт себя как
настоящий: `getUpdates` виснет ровно так же, как живое соединение, — до
`POLL_TIMEOUT` секунд. Проверка запускает настоящие потоки панели, дожидается,
пока длинный опрос повиснет, и только тогда заводит вопрос. Секундомер идёт от
`ask.create` (это и есть `chekan ask`) до `sendMessage`. Пока доставка ехала
прицепом к опросу, здесь набегало 24 секунды; порог назван числом ниже.

**Лечить это учащённым опросом нельзя.** Длинный опрос заведён ровно затем,
чтобы не бить в Телеграм тысячами пустых запросов в час. Поэтому вместе с
задержкой проверяется и цена: за всё время замера обращений `getUpdates` ровно
одно — то самое, которое так и висит недоотвеченным.

**Счётчик сверяется с независимым счётом.** Число в карточке сравнивается не с
`waiting_count`, а со списком ждущих, собранным здесь же прямым чтением
`state.json`. Иначе счётчик сверяется сам с собой и переживёт любую ошибку в
отборе ждущих.
"""

from __future__ import annotations

import json
import re
import subprocess
import sys
import threading
import time
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from chekan import ask, telegram  # noqa: E402
from chekan.store import Run  # noqa: E402

TOKEN = "1234567890:AAHfake-token-for-tests-0123456789x"
OWNER = 424242

# Порог «реального времени», записанный числом. Три секунды — это заведомо не
# «десятки секунд» и при этом не гонка с планировщиком потоков: круг доставки
# ходит каждые TICK_SECONDS, и даже пяток промахов сюда укладывается.
LIMIT_SECONDS = 3.0

# Сколько ждать сообщения, прежде чем сдаться. Больше длинного опроса намеренно:
# если задержку вернут, проверка обязана НАЗВАТЬ измеренные секунды, а не просто
# упасть по своему же короткому сроку.
GIVE_UP_SECONDS = telegram.POLL_TIMEOUT + 10


class HangingBot:
    """Поддельный Bot API, который виснет на `getUpdates` как настоящий.

    Без этого замер бессмысленен: мгновенный `getUpdates` скрывает ровно ту
    задержку, которую здесь ловят.
    """

    def __init__(self):
        self.lock = threading.Lock()
        self.calls: list[tuple[str, dict]] = []
        self.polling = threading.Event()   # длинный опрос повис
        self.released = threading.Event()  # отпустить его (разбор проверки)
        self._mid = 100

    def __call__(self, token: str, method: str, params: dict | None = None, timeout: int = 10):
        with self.lock:
            self.calls.append((method, dict(params or {})))
        if method == "getUpdates":
            self.polling.set()
            self.released.wait(telegram.POLL_TIMEOUT)
            return []
        if method == "sendMessage":
            with self.lock:
                self._mid += 1
                return {"message_id": self._mid, "chat": {"id": (params or {}).get("chat_id")}}
        if method == "editMessageText":
            return {"message_id": (params or {}).get("message_id")}
        return {}

    def _params(self, method: str) -> list[dict]:
        with self.lock:
            return [p for m, p in self.calls if m == method]

    def texts(self) -> list[str]:
        return [p.get("text", "") for p in self._params("sendMessage")]

    def edits(self) -> list[dict]:
        return self._params("editMessageText")

    def count(self, method: str) -> int:
        return len(self._params(method))


@pytest.fixture
def repo(tmp_path: Path) -> Path:
    root = tmp_path / "proj"
    root.mkdir()
    subprocess.run(["git", "init", "-q"], cwd=root, check=True)
    subprocess.run(["git", "config", "user.email", "t@t"], cwd=root, check=True)
    subprocess.run(["git", "config", "user.name", "t"], cwd=root, check=True)
    (root / "app.py").write_text("print('ok')\n", encoding="utf-8")
    subprocess.run(["git", "add", "-A"], cwd=root, check=True)
    subprocess.run(["git", "commit", "-qm", "init"], cwd=root, check=True)
    return root


@pytest.fixture(autouse=True)
def cfg(tmp_path: Path, monkeypatch):
    monkeypatch.setenv(telegram.CONFIG_ENV, str(tmp_path / "cfg" / "telegram.json"))
    telegram.save({"token": TOKEN, "bot": "ceh_test_bot", "owner_id": str(OWNER)})


@pytest.fixture
def bot(monkeypatch) -> HangingBot:
    fake = HangingBot()
    monkeypatch.setattr(telegram, "call", fake)
    return fake


@pytest.fixture
def panel(bot: HangingBot):
    """Настоящие потоки панели, поднятые и погашенные вместе с проверкой.

    Врезка именно в `start_poller`: доставка обязана ехать от того же запуска,
    что и опрос, — сама по себе, без чужой руки на `tick()`.
    """
    stop = threading.Event()
    threads: list[threading.Thread] = []

    def start(runs_root: Path):
        threads.extend(telegram.start_poller(runs_root, stop=stop))
        assert bot.polling.wait(10), "длинный опрос так и не начался — замерять нечего"
        return stop

    yield start
    stop.set()
    bot.released.set()
    for thread in threads:
        thread.join(timeout=10)
        assert not thread.is_alive(), f"поток {thread.name} не остановился"


def _make(repo: Path, title: str, tasks: int = 2) -> Run:
    run = Run.create(repo, title, "normal", "T1")
    run.add_requirement("R01", "цитата", note="причина")
    for i in range(tasks):
        run.add_task(f"{i + 1:02d}", f"задача {i + 1}", ["R01"], verify="true")
    return run


def _roots(repo: Path) -> Path:
    return repo / ".chekan"


def _waiting_slugs_from_disk(repo: Path) -> list[str]:
    """Ждущие ответа, посчитанные заново и мимо кода панели."""
    out = []
    for path in sorted(_roots(repo).glob("*/state.json")):
        st = json.loads(path.read_text(encoding="utf-8"))
        qid = st.get("waiting_for")
        if not qid:
            continue
        q = next((x for x in st.get("questions") or [] if x.get("id") == qid), None)
        if q and q.get("status") != "answered":
            out.append(path.parent.name)
    return out


def _wait_until(check, timeout: float) -> float | None:
    """Дождаться условия и вернуть момент, когда оно выполнилось. Нет — None."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if check():
            return time.monotonic()
        time.sleep(0.02)
    return None


# --- задержка ---


def test_the_question_reaches_the_owner_in_seconds_not_in_half_a_minute(
    repo: Path, bot: HangingBot, panel
):
    """Замер: от `chekan ask` до сообщения — секунды, а не цикл длинного опроса.

    Рычаг порчи: вернуть доставку в очередь за `getUpdates` (убрать круг
    `tick_forever` из `start_poller` или вернуть `tick` только в `poll_forever`) —
    здесь набежит порядка POLL_TIMEOUT секунд, и проверка покраснеет, назвав их.
    """
    run = _make(repo, "Прогон с вопросом")
    panel(_roots(repo))
    question = "Брать порт 8443 или 443?"

    asked_at = time.monotonic()
    ask.create(run, question)
    arrived_at = _wait_until(
        lambda: any(question in t for t in bot.texts()), GIVE_UP_SECONDS
    )

    assert arrived_at is not None, (
        f"вопрос не доехал за {GIVE_UP_SECONDS:.0f} с — доставки нет вовсе"
    )
    delay = arrived_at - asked_at
    assert delay < LIMIT_SECONDS, (
        f"вопрос шёл {delay:.1f} с — это не реальное время, а цикл длинного опроса "
        f"(порог {LIMIT_SECONDS:.1f} с)"
    )
    # Задержку сняли не учащённым опросом: длинный так и висит недоотвеченным.
    assert bot.count("getUpdates") == 1, (
        f"за замер ушло {bot.count('getUpdates')} обращений getUpdates — "
        "длинный опрос сломан, это тысячи пустых запросов в час"
    )


def test_the_open_list_is_fixed_in_seconds_too(repo: Path, bot: HangingBot, panel):
    """R11 в реальном времени: правка списка тоже не ждёт конца длинного опроса."""
    run = _make(repo, "Идущий прогон")
    telegram.remember_list(OWNER, 777, telegram.runs_text(_roots(repo)))
    panel(_roots(repo))

    changed_at = time.monotonic()
    with run.mutate() as st:
        st["tasks"][0]["status"] = "done"
    edited_at = _wait_until(
        lambda: any("закрыто 1 из 2" in e.get("text", "") for e in bot.edits()),
        GIVE_UP_SECONDS,
    )

    assert edited_at is not None, "список так и не поправился"
    delay = edited_at - changed_at
    assert delay < LIMIT_SECONDS, (
        f"список правился {delay:.1f} с после изменения на диске (порог {LIMIT_SECONDS:.1f} с)"
    )
    assert bot.edits()[0]["message_id"] == 777, "правка ушла в другое сообщение"


def test_the_delivery_circle_does_not_knock_on_telegram_for_nothing(repo: Path, bot: HangingBot):
    """Цена реального времени: круг без изменений на диске молчит.

    Рычаг порчи: заменить длинный опрос коротким или слать что-нибудь каждым
    кругом — обращения появятся здесь, хотя на диске ничего не менялось.
    """
    assert telegram.POLL_TIMEOUT >= 20, "длинный опрос укоротили — это пустые запросы в час"
    run = _make(repo, "Идущий прогон")
    ask.create(run, "Брать ли Postgres?")
    assert telegram.tick(_roots(repo))["delivered"] == 1
    sent_before = len(bot.texts())

    stop = threading.Event()
    thread = threading.Thread(target=telegram.tick_forever, args=(_roots(repo), stop))
    thread.start()
    time.sleep(1.0)  # десятки кругов доставки подряд, а на диске тишина
    stop.set()
    thread.join(timeout=10)

    assert not thread.is_alive(), "круг доставки не остановился"
    assert len(bot.texts()) == sent_before, "круг доставки шлёт сообщения на пустом месте"
    assert bot.count("editMessageText") == 0, "круг доставки правит список без причины"


# --- счётчик в карточке вопроса ---


def _counter(text: str) -> int | None:
    found = re.search(r"Ждут ответа: (\d+)", text)
    return int(found.group(1)) if found else None


def test_the_question_card_carries_the_counter_itself(repo: Path, bot: HangingBot):
    """R09: пришла карточка — в ней число ждущих, и оно сходится со списком.

    Рычаг порчи: убрать счётчик из `question_text` — карточка приходит, а
    обязательного числа в ней нет, и владелец, ни разу не нажавший «Список»,
    не увидит его вовсе.
    """
    first = _make(repo, "Первый прогон")
    # Второй прогон никого не ждёт — иначе «число ждущих» неотличимо от «числа
    # прогонов», и подмена одного другим прошла бы незамеченной.
    _make(repo, "Прогон без вопроса")
    ask.create(first, "Какой домен брать?")

    assert telegram.tick(_roots(repo))["delivered"] == 1

    card = bot.texts()[-1]
    assert "Какой домен брать?" in card, "это не карточка вопроса"
    assert _counter(card) == len(_waiting_slugs_from_disk(repo)) == 1, (
        f"счётчик в карточке разошёлся с независимым счётом ждущих: {card!r}"
    )


def test_the_counter_in_the_card_is_the_one_at_the_moment_of_sending(
    repo: Path, bot: HangingBot
):
    """«Актуальный» — значит посчитанный сейчас, а не когда-то раньше.

    Рычаг порчи: посчитать ждущих один раз и подставлять число дальше — вторая
    карточка соврёт, что ждёт один.
    """
    first = _make(repo, "Первый прогон")
    second = _make(repo, "Второй прогон")
    third = _make(repo, "Третий прогон")

    ask.create(first, "Первый вопрос")
    telegram.tick(_roots(repo))
    assert _counter(bot.texts()[-1]) == len(_waiting_slugs_from_disk(repo)) == 1

    ask.create(second, "Второй вопрос")
    telegram.tick(_roots(repo))
    assert _counter(bot.texts()[-1]) == len(_waiting_slugs_from_disk(repo)) == 2

    ask.answer(Run(first.root, first.slug), "Q1", "берите первый", by="cli")
    ask.create(third, "Третий вопрос")
    telegram.tick(_roots(repo))
    assert _counter(bot.texts()[-1]) == len(_waiting_slugs_from_disk(repo)) == 2, (
        "разобранный вопрос всё ещё сидит в счётчике карточки"
    )


def test_the_card_counter_agrees_with_the_list_counter(repo: Path, bot: HangingBot):
    """Одно число в двух местах — одно и то же число, иначе владелец не верит ни одному."""
    first = _make(repo, "Первый прогон")
    second = _make(repo, "Второй прогон")
    _make(repo, "Прогон без вопроса")  # ждущих двое из трёх, а не «сколько прогонов»
    ask.create(first, "Первый вопрос")
    ask.create(second, "Второй вопрос")

    telegram.tick(_roots(repo))

    cards = [t for t in bot.texts() if "вопрос" in t.lower()]
    assert len(cards) == 2, "ушли не обе карточки"
    from_list = _counter(telegram.runs_text(_roots(repo)))
    assert from_list == len(_waiting_slugs_from_disk(repo)) == 2
    assert [_counter(c) for c in cards] == [from_list, from_list]
