"""Связь с Телеграмом.

Настоящий Телеграм здесь не дёргается ни разу: на петле поднимается поддельный
Bot API, а базовый адрес берётся из окружения. Поэтому проверяется весь путь
целиком — токен → getUpdates → команда → ответ → уведомление — и при этом ни
один секрет не нужен.

Что здесь важно и потому закрыто проверками:

1. **Токен — секрет.** Файл 0600 в каталоге 0700, вне `.chekan/` (тот уезжает
   в репозиторий), и нигде целиком не печатается — даже в тексте ошибки, куда
   urllib охотно кладёт полный адрес запроса вместе с токеном.
2. **`/pause` из Телеграма — ТА ЖЕ пауза, что кнопкой.** Иначе получилось бы
   две независимые паузы, и одна из них декоративная.
3. **Связь не смеет ронять работу.** Нет сети — команда всё равно проходит.
"""

from __future__ import annotations

import json
import os
import stat
import subprocess
import sys
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import pytest

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

from chekan import proof, telegram  # noqa: E402
from chekan.__main__ import main  # noqa: E402
from chekan.store import Run  # noqa: E402

# Формой совпадает с настоящим (10 цифр, двоеточие, 35 знаков) — иначе сканер
# секретов проверялся бы на строке, которую он и не обязан узнавать.
TOKEN = "1234567890:AAHfake-token-for-tests-0123456789x"
OWNER = 424242


# --- поддельный Телеграм ---


class FakeTelegram:
    """Bot API на петле: getMe, getUpdates, sendMessage. Ровно столько, сколько нужно."""

    def __init__(self, token: str = TOKEN):
        self.token = token
        self.sent: list[dict] = []
        self.queue: list[dict] = []
        self._next = 1
        outer = self

        class H(BaseHTTPRequestHandler):
            def log_message(self, *a):
                pass

            def do_POST(self):
                parts = self.path.strip("/").split("/")
                length = int(self.headers.get("Content-Length") or 0)
                raw = self.rfile.read(length).decode("utf-8")
                params = dict(
                    p.split("=", 1) for p in raw.split("&") if "=" in p
                ) if raw else {}
                params = {k: urllib.parse.unquote_plus(v) for k, v in params.items()}
                if len(parts) != 2 or parts[0] != "bot" + outer.token:
                    self._out(401, {"ok": False, "description": "Unauthorized"})
                    return
                method = parts[1]
                if method == "getMe":
                    self._out(200, {"ok": True, "result": {"id": 7, "is_bot": True,
                                                           "username": "ceh_test_bot"}})
                elif method == "getUpdates":
                    offset = int(params.get("offset") or 0)
                    batch = [u for u in outer.queue if u["update_id"] >= offset]
                    self._out(200, {"ok": True, "result": batch})
                elif method == "sendMessage":
                    outer.sent.append(params)
                    self._out(200, {"ok": True, "result": {"message_id": 1}})
                else:
                    self._out(200, {"ok": False, "description": "unknown method"})

            def _out(self, code, payload):
                body = json.dumps(payload).encode("utf-8")
                self.send_response(code)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(body)))
                self.end_headers()
                self.wfile.write(body)

        self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
        self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}"
        threading.Thread(target=self.httpd.serve_forever, daemon=True).start()

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

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

    def close(self):
        self.httpd.shutdown()


@pytest.fixture
def tg(tmp_path: Path, monkeypatch) -> FakeTelegram:
    fake = FakeTelegram()
    monkeypatch.setenv(telegram.API_ENV, fake.url)
    monkeypatch.setenv(telegram.CONFIG_ENV, str(tmp_path / "cfg" / "telegram.json"))
    yield fake
    fake.close()


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


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


def _runs_root(run: Run) -> Path:
    return run.root / ".chekan"


# --- токен как секрет ---


def test_token_file_is_private(tg: FakeTelegram):
    telegram.connect(TOKEN)
    path = telegram.config_path()
    assert path.is_file()
    assert stat.S_IMODE(path.stat().st_mode) == 0o600, "токен читается кем угодно"
    assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700, "каталог с токеном открыт"


def test_token_is_not_written_into_the_run_directory(tg: FakeTelegram, run: Run):
    """`.chekan/` уезжает в репозиторий проекта — токену там не место."""
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    telegram.notify(telegram.status_text(run))
    base = _runs_root(run)
    leaked = [
        p for p in base.rglob("*")
        if p.is_file() and TOKEN in p.read_text(encoding="utf-8", errors="ignore")
    ]
    assert not leaked, f"токен утёк в каталог прогона: {leaked}"
    assert base not in telegram.config_path().parents


def test_default_config_path_lives_in_the_home_config(monkeypatch, tmp_path: Path):
    monkeypatch.delenv(telegram.CONFIG_ENV, raising=False)
    monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
    assert telegram.config_path() == tmp_path / ".config" / "chekan" / "telegram.json"


def test_status_for_the_page_carries_only_the_tail(tg: FakeTelegram):
    telegram.connect(TOKEN)
    payload = telegram.status()
    assert TOKEN not in json.dumps(payload, ensure_ascii=False), "токен уехал на страницу"
    assert payload["token_tail"] == "…" + TOKEN[-4:]
    assert payload["bot"] == "ceh_test_bot"


def test_mask_shows_four_last_signs_and_no_more():
    assert telegram.mask("1234567890:AAHsecretsecret") == "…cret"
    assert telegram.mask(None) == "—"


def test_error_text_never_carries_the_token(tg: FakeTelegram, monkeypatch):
    """urllib кладёт в исключение полный адрес, а токен стоит прямо в пути."""
    monkeypatch.setenv(telegram.API_ENV, "http://127.0.0.1:1")  # никто не слушает
    with pytest.raises(telegram.TelegramError) as exc:
        telegram.connect(TOKEN)
    text = str(exc.value)
    assert TOKEN not in text
    assert TOKEN.split(":")[1][:8] not in text
    assert "1234567890" not in text, "идентификатор бота — тоже часть токена"


def test_wrong_token_is_refused_and_nothing_is_saved(tg: FakeTelegram):
    with pytest.raises(telegram.TelegramError):
        telegram.connect("9999999999:not-the-right-one")
    assert not telegram.config_path().exists(), "негодный токен всё-таки записан"


def test_secrets_scanner_recognises_a_bot_token():
    from chekan import secrets

    text = f"BOT_TOKEN={TOKEN}\n"
    findings = secrets.scan_text(text, "brief.md")
    assert findings, "сканер не узнал токен бота — он уедет в бриф и отчёт"
    redacted, _ = secrets.redact(text)
    assert TOKEN not in redacted


def test_forget_removes_the_token(tg: FakeTelegram):
    telegram.connect(TOKEN)
    assert telegram.forget() is True
    assert not telegram.config_path().exists()
    assert telegram.status() == {"connected": False}


# --- разбор команд ---


@pytest.mark.parametrize(
    "text,expected",
    [
        ("Список", "list"),
        ("Пауза", "pause"),
        ("Помощь", "help"),
        ("Следующий", "next"),
        ("/start", "start"),
        ("/start@ceh_test_bot", "start"),
        ("/status", None),
        ("  /Pause  ", None),
        ("/resume лишнее слово", None),
        ("привет", None),
        ("", None),
    ],
)
def test_parse_command(text, expected):
    assert telegram.parse_command(text) == expected


def test_start_binds_one_chat_and_others_are_ignored(tg: FakeTelegram, run: Run):
    telegram.connect(TOKEN)
    chat, reply = telegram.handle_update(tg.say("/start"), _runs_root(run))
    assert chat == OWNER and reply and "Связь установлена" in reply
    assert telegram.load()["chat_id"] == OWNER

    _, foreign = telegram.handle_update(tg.say("Список", chat=999), _runs_root(run))
    assert foreign is None, "бот ответил чужому чату"


def test_status_answer_reports_phase_tasks_and_blockers(tg: FakeTelegram, run: Run):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    proof.run_command(run, "true", task="01")
    assert proof.close_task(run, "01")[0]
    reply = telegram.status_text(run)
    assert "Тестовый прогон" in reply
    assert "закрыто 1 из 1" in reply
    assert "Фаза:" in reply


def test_help_names_the_limitation(tg: FakeTelegram, run: Run):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    _, reply = telegram.handle_update(tg.say("Помощь"), _runs_root(run))
    assert "панель" in reply.lower(), "ограничение «пока работает панель» не названо"
    for btn in ("Список", "Пауза", "Следующий", "Помощь"):
        assert btn in reply


def test_plain_text_without_open_run_explains_how_to_open(tg: FakeTelegram, run: Run):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    _, reply = telegram.handle_update(tg.say("танцы"), _runs_root(run))
    assert "Открытого прогона нет" in reply


def test_without_an_active_run_the_bot_says_so(tg: FakeTelegram, repo: Path):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    (repo / ".chekan").mkdir(exist_ok=True)
    assert telegram.handle_command("status", repo / ".chekan") == telegram.NO_RUN


# --- пауза: та же самая, что кнопкой ---


def test_pause_from_telegram_is_the_button_pause(tg: FakeTelegram, run: Run, repo: Path, capsys):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    _, reply = telegram.handle_update(tg.say("Пауза"), _runs_root(run))
    assert "паузе" in reply

    fresh = Run.load(repo)
    assert fresh.is_paused(), "пауза из Телеграма не попала в состояние прогона"
    assert len(fresh.state["pauses"]) == 1, "заведена вторая, отдельная пауза"
    assert _cli(repo, "task", "start", "01") == 1, "на паузе задача всё-таки взята в работу"
    assert "на паузе" in capsys.readouterr().err


def test_resume_from_telegram_closes_the_same_span(tg: FakeTelegram, run: Run, repo: Path):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    run.pause()  # поставлено кнопкой
    reply = telegram.handle_command("resume", _runs_root(run))
    assert "продолжается" in reply
    fresh = Run.load(repo)
    assert not fresh.is_paused()
    assert len(fresh.state["pauses"]) == 1 and fresh.state["pauses"][0]["to"]
    assert _cli(repo, "task", "start", "01") == 0


def test_repeated_pause_answers_honestly(tg: FakeTelegram, run: Run):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    telegram.handle_update(tg.say("Пауза"), _runs_root(run))
    _, again = telegram.handle_update(tg.say("Пауза"), _runs_root(run))
    assert "уже на паузе" in again
    assert len(Run.load(run.root).state["pauses"]) == 1


# --- тексты уведомлений ---


def test_task_done_text_names_the_proof(tg: FakeTelegram, run: Run):
    proof.run_command(run, "true", task="01")
    proof.close_task(run, "01")
    text = telegram.task_done_text(run, "01")
    assert "01" in text and "задача" in text
    assert "доказано: true" in text
    assert "1 из 1" in text


def test_gate_text_separates_pass_from_fail(run: Run):
    ok = telegram.gate_text(run, "G1", [])
    bad = telegram.gate_text(run, "G1", ["первая претензия", "вторая претензия"])
    assert "пройден" in ok and "НЕ пройден" not in ok
    assert "НЕ пройден" in bad and "первая претензия" in bad


def test_finished_text_counts_the_tasks(run: Run):
    text = telegram.finished_text(run)
    assert "завершён" in text and "0 из 1" in text


# --- полный круг через поддельный сервер ---


def test_full_round_trip_token_to_answer(tg: FakeTelegram, run: Run):
    """Токен → getUpdates → команда → ответ. Ровно тот путь, что у владельца."""
    telegram.connect(TOKEN)
    tg.say("/start")
    tg.say("Список")
    assert telegram.poll_once(_runs_root(run)) == 2
    assert len(tg.sent) == 2
    assert "Связь установлена" in tg.texts()[0]
    assert "Тестовый прогон" in tg.texts()[1]
    assert telegram.load()["offset"] == 3, "смещение не сдвинулось — те же обновления придут снова"
    assert telegram.load()["last_seen"]

    tg.sent.clear()
    assert telegram.poll_once(_runs_root(run)) == 0, "обработанные обновления пришли повторно"
    assert not tg.sent


def test_closing_a_task_sends_a_notification(tg: FakeTelegram, run: Run, repo: Path):
    """Настоящий путь уведомления: команда CLI → отправка в Телеграм."""
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    proof.run_command(run, "true", task="01")
    assert _cli(repo, "task", "done", "01") == 0
    assert tg.texts(), "закрытая задача не дошла до Телеграма"
    assert "Задача 01 закрыта" in tg.texts()[-1]
    assert str(OWNER) == tg.sent[-1]["chat_id"]


def test_pause_command_sends_a_notification(tg: FakeTelegram, run: Run, repo: Path):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    assert _cli(repo, "pause") == 0
    assert any("на паузе" in t for t in tg.texts())


def test_report_notifies_that_the_run_is_finished(tg: FakeTelegram, run: Run, repo: Path):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    assert _cli(repo, "report") == 0
    assert any("завершён" in t for t in tg.texts())


def test_nothing_is_sent_before_the_owner_says_start(tg: FakeTelegram, run: Run, repo: Path):
    telegram.connect(TOKEN)  # chat_id ещё не известен
    proof.run_command(run, "true", task="01")
    assert _cli(repo, "task", "done", "01") == 0
    assert not tg.sent, "сообщение ушло неизвестно кому"


# --- связь не смеет ронять работу ---


def test_a_dead_telegram_does_not_break_the_command(tg: FakeTelegram, run: Run, repo: Path,
                                                    monkeypatch):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    monkeypatch.setenv(telegram.API_ENV, "http://127.0.0.1:1")  # сети нет
    proof.run_command(run, "true", task="01")
    assert _cli(repo, "task", "done", "01") == 0, "нет связи — и работа встала"
    assert Run.load(repo).task("01")["status"] == "done"


def test_notify_without_any_config_is_a_no_op(tg: FakeTelegram):
    assert telegram.notify("что-нибудь") is False
    assert not tg.sent


# --- приём с панели ---


class _Panel:
    """Сервер панели на петле — тот же обработчик, что раздаёт страницу."""

    def __init__(self, directory: Path):
        from functools import partial

        from chekan.serve_app import Handler

        self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(directory)))
        self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}"
        threading.Thread(target=self.httpd.serve_forever, daemon=True).start()

    def ask(self, path: str, payload: dict | None = None) -> tuple[int, dict]:
        data = json.dumps(payload).encode("utf-8") if payload is not None else None
        req = urllib.request.Request(self.url + path, data=data,
                                     headers={"Content-Type": "application/json"},
                                     method="POST" if data is not None else "GET")
        try:
            with urllib.request.urlopen(req, timeout=10) as r:
                return r.status, json.loads(r.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            return exc.code, json.loads(exc.read().decode("utf-8"))

    def close(self):
        self.httpd.shutdown()


@pytest.fixture
def panel(run: Run):
    p = _Panel(_runs_root(run))
    yield p
    p.close()


def test_panel_connects_and_never_gives_the_token_back(tg: FakeTelegram, panel):
    code, payload = panel.ask("/api/telegram/connect", {"token": TOKEN})
    assert code == 200 and payload["connected"] is True
    code, shown = panel.ask("/api/telegram/status")
    assert code == 200
    assert TOKEN not in json.dumps(shown, ensure_ascii=False)
    assert shown["token_tail"] == "…" + TOKEN[-4:]
    assert shown["bot"] == "ceh_test_bot"


def test_panel_reports_a_bad_token_without_echoing_it(tg: FakeTelegram, panel):
    code, payload = panel.ask("/api/telegram/connect", {"token": "9999999999:nope"})
    assert code == 400
    assert "9999999999" not in payload["error"]
    assert not telegram.config_path().exists()


def test_panel_disconnects(tg: FakeTelegram, panel):
    panel.ask("/api/telegram/connect", {"token": TOKEN})
    code, payload = panel.ask("/api/telegram/disconnect", {})
    assert code == 200 and payload == {"connected": False}
    assert not telegram.config_path().exists()


def test_panel_pause_button_notifies_telegram(tg: FakeTelegram, run: Run, panel):
    telegram.connect(TOKEN)
    telegram.update(chat_id=OWNER)
    code, payload = panel.ask(f"/{run.slug}/api/pause", {})
    assert code == 200 and payload["paused"] is True
    assert any("на паузе" in t for t in tg.texts()), "кнопка паузы промолчала в Телеграме"
