"""Доставка вопроса наружу и самообновление списка.

Настоящий Телеграм здесь не дёргается: `call()` подменяется писцом, который
запоминает КАЖДОЕ обращение — метод и параметры. Иначе половину этих правил
проверить нечем: они не про текст ответа, а про то, сколько раз и каким методом
бот полез в Телеграм.

Четыре вещи, которые ломаются незаметно, и потому закрыты отдельно:

1. **Вопрос уходит сам и ровно один раз.** Не уходит — владелец сидит и не знает,
   что прогон встал. Уходит каждым тиком — телефон звонит раз в двадцать секунд,
   и владелец выключает бота.
2. **Ответ адресован открытому прогону и только ему.** Ждут двое — угадывать
   нельзя: угадали неверно, и прогон продолжился с чужим решением.
3. **Пока ничего не открыто, текст ответом не считается.** Записать «наугад в
   единственного ждущего» — та же ошибка, просто реже.
4. **Список правится, а не шлётся заново.** Новое сообщение на каждую закрытую
   задачу — это лента одинаковых списков, в которой актуальный теряется.
"""

from __future__ import annotations

import subprocess
import sys
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


class Bot:
    """Поддельный Bot API: помнит каждое обращение — метод и параметры."""

    def __init__(self):
        self.calls: list[tuple[str, dict]] = []
        self.updates: list[dict] = []
        self.fail = False
        self._mid = 100
        self._next = 1

    def __call__(self, token: str, method: str, params: dict | None = None, timeout: int = 10):
        params = dict(params or {})
        self.calls.append((method, params))
        if self.fail and method in ("sendMessage", "editMessageText"):
            raise telegram.TelegramError("нет связи с Телеграмом")
        if method == "getUpdates":
            offset = int(params.get("offset") or 0)
            return [u for u in self.updates if u["update_id"] >= offset]
        if method == "sendMessage":
            self._mid += 1
            return {"message_id": self._mid, "chat": {"id": params.get("chat_id")}}
        if method == "editMessageText":
            return {"message_id": params.get("message_id")}
        return {}

    def say(self, text: str, chat: int = OWNER) -> dict:
        upd = {"update_id": self._next, "message": {"chat": {"id": chat}, "text": text}}
        self._next += 1
        self.updates.append(upd)
        return upd

    def sent(self) -> list[dict]:
        return [p for m, p in self.calls if m == "sendMessage"]

    def edits(self) -> list[dict]:
        return [p for m, p in self.calls if m == "editMessageText"]

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

    def clear(self) -> None:
        self.calls.clear()


@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) -> Bot:
    fake = Bot()
    monkeypatch.setattr(telegram, "call", fake)
    return fake


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 _fresh(run: Run) -> Run:
    """Прогон, перечитанный с диска: ответ пишет другой код, а не этот объект."""
    return Run(run.root, run.slug)


# --- вопрос уходит владельцу ---


def test_waiting_run_pushes_the_question_to_the_owner_by_itself(repo: Path, bot: Bot):
    """R05.2, R05.3: тик доставляет вопрос, а владелец ни о чём не спрашивал.

    Рычаг порчи: не слать вопрос из тика — проверка краснеет, и прогон стоит
    молча, пока владелец сам не заглянет в бота.
    """
    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Брать порт 8443 или 443?")

    assert not bot.calls, "что-то ушло в Телеграм до тика"
    assert telegram.tick(_roots(repo))["delivered"] == 1

    assert len(bot.sent()) == 1, "вопрос не ушёл владельцу"
    message = bot.sent()[0]
    assert "Брать порт 8443 или 443?" in message["text"], "нет текста вопроса"
    assert "Прогон с вопросом" in message["text"], "нет имени прогона"
    assert message["chat_id"] == str(OWNER)
    assert not [m for m, _ in bot.calls if m == "getUpdates"], "владельца всё же опросили"


def test_the_same_question_is_delivered_exactly_once(repo: Path, bot: Bot):
    """Рычаг порчи: снять отметку о доставке — тик шлёт то же самое каждый круг."""
    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Платёж включать?")

    for _ in range(5):
        telegram.tick(_roots(repo))

    asked = [t for t in bot.texts() if "Платёж включать?" in t]
    assert len(asked) == 1, f"вопрос доставлен {len(asked)} раз(а)"
    delivered = ask.question(_fresh(run), "Q1")
    assert delivered.get("delivered_at"), "отметка о доставке не поставлена"
    assert delivered.get("delivered_to") == str(OWNER)


def test_a_question_that_did_not_go_through_is_not_marked_as_delivered(repo: Path, bot: Bot):
    """Упавшая отправка не хоронит вопрос: следующий тик попробует снова."""
    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Домен какой?")

    bot.fail = True
    assert telegram.tick(_roots(repo))["delivered"] == 0
    assert ask.undelivered(_fresh(run)), "вопрос отмечен доставленным, хотя не ушёл"

    bot.fail = False
    assert telegram.tick(_roots(repo))["delivered"] == 1
    assert not ask.undelivered(_fresh(run))


def test_answered_question_is_never_delivered(repo: Path, bot: Bot):
    run = _make(repo, "Быстрый прогон")
    ask.create(run, "Успеем к пятнице?")
    ask.answer(_fresh(run), "Q1", "успеем", by="cli")

    assert telegram.tick(_roots(repo))["delivered"] == 0
    assert not bot.sent(), "разобранный вопрос всё-таки ушёл владельцу"


def test_the_panel_loop_itself_runs_the_tick(repo: Path, bot: Bot, monkeypatch):
    """Вся эта машинерия мертва, если цикл панели её не дёргает.

    Рычаг порчи: убрать `tick` из `poll_forever` — вопрос уходит только в
    проверках, а у владельца бот молчит до первой его же команды.
    """
    import threading

    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Ждать ли ответа?")
    stop = threading.Event()
    monkeypatch.setattr(telegram, "poll_once", lambda *a, **kw: stop.set())

    telegram.poll_forever(_roots(repo), stop=stop)

    assert [t for t in bot.texts() if "Ждать ли ответа?" in t], "цикл панели не доставил вопрос"


# --- ответ адресуется открытому прогону ---


def test_plain_text_answers_the_open_run_and_only_it(repo: Path, bot: Bot):
    """R06.4: ждут двое — ответ доезжает до открытого и никуда больше.

    Рычаг порчи: писать ответ в первый ждущий (или в активный прогон) вместо
    открытого — проверка краснеет на втором прогоне.
    """
    first = _make(repo, "Первый прогон")
    second = _make(repo, "Второй прогон")
    ask.create(first, "Первый вопрос")
    ask.create(second, "Второй вопрос")

    # Открываем ВТОРОГО по очереди намеренно: открой первого — и «пишу в
    # открытый» неотличимо от «пишу в первого попавшегося ждущего».
    telegram.next_text(_roots(repo))
    telegram.next_text(_roots(repo))
    queue = telegram.waiting_runs(_roots(repo))
    opened = telegram.open_slug()
    assert opened == queue[1].slug, "открыт не второй по очереди — рычаг порчи не сработает"
    target, other = (first, second) if opened == first.slug else (second, first)
    assert other.slug == queue[0].slug

    chat, reply = telegram.handle_update(bot.say("берите Postgres"), _roots(repo))

    assert chat == OWNER
    assert _fresh(target).state["title"] in reply, "бот не назвал, куда записал ответ"
    answered = ask.question(_fresh(target), "Q1")
    assert answered["status"] == "answered"
    assert answered["answer"]["text"] == "берите Postgres"
    assert answered["answer"]["by"] == "telegram"

    missed = ask.question(_fresh(other), "Q1")
    assert missed["status"] == "waiting", "ответ уехал в чужой прогон"
    assert _fresh(other).waiting_for() == "Q1"


def test_the_answer_from_telegram_lifts_the_waiting_pause(repo: Path, bot: Bot):
    """R12i.2: ответ снимает паузу ожидания сам, без отдельной команды."""
    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Брать ли Postgres?")
    telegram.next_text(_roots(repo))
    assert _fresh(run).is_paused() and _fresh(run).waiting_for() == "Q1"

    telegram.handle_update(bot.say("да, берите"), _roots(repo))

    after = _fresh(run)
    assert after.waiting_for() is None, "ожидание не снято"
    assert not after.is_paused(), "прогон стоит и после ответа"


def test_plain_text_is_not_an_answer_while_nothing_is_open(repo: Path, bot: Bot):
    """R06.4: открытого нет — бот говорит открыть прогон, а не записывает наугад.

    Рычаг порчи: принять текст за ответ единственному ждущему — краснеет.
    """
    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Брать ли Postgres?")
    assert telegram.open_slug() is None

    chat, reply = telegram.handle_update(bot.say("да, берите"), _roots(repo))

    assert chat == OWNER
    assert telegram.BTN_NEXT in reply, "не сказано, чем открыть прогон"
    assert "открыт" in reply.lower()
    still = ask.question(_fresh(run), "Q1")
    assert still["status"] == "waiting", "текст записан ответом без открытого прогона"
    assert _fresh(run).waiting_for() == "Q1"


def test_text_into_a_run_that_stopped_waiting_is_refused_in_words(repo: Path, bot: Bot):
    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Брать ли Postgres?")
    telegram.next_text(_roots(repo))
    ask.answer(_fresh(run), "Q1", "уже ответили из терминала", by="cli")

    _, reply = telegram.handle_update(bot.say("а вот ещё"), _roots(repo))

    assert "не ждёт ответа" in reply
    assert ask.question(_fresh(run), "Q1")["answer"]["text"] == "уже ответили из терминала"


def test_a_command_is_still_a_command_not_an_answer(repo: Path, bot: Bot):
    """Разбор обычного текста не смеет проглотить команды и кнопки."""
    run = _make(repo, "Прогон с вопросом")
    ask.create(run, "Брать ли Postgres?")
    telegram.next_text(_roots(repo))

    _, listed = telegram.handle_update(bot.say(telegram.BTN_LIST), _roots(repo))
    _, helped = telegram.handle_update(bot.say(telegram.BTN_HELP), _roots(repo))

    assert "Прогоны проекта" in listed
    assert "Чекан на связи" in helped
    assert ask.question(_fresh(run), "Q1")["status"] == "waiting", "команда записана ответом"


# --- список правится сам ---


def _press_list(repo: Path, bot: Bot) -> dict:
    """Владелец нажал «Список»: панель отправила его и запомнила идентификатор."""
    bot.say(telegram.BTN_LIST)
    assert telegram.poll_once(_roots(repo)) == 1
    saved = telegram.load().get(telegram.LIST_KEY)
    assert saved and saved.get("message_id"), "отправленный список не запомнен"
    return saved


def test_the_tick_edits_the_sent_list_when_the_state_changed(repo: Path, bot: Bot):
    """R11.1: состояние изменилось — ушёл editMessageText на тот же идентификатор."""
    run = _make(repo, "Идущий прогон")
    saved = _press_list(repo, bot)
    bot.clear()

    with run.mutate() as st:
        st["tasks"][0]["status"] = "done"
    assert telegram.tick(_roots(repo))["edited"] is True

    assert len(bot.edits()) == 1, "список не поправлен"
    edit = bot.edits()[0]
    assert edit["message_id"] == saved["message_id"], "правка ушла в другое сообщение"
    assert edit["chat_id"] == str(OWNER)
    assert "закрыто 1 из 2" in edit["text"], "поправлено, но старым текстом"


def test_ten_updates_do_not_send_a_single_new_list(repo: Path, bot: Bot):
    """R11.2: за десять обновлений исходящих sendMessage со списком — ноль.

    Рычаг порчи: слать список заново вместо правки — проверка краснеет.
    """
    run = _make(repo, "Идущий прогон")
    saved = _press_list(repo, bot)
    bot.clear()

    for i in range(10):
        run.add_task(f"1{i}", f"добавленная {i}", ["R01"], verify="true")
        telegram.tick(_roots(repo))

    resent = [p for p in bot.sent() if "Прогоны проекта" in p.get("text", "")]
    assert resent == [], f"список отправлен заново {len(resent)} раз(а)"
    assert len(bot.edits()) == 10, "не каждое изменение доехало правкой"
    assert {e["message_id"] for e in bot.edits()} == {saved["message_id"]}
    assert "закрыто 0 из 12" in bot.edits()[-1]["text"]


def test_an_unchanged_state_is_not_edited_at_all(repo: Path, bot: Bot):
    """Правка на пустом месте — то же самое мигание, только без причины."""
    _make(repo, "Идущий прогон")
    _press_list(repo, bot)
    bot.clear()

    for _ in range(3):
        assert telegram.tick(_roots(repo))["edited"] is False

    assert not bot.edits(), "список правится, хотя на диске ничего не менялось"


def test_the_delivered_question_shows_up_in_the_edited_list(repo: Path, bot: Bot):
    """Тик делает обе половины: вопрос ушёл и список это уже показывает."""
    run = _make(repo, "Прогон с вопросом")
    _press_list(repo, bot)
    bot.clear()

    ask.create(run, "Брать ли Postgres?")
    result = telegram.tick(_roots(repo))

    assert result == {"delivered": 1, "edited": True}
    assert "Ждут ответа: 1" in bot.edits()[-1]["text"]


def test_a_deleted_list_message_is_forgotten_instead_of_retried(repo: Path, bot: Bot):
    """Сообщение удалили — панель перестаёт биться в него каждым тиком."""
    run = _make(repo, "Идущий прогон")
    _press_list(repo, bot)
    bot.clear()
    bot.fail = True

    with run.mutate() as st:
        st["tasks"][0]["status"] = "done"
    assert telegram.tick(_roots(repo))["edited"] is False
    assert telegram.load().get(telegram.LIST_KEY) is None

    bot.fail = False
    bot.clear()
    with run.mutate() as st:
        st["tasks"][1]["status"] = "done"
    assert telegram.tick(_roots(repo))["edited"] is False
    assert not bot.calls, "панель всё ещё правит удалённое сообщение"


# --- «продолжить» на прогоне, который ждёт ответа ---


def test_resume_tells_the_truth_about_a_run_waiting_for_an_answer(repo: Path, bot: Bot):
    """Прогон ждёт ответа — это не «и так не на паузе», а ровно наоборот."""
    run = _make(repo, "Прогон с вопросом")
    (_roots(repo) / "active").write_text(run.slug + "\n", encoding="utf-8")
    ask.create(run, "Брать ли Postgres?")
    assert _fresh(run).is_paused()

    reply = telegram.handle_command("resume", _roots(repo))

    assert "и так не на паузе" not in reply, "бот соврал: прогон стоит и ждёт ответа"
    assert "Q1" in reply and "ждёт ответа" in reply
    assert _fresh(run).is_paused(), "ожидание снято командой, а не ответом"
