"""Прогоны как чаты: список, счётчик, очередь, кнопки снизу.

Настоящий Телеграм здесь не дёргается: `call()` подменяется, а состояние прогонов
пишется прямо на диск. Так и надо — проверяется не разговор с сетью, а четыре
вещи, которые легко сделать неправильно и незаметно:

1. **Список не плоский.** Архив отделён от идущих, брошенный прогон в нём помечен.
   Свалить всё в один столбец — значит показать давно закрытое как живое.
2. **Состояние читается с диска в момент запроса.** Иначе панель показывает то,
   что запомнила при старте, и владелец видит вчерашний день.
3. **Счётчик — это длина списка ждущих.** Поэтому он сверяется со списком,
   посчитанным здесь заново по файлам состояния, а не сам с собой.
4. **Кнопка обязана работать.** Телеграм шлёт нажатие обычным текстом без косой
   черты: клавиатура на месте, а разбор только по «/» делает её картинкой.
"""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

import pytest

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

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

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


@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)})


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 _activate(repo: Path, run: Run) -> None:
    (_roots(repo) / "active").write_text(run.slug + "\n", encoding="utf-8")


def _finish(run: Run) -> None:
    with run.mutate() as st:
        st["finished_at"] = now()


def _close_task(run: Run, tid: str) -> None:
    with run.mutate() as st:
        for task in st["tasks"]:
            if task["id"] == tid:
                task["status"] = "done"


def _ask(run: Run, text: str, qid: str = "Q1") -> None:
    """Поставить прогон в ожидание ответа — так же, как это делает `chekan ask`.

    Пишем прямо в состояние, а не через `ceh/ask.py`: здесь проверяется чтение
    вопроса с диска, и оно обязано работать независимо от того, чьей рукой вопрос
    туда положен.
    """
    with run.mutate() as st:
        st.setdefault("questions", []).append(
            {"id": qid, "text": text, "task": None, "asked_at": now(),
             "status": "waiting", "answer": None}
        )
        st["waiting_for"] = qid
        st.setdefault("pauses", []).append(
            {"from": now(), "to": None, "reason": "question", "question": qid}
        )


def _answer(run: Run, qid: str = "Q1") -> None:
    with run.mutate() as st:
        for q in st["questions"]:
            if q["id"] == qid:
                q["status"] = "answered"
                q["answer"] = {"text": "ага", "at": now(), "by": "cli"}
        st["waiting_for"] = None
        st["pauses"][-1]["to"] = now()


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 test_list_shows_live_and_archived_with_title_phase_and_share(repo: Path):
    old = _make(repo, "Старый прогон")
    _close_task(old, "01")
    _finish(old)
    live = _make(repo, "Идущий прогон")

    text = telegram.runs_text(_roots(repo))

    assert "Старый прогон" in text and "Идущий прогон" in text
    assert "закрыто 1 из 2" in text, "нет доли закрытых задач у завершённого"
    assert "закрыто 0 из 2" in text, "нет доли закрытых задач у идущего"
    assert "Подготовка" in text, "нет фазы"
    assert live.slug != old.slug


def test_archive_is_separated_from_the_live_ones(repo: Path):
    """Рычаг порчи: сделать список плоским — проверка краснеет."""
    old = _make(repo, "Закрытый прогон")
    _finish(old)
    _make(repo, "Живой прогон")

    text = telegram.runs_text(_roots(repo))

    assert "Архив:" in text, "архива нет — список плоский"
    head = text.index("Архив:")
    assert text.index("Живой прогон") < head, "идущий уехал в архив"
    assert text.index("Закрытый прогон") > head, "завершённый смешан с идущими"
    assert "завершён" in text


def test_abandoned_run_is_shown_in_the_archive_with_a_mark(repo: Path):
    """Г2: нет `finished_at`, но и не текущий — брошен, и это сказано словом."""
    live = _make(repo, "Живой прогон")
    _make(repo, "Брошенный прогон")  # стал текущим
    _activate(repo, live)  # текущим снова назначен первый

    text = telegram.runs_text(_roots(repo))

    assert "брошен" in text
    head = text.index("Архив:")
    assert text.index("Брошенный прогон") > head, "брошенный показан как идущий"
    assert text.index("Живой прогон") < head


def test_list_is_read_from_disk_at_the_moment_of_the_request(repo: Path):
    """R04.1: правка состояния видна следующим показом, без перезапуска панели."""
    run = _make(repo, "Идущий прогон")
    before = telegram.runs_text(_roots(repo))
    assert "закрыто 0 из 2" in before

    _close_task(run, "01")
    after = telegram.runs_text(_roots(repo))

    assert "закрыто 1 из 2" in after, "показан слепок из памяти процесса"


# --- счётчик ---


def test_counter_matches_the_independently_counted_waiting_list(repo: Path):
    first = _make(repo, "Первый прогон")
    second = _make(repo, "Второй прогон")
    _ask(first, "Какой домен брать?")

    expected = _waiting_slugs_from_disk(repo)
    assert telegram.waiting_count(_roots(repo)) == len(expected) == 1
    assert f"Ждут ответа: {len(expected)}" in telegram.runs_text(_roots(repo))

    _ask(second, "Платёж включать?")
    expected = _waiting_slugs_from_disk(repo)
    assert telegram.waiting_count(_roots(repo)) == len(expected) == 2
    assert f"Ждут ответа: {len(expected)}" in telegram.runs_text(_roots(repo))

    _answer(first)
    expected = _waiting_slugs_from_disk(repo)
    assert telegram.waiting_count(_roots(repo)) == len(expected) == 1
    assert f"Ждут ответа: {len(expected)}" in telegram.runs_text(_roots(repo))


def test_manual_pause_is_not_counted_as_waiting(repo: Path):
    """R09.1: счётчик — про ответ владельца, а не про любую остановку."""
    live = _make(repo, "Ручная пауза")
    live.pause()
    assert live.is_paused()

    assert _waiting_slugs_from_disk(repo) == []
    assert telegram.waiting_count(_roots(repo)) == 0
    text = telegram.runs_text(_roots(repo))
    assert "Ждущих ответа нет." in text
    assert "на паузе" in text, "ручная пауза пропала из списка вовсе"


def test_counter_is_visible_in_the_list_without_a_separate_command(repo: Path):
    run = _make(repo, "Прогон с вопросом")
    _ask(run, "Брать ли Postgres?")
    assert "Ждут ответа: 1" in telegram.handle_command("list", _roots(repo))


# --- очередь ---


def test_next_opens_the_waiting_run_and_makes_it_the_answer_target(repo: Path):
    run = _make(repo, "Прогон с вопросом")
    _ask(run, "Брать ли Postgres?")

    text = telegram.next_text(_roots(repo))

    assert "Прогон с вопросом" in text
    assert "Подготовка" in text, "не названа фаза"
    assert "Брать ли Postgres?" in text, "не показан текст ждущего вопроса"
    assert telegram.open_slug() == run.slug, "прогон не стал адресатом ответа"


def test_next_walks_the_queue_and_never_returns_the_same_run_twice(repo: Path):
    """Рычаг порчи: возвращать одного и того же ждущего — проверка краснеет."""
    first = _make(repo, "Первый прогон")
    second = _make(repo, "Второй прогон")
    _ask(first, "Первый вопрос")
    _ask(second, "Второй вопрос")

    opened = []
    for _ in range(2):
        telegram.next_text(_roots(repo))
        opened.append(telegram.open_slug())

    assert len(set(opened)) == 2, f"очередь вернула одного дважды: {opened}"
    assert set(opened) == {first.slug, second.slug}

    third = telegram.next_text(_roots(repo))
    assert "уже открывали" in third, "круг пошёл заново, показав того же ещё раз"
    assert telegram.open_slug() == opened[-1], "конец круга подменил адресата ответа"

    _answer(second)
    telegram.next_text(_roots(repo))
    assert telegram.open_slug() == first.slug, "разбор не сдвинул очередь"


def test_empty_queue_answers_in_words(repo: Path):
    """Рычаг порчи: вернуть пустую строку вместо слов — проверка краснеет."""
    _make(repo, "Прогон без вопросов")

    text = telegram.next_text(_roots(repo))

    assert text.strip(), "пустая очередь ответила пустотой"
    assert "очередь пуста" in text
    assert telegram.open_slug() is None


# --- кнопки снизу ---


def test_reply_keyboard_carries_the_four_commands(repo: Path):
    markup = json.loads(telegram.keyboard())
    labels = [b for row in markup["keyboard"] for b in row]
    assert labels == [telegram.BTN_LIST, telegram.BTN_NEXT,
                      telegram.BTN_PAUSE, telegram.BTN_HELP]
    assert markup["resize_keyboard"] is True


def test_button_text_without_a_slash_runs_the_same_command(repo: Path):
    """Рычаг порчи: оставить разбор только для строк с «/» — красное.

    Клавиатура при этом остаётся на месте, поэтому глазами поломка не видна:
    кнопки нарисованы, а нажатие не делает ничего.
    """
    run = _make(repo, "Прогон с вопросом")
    _ask(run, "Брать ли Postgres?")

    for label, cmd in (
        (telegram.BTN_LIST, "list"),
        (telegram.BTN_HELP, "help"),
        (telegram.BTN_NEXT, "next"),
        (telegram.BTN_PAUSE, "pause"),
    ):
        assert telegram.parse_command(label) == cmd, f"кнопка «{label}» не разобрана"

    pressed = telegram.handle_command(
        telegram.parse_command(telegram.BTN_LIST), _roots(repo)
    )
    assert "Прогон с вопросом" in pressed
    assert pressed == telegram.handle_command("list", _roots(repo))


def test_plain_text_is_still_not_a_command(repo: Path):
    """Разбор узнаёт подписи кнопок, а не любой текст: иначе ответ владельца
    превратился бы в неизвестную команду."""
    assert telegram.parse_command("да, бери Postgres") is None
    assert telegram.parse_command("") is None


def test_reply_carries_the_keyboard_through_the_poll(repo: Path, monkeypatch):
    """R10.1: клавиатура едет с ответом бота, а не остаётся в голове панели."""
    _make(repo, "Прогон с кнопками")
    sent: list[dict] = []
    updates = [{"update_id": 5, "message": {"chat": {"id": OWNER},
                                            "text": telegram.BTN_HELP}}]

    def fake_call(token, method, params=None, timeout=10):
        if method == "getUpdates":
            return updates
        if method == "sendMessage":
            sent.append(params)
            return {"message_id": 1}
        return {}

    monkeypatch.setattr(telegram, "call", fake_call)
    assert telegram.poll_once(_roots(repo)) == 1

    assert len(sent) == 1, "нажатие кнопки осталось без ответа"
    assert telegram.HELP == sent[0]["text"]
    labels = [b for row in json.loads(sent[0]["reply_markup"])["keyboard"] for b in row]
    assert telegram.BTN_NEXT in labels


def test_the_list_goes_without_the_keyboard_so_that_it_can_be_edited(repo: Path, monkeypatch):
    """R10.1 и R11.1 в одном сообщении не помещаются — и это узнал живой Телеграм.

    Настоящий Bot API отвечает «message can't be edited» на правку сообщения,
    отправленного с клавиатурой снизу. Заглушка правит что угодно, поэтому
    поломка была видна только живым проходом (`tests/live_telegram.sh`): список
    уходил с клавиатурой, первая же правка падала, и панель забывала сообщение —
    R11 не работал вовсе. Кнопки от этого не пропали: их несёт любой другой ответ.
    """
    _make(repo, "Прогон с кнопками")
    sent: list[dict] = []
    updates = [{"update_id": 5, "message": {"chat": {"id": OWNER},
                                            "text": telegram.BTN_LIST}}]

    def fake_call(token, method, params=None, timeout=10):
        if method == "getUpdates":
            return updates
        if method == "sendMessage":
            sent.append(params)
            return {"message_id": 1}
        return {}

    monkeypatch.setattr(telegram, "call", fake_call)
    telegram.poll_once(_roots(repo))

    assert "Прогон с кнопками" in sent[0]["text"]
    assert "reply_markup" not in sent[0], "список с клавиатурой Телеграм править не даст"
