"""Вопрос наружу и ответ обратно.

Механизм, которого не было вовсе: исполнитель упёрся, спросил владельца, прогон
встал, ответ вернулся и прогон пошёл дальше. Четыре вещи здесь легко сделать
неправильно, поэтому каждая закрыта отдельно и обязана краснеть, если её сломать:

1. **Вопрос живёт на диске.** Убит процесс — вопрос на месте; иначе `chekan ask`
   помнит вопрос только в своей памяти и панель показать его не может.
2. **Ожидание отличимо от ручной паузы.** Иначе владелец видит «на паузе» и
   думает, что это он сам её поставил, — а работа ждёт его слова.
3. **Ответ снимает паузу сам.** Отдельная команда «продолжить» тут значила бы,
   что прогон стоит и после ответа, пока о нём не вспомнят.
4. **Таймаута нет.** Проверяется подменой часов, а не ожиданием: сдвинули на
   сутки — вопрос всё ещё ждёт.
"""

from __future__ import annotations

import datetime as dt
import os
import subprocess
import sys
import time
from pathlib import Path

import pytest

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

from chekan import ask  # noqa: E402
from chekan.__main__ import main  # noqa: E402
from chekan.store import Run, StateError, new_state, validate  # noqa: E402

ROOT = Path(__file__).resolve().parent.parent


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


def _run(repo: Path, mode: str = "normal") -> Run:
    r = Run.create(repo, "Тестовый прогон", mode, "T1")
    r.add_requirement("R01", "цитата", note="причина")
    r.add_task("01", "задача", ["R01"], verify="true")
    return r


@pytest.fixture
def run(repo: Path) -> Run:
    return _run(repo)


def _cli(repo: Path, *args: str) -> int:
    cwd = Path.cwd()
    try:
        os.chdir(repo)
        return main(list(args))
    finally:
        os.chdir(cwd)


def _spawn(repo: Path, *args: str) -> subprocess.Popen:
    """Настоящий отдельный процесс: `chekan ask` блокируется, ответ приходит извне."""
    env = dict(os.environ)
    env["PYTHONPATH"] = str(ROOT) + (
        os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else ""
    )
    return subprocess.Popen(
        [sys.executable, "-m", "chekan", *args],
        cwd=repo, env=env, text=True,
        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    )


def _wait_for_question(run: Run, seconds: float = 20.0) -> dict:
    deadline = time.monotonic() + seconds
    while time.monotonic() < deadline:
        q = ask.waiting(Run(run.root, run.slug))
        if q is not None:
            return q
        time.sleep(0.05)
    raise AssertionError("вопрос так и не появился на диске")


# --- вопрос на диске ---


def test_question_is_written_to_disk_with_text_time_task_and_status(run: Run):
    run.set_task_status("01", "in-progress")
    q = ask.create(run, "Брать порт 8443 или 443?")

    fresh = ask.waiting(Run(run.root, run.slug))  # перечитано с диска, не из памяти
    assert fresh is not None
    assert fresh["id"] == q["id"]
    assert fresh["text"] == "Брать порт 8443 или 443?"
    assert fresh["status"] == "waiting"
    assert fresh["asked_at"]
    assert fresh["task"] == "01", "вопрос не привязан к задаче, на которой упёрлись"


def test_question_survives_the_death_of_the_process_that_asked(run: Run, repo: Path):
    """Убитый `chekan ask` не уносит вопрос с собой."""
    proc = _spawn(repo, "ask", "Порт какой?")
    try:
        q = _wait_for_question(run)
        proc.kill()
        proc.wait(timeout=10)
    finally:
        if proc.poll() is None:
            proc.kill()

    after = Run(repo, run.slug)
    assert ask.waiting(after) is not None, "вопрос пропал вместе с процессом"
    assert ask.waiting(after)["id"] == q["id"]
    assert after.is_paused(), "процесс умер, а прогон поехал дальше без ответа"


def test_answer_is_recorded_next_to_the_question(run: Run):
    q = ask.create(run, "Порт какой?")
    ask.answer(run, q["id"], "8443", by="telegram")

    stored = ask.question(Run(run.root, run.slug), q["id"])
    assert stored["status"] == "answered"
    assert stored["answer"]["text"] == "8443"
    assert stored["answer"]["by"] == "telegram"
    assert stored["answer"]["at"], "время ответа не записано"


# --- ask ждёт и печатает ответ дословно ---


def test_ask_waits_and_prints_the_answer_verbatim(run: Run, repo: Path):
    proc = _spawn(repo, "ask", "Порт какой?")
    try:
        q = _wait_for_question(run)
        assert _cli(repo, "answer", q["id"], "Только 8443, 443 занят") == 0
        stdout, stderr = proc.communicate(timeout=30)
    finally:
        if proc.poll() is None:
            proc.kill()

    assert proc.returncode == 0, f"chekan ask вернул {proc.returncode}: {stderr}"
    assert "Только 8443, 443 занят" in stdout, "ответ не напечатан дословно"


def test_answer_to_unknown_question_fails(run: Run, repo: Path, capsys):
    ask.create(run, "Порт какой?")
    assert _cli(repo, "answer", "Q99", "неважно") == 1
    assert "Q99" in capsys.readouterr().err


def test_second_question_is_refused_while_one_waits(run: Run):
    ask.create(run, "Первый")
    with pytest.raises(StateError) as exc:
        ask.create(run, "Второй")
    assert "Q1" in str(exc.value)


# --- пауза ожидания ---


def test_question_stops_the_whole_run(run: Run, repo: Path, capsys):
    ask.create(run, "Порт какой?")
    assert Run(repo, run.slug).is_paused(), "вопрос задан, а прогон идёт дальше"
    assert _cli(repo, "task", "start", "01") == 1
    assert "ждёт ответа" in capsys.readouterr().err
    assert Run(repo, run.slug).task("01")["status"] == "pending"


def test_waiting_pause_is_distinguishable_from_a_manual_one(run: Run, repo: Path):
    """Обе паузы останавливают прогон, но причина у них разная и видна в состоянии."""
    run.pause()
    st = Run(repo, run.slug).state
    assert st.get("waiting_for") is None, "ручная пауза выдаёт себя за ожидание ответа"
    assert st["pauses"][-1].get("reason") is None
    run.resume()

    q = ask.create(run, "Порт какой?")
    st = Run(repo, run.slug).state
    assert st["waiting_for"] == q["id"], "ожидание ответа неотличимо от ручной паузы"
    assert st["pauses"][-1]["reason"] == "question"
    assert st["pauses"][-1]["question"] == q["id"]
    assert Run(repo, run.slug).waiting_for() == q["id"]


def test_answer_lifts_the_pause_by_itself(run: Run, repo: Path):
    q = ask.create(run, "Порт какой?")
    ask.answer(run, q["id"], "8443")

    after = Run(repo, run.slug)
    assert not after.is_paused(), "ответ дан, а прогон всё ещё стоит"
    assert after.waiting_for() is None
    assert _cli(repo, "task", "start", "01") == 0, "после ответа работа не возобновилась"


def test_resume_does_not_lift_a_waiting_pause(run: Run, repo: Path, capsys):
    """«Продолжить» руками — это продолжить без ответа, ради которого встали."""
    q = ask.create(run, "Порт какой?")
    assert _cli(repo, "resume") == 1
    assert q["id"] in capsys.readouterr().err
    assert Run(repo, run.slug).is_paused()


def test_manual_pause_outlives_the_answer(run: Run, repo: Path):
    """Ручную паузу ставил владелец — ответ на вопрос его решения не отменяет."""
    run.pause()
    q = ask.create(run, "Порт какой?")
    ask.answer(run, q["id"], "8443")

    after = Run(repo, run.slug)
    assert after.waiting_for() is None
    assert after.is_paused(), "ответ снял чужую, ручную паузу"


# --- режим auto ---


def test_auto_mode_refuses_to_ask(repo: Path):
    """Отдельным процессом со сроком: сорвавшийся отказ обязан краснеть, а не висеть.

    В памяти теста `chekan ask` без отказа ушёл бы ждать ответа, которого в auto
    некому дать, — и проверка не упала бы, а замерла навсегда.
    """
    auto = _run(repo, "auto")
    proc = _spawn(repo, "ask", "Порт какой?")
    try:
        _, err = proc.communicate(timeout=60)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.communicate()
        raise AssertionError("в режиме auto chekan ask встал ждать ответа, которого некому дать")

    assert proc.returncode != 0, "в режиме auto вопрос всё-таки задан"
    assert "auto" in err
    assert "предположение" in err
    assert not Run(repo, auto.slug).state.get("questions"), "в auto вопрос лёг на диск"
    assert not Run(repo, auto.slug).is_paused(), "в auto прогон встал ждать несуществующего ответа"


# --- панель погашена ---


def test_ask_admits_the_channel_is_silent_when_the_panel_is_down(run: Run, repo: Path, capsys):
    assert not ask.panel_running(run)
    proc = _spawn(repo, "ask", "Порт какой?")
    try:
        q = _wait_for_question(run)
        _cli(repo, "answer", q["id"], "8443")
        stdout, _ = proc.communicate(timeout=30)
    finally:
        if proc.poll() is None:
            proc.kill()
    capsys.readouterr()

    assert "Панель не запущена" in stdout, "вопрос сделал вид, что ушёл владельцу"
    assert f"chekan answer {q['id']}" in stdout, "не сказано, как ответить без панели"


# --- таймаута нет ---


def _shift_clock(monkeypatch, hours: int) -> None:
    """Подменить источник времени прогона, а не ждать по-настоящему."""
    from chekan import store

    real = dt.datetime.now

    def later(tz=None):
        return real(tz) + dt.timedelta(hours=hours)

    monkeypatch.setattr(
        store, "now",
        lambda: later(dt.timezone.utc).astimezone().isoformat(timespec="seconds"),
    )


def test_question_does_not_expire_when_the_clock_jumps_a_day(run: Run, repo: Path, monkeypatch):
    q = ask.create(run, "Порт какой?")
    _shift_clock(monkeypatch, 24)

    after = Run(repo, run.slug)
    assert ask.waiting(after) is not None, "вопрос решился сам за сутки"
    assert ask.waiting(after)["id"] == q["id"]
    assert after.is_paused(), "через сутки прогон поехал без ответа"
    assert ask.question(after, q["id"])["status"] == "waiting"


def test_wait_loop_has_no_deadline(run: Run, repo: Path, monkeypatch):
    """Сутки на часах и полсотни кругов ожидания — цикл не сдаётся и не падает."""
    q = ask.create(run, "Порт какой?")
    _shift_clock(monkeypatch, 24)
    rounds = 0

    def sleeper(_seconds: float) -> None:
        nonlocal rounds
        rounds += 1
        if rounds == 50:
            ask.answer(Run(repo, run.slug), q["id"], "8443", by="telegram")

    got = ask.wait_for_answer(run, q["id"], sleeper=sleeper, poll=0)
    assert rounds == 50, "цикл вышел раньше ответа — где-то завёлся срок"
    assert got["answer"]["text"] == "8443"


# --- состояние не даёт разойтись ---


def test_state_rejects_waiting_without_a_pause():
    st = new_state("s", "t", "normal", "T1")
    st["questions"] = [
        {"id": "Q1", "text": "?", "asked_at": "2026-01-01T00:00:00+00:00",
         "status": "waiting", "answer": None}
    ]
    st["waiting_for"] = "Q1"
    with pytest.raises(StateError):
        validate(st)


def test_state_rejects_a_waiting_question_without_the_waiting_mark():
    st = new_state("s", "t", "normal", "T1")
    st["questions"] = [
        {"id": "Q1", "text": "?", "asked_at": "2026-01-01T00:00:00+00:00",
         "status": "waiting", "answer": None}
    ]
    st["pauses"] = [{"from": "2026-01-01T00:00:00+00:00", "to": None}]
    with pytest.raises(StateError):
        validate(st)


def test_old_runs_without_questions_still_validate(run: Run):
    st = dict(run.state)
    st.pop("questions", None)
    st.pop("waiting_for", None)
    validate(st)  # прогоны, заведённые до вопросов, обязаны читаться


# --- шов для доставки ---


def test_delivery_seam_reports_new_and_answered_questions(run: Run, repo: Path):
    q = ask.create(run, "Порт какой?")
    assert [x["id"] for x in ask.undelivered(run)] == [q["id"]]

    ask.mark_delivered(run, q["id"], to=12345)
    assert ask.undelivered(Run(repo, run.slug)) == [], "вопрос слался бы каждый тик опроса"
    assert not ask.is_answered(run, q["id"])

    ask.answer(run, q["id"], "8443", by="telegram")
    assert ask.is_answered(Run(repo, run.slug), q["id"])
    assert ask.undelivered(Run(repo, run.slug)) == []
