#!/usr/bin/env bash
# Живая проверка связи с Телеграмом: настоящий бот, настоящий аккаунт владельца.
#
# Заглушка доказывает разговор с заглушкой. Здесь вопрос уходит через api.telegram.org
# в настоящего бота, а отвечает настоящий аккаунт владельца в web.telegram.org —
# кнопки жмутся мышью в браузере, а не подставляются в getUpdates. Подмена
# входящего сообщения самодельным ответом Bot API живым проходом не считается:
# тогда это та же заглушка, только дороже.
#
# Сценарий: /start → клавиатура снизу → `chekan ask` → вопрос пришёл в чат →
# нажатие «Следующий» → ответ обычным текстом → `chekan ask` вернул его дословно и
# пауза снялась → «Список» → состояние на диске изменилось → уже отправленное
# сообщение поправлено на месте, нового не прислано.
#
# Токен тестового бота: ~/.config/chekan/test-bot-token (0600). Наружу из него
# выходит только хвост из четырёх знаков — ни в выводе, ни в артефактах, ни в
# командной строке процессов целиком его нет.
#
#   bash tests/live_telegram.sh          0 — всё доказано, 1 — нет
#   CHEKAN_LIVE_KEEP=1 …                оставить рабочий каталог для разбора
#   CHEKAN_LIVE_HEADED=1 …              смотреть глазами, как это происходит
set -uo pipefail

REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOKEN_FILE="${CHEKAN_LIVE_TOKEN_FILE:-$HOME/.config/chekan/test-bot-token}"
SESSION_FILE="${CHEKAN_LIVE_SESSION:-$HOME/.gaechkin/tg_test_session.json}"
TG_CLIENT="$REPO/canon/skills/tg-web-e2e/tg_client.py"
PY="${CHEKAN_LIVE_PYTHON:-python3}"

die() { printf '%s\n' "$@" >&2; exit 1; }
say() { printf '%s\n' "$*"; }

# --- чем проверяем ---------------------------------------------------------

[ -r "$TOKEN_FILE" ] || die \
  "Нет токена тестового бота: $TOKEN_FILE" \
  "  Живой проход без него невозможен — проверять нечем." \
  "  Заведите бота у @BotFather и положите токен туда: права 0600, в репозиторий не класть."
[ -s "$TOKEN_FILE" ] || die "Файл токена пуст: $TOKEN_FILE"

[ -r "$SESSION_FILE" ] || die \
  "Нет сессии Телеграма владельца: $SESSION_FILE" \
  "  Боты друг другу не пишут — сторона владельца это живой аккаунт в web.telegram.org." \
  "  Заведите её один раз: python3 $REPO/canon/skills/tg-web-e2e/bootstrap_session.py"
[ -r "$TG_CLIENT" ] || die "Нет клиента Телеграма: $TG_CLIENT"

"$PY" -c 'import playwright.async_api' 2>/dev/null || die \
  "Нет playwright для $PY — живой стороной владельца управлять нечем." \
  "  $PY -m pip install playwright && $PY -m playwright install chromium"

# --- рабочий каталог: свой проект, своя конфигурация связи ------------------

WORK="$(mktemp -d "${TMPDIR:-/tmp}/ceh-live-XXXXXX")"
export CHEKAN_TG_CONFIG="$WORK/telegram.json"   # боевую связь владельца не трогаем
export PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}"
export CHEKAN_LIVE_WORK="$WORK"
export CHEKAN_LIVE_REPO="$REPO"
export CHEKAN_LIVE_SESSION_FILE="$SESSION_FILE"
export CHEKAN_LIVE_CLIENT="$TG_CLIENT"
export CHEKAN_LIVE_TOKEN_FILE="$TOKEN_FILE"

cleanup() {
  local code=$?
  ( cd "$WORK" && "$PY" -m chekan dash --stop >/dev/null 2>&1 )
  [ -s "$WORK/ask.pid" ] && kill "$(cat "$WORK/ask.pid")" 2>/dev/null
  pkill -f "$WORK/.chekan" 2>/dev/null
  if [ -n "${CHEKAN_LIVE_KEEP:-}" ]; then
    say "Рабочий каталог оставлен: $WORK"
  else
    rm -rf "$WORK"
  fi
  return $code
}
trap cleanup EXIT

# --- связь: токен, хозяин, чистое смещение ---------------------------------

"$PY" - > "$WORK/link.tsv" <<'PY' || die "Связь с тестовым ботом не поднялась."
import json, os, pathlib, sys

sys.path.insert(0, os.environ["CHEKAN_LIVE_REPO"])
from chekan import telegram

token = pathlib.Path(os.environ["CHEKAN_LIVE_TOKEN_FILE"]).read_text(encoding="utf-8").strip()
# Хозяин бота — тот самый живой аккаунт, чьей сессией мы будем жать кнопки.
# Вписывать его числом рядом значит однажды проверять один аккаунт сессией другого.
session = json.loads(pathlib.Path(os.environ["CHEKAN_LIVE_SESSION_FILE"]).read_text("utf-8"))
owner = None
for origin in session.get("origins") or []:
    for item in origin.get("localStorage") or []:
        if item.get("name") == "user_auth":
            owner = json.loads(item["value"]).get("id")
if owner is None:
    print("В сессии нет владельца (user_auth) — заведите её заново.", file=sys.stderr)
    raise SystemExit(1)
try:
    state = telegram.connect(token, owner)
except telegram.TelegramError as exc:
    print(f"Телеграм не принял тестовый токен: {exc}", file=sys.stderr)
    raise SystemExit(1)
# Смещение вперёд: getUpdates отдаёт сутки истории, и вчерашний «Список» разобрался
# бы как сегодняшняя команда. Проверка обязана начинать с пустого чата.
last = telegram.call(token, "getUpdates", {"offset": -1, "timeout": 0})
if last:
    telegram.update(offset=last[-1]["update_id"] + 1)
print(state["bot"], state["token_tail"], state["owner_id"], sep="\t")
PY

BOT_NAME="$(cut -f1 "$WORK/link.tsv")"
TOKEN_TAIL="$(cut -f2 "$WORK/link.tsv")"
OWNER_ID="$(cut -f3 "$WORK/link.tsv")"
export CHEKAN_LIVE_BOT="$BOT_NAME"
say "Бот @$BOT_NAME (токен $TOKEN_TAIL), владелец $OWNER_ID."

# --- прогон и панель -------------------------------------------------------

( cd "$WORK" && git init -q . && git config user.email live@local && git config user.name live \
  && : > app.py && git add -A && git commit -qm live ) || die "Не завёлся git-репозиторий проверки."

( cd "$WORK" && "$PY" -m chekan init "живая проверка связи с Телеграмом" --no-dash >/dev/null ) \
  || die "Не завёлся прогон проверки."
# Опрос Телеграма живёт в сервере панели: без запущенной панели бот молчит.
( cd "$WORK" && "$PY" -m chekan dash --no-open >/dev/null ) || die "Панель не поднялась."

# --- сам проход ------------------------------------------------------------

"$PY" - <<'PY'
"""Сторона владельца: живой аккаунт в web.telegram.org под управлением Playwright.

Вторым ботом это не сделать — боты друг другу не пишут; getUpdates своими руками
не считается — это заглушка, только дороже. Поэтому кнопки жмёт настоящий клиент
Телеграма настоящим аккаунтом, а бот на той стороне узнаёт о нажатии ровно так же,
как узнал бы от владельца.
"""
import asyncio
import importlib.util
import json
import os
import subprocess
import sys
import time
from pathlib import Path

WORK = Path(os.environ["CHEKAN_LIVE_WORK"])
REPO = os.environ["CHEKAN_LIVE_REPO"]
BOT = os.environ["CHEKAN_LIVE_BOT"]
HEADLESS = not os.environ.get("CHEKAN_LIVE_HEADED")

spec = importlib.util.spec_from_file_location("tg_client", os.environ["CHEKAN_LIVE_CLIENT"])
tg_client = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tg_client)

MARK = time.strftime("%H%M%S")
QUESTION = f"Живая проверка {MARK}: брать Postgres или хватит файлов?"
ANSWER = f"Хватит файлов, {MARK} — Postgres не берём."
DELIVERY_WAIT = 90   # тик доставки идёт перед длинным опросом: 0–25 секунд плюс сеть
REPLY_WAIT = 60
EDIT_WAIT = 90

proved, failures = [], []


def ok(text):
    proved.append(text)
    print(f"  ✔ {text}", flush=True)


def bad(text):
    failures.append(text)
    print(f"  ✘ {text}", flush=True)


def ceh(*args, wait=True):
    proc = subprocess.Popen(
        [sys.executable, "-m", "chekan", *args],
        cwd=WORK, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
    )
    if not wait:
        (WORK / "ask.pid").write_text(str(proc.pid), encoding="utf-8")
        return proc
    return proc.communicate()[0]


def state():
    runs = sorted((WORK / ".chekan").glob("*/state.json"))
    return json.loads(runs[0].read_text(encoding="utf-8"))


async def bubbles(page):
    """Тексты входящих пузырей с их идентификаторами: (mid, текст)."""
    return await page.evaluate(
        "() => Array.from(document.querySelectorAll('.bubbles-inner .bubble.is-in[data-mid]'))"
        ".map(b => [Number(b.getAttribute('data-mid')),"
        " (b.querySelector('.message') || b).innerText.trim()])"
    )


async def wait_for(page, needle, timeout, after=0):
    """Дождаться входящего сообщения с этим текстом. Не дождались — None."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        for mid, text in await bubbles(page):
            if mid > after and needle in text:
                return mid, text
        await asyncio.sleep(1.0)
    return None


async def settle(page, quiet=5.0, timeout=45):
    """Дождаться тишины в чате и вернуть последний входящий.

    Живой клиент — не заглушка: нажатие на кнопку изредка уходит дважды (панель
    кнопок в этот момент сворачивается, и клик приходит по уже переставленному
    элементу). Мерку «нового сообщения не прислали» нельзя ставить, пока чат не
    затих: иначе она поймает эхо собственного нажатия, а не работу бота.
    """
    deadline = time.time() + timeout
    last, since = None, time.time()
    while time.time() < deadline:
        mids = [mid for mid, _ in await bubbles(page)]
        top = max(mids) if mids else 0
        if top != last:
            last, since = top, time.time()
        elif time.time() - since >= quiet:
            return top
        await asyncio.sleep(1.0)
    return last or 0


async def reply_keyboard(page):
    """Подписи кнопок снизу. Панель приходит свёрнутой — сначала разворачиваем."""
    buttons = page.locator(".reply-keyboard-button")
    if await buttons.count() == 0 or not await buttons.first.is_visible():
        toggle = page.locator(".btn-icon.toggle-reply-markup").first
        if await toggle.count():
            await toggle.click()
            await asyncio.sleep(1.5)
    labels = []
    for i in range(await buttons.count()):
        labels.append((await buttons.nth(i).inner_text()).strip())
    return labels


async def press(page, label):
    """Нажать кнопку снизу мышью — не отправить её подпись текстом."""
    await reply_keyboard(page)
    await page.locator(f".reply-keyboard-button:has-text('{label}')").first.click()
    await asyncio.sleep(0.5)


async def main():
    async with tg_client.TgWebClient(
        session_path=Path(os.environ["CHEKAN_LIVE_SESSION_FILE"]), headless=HEADLESS
    ) as tg:
        page = tg.page
        await tg.open_chat(BOT)
        seen = await tg.incoming_count()

        print("1. Бот отвечает живому владельцу", flush=True)
        await tg.send("/start")
        hit = await wait_for(page, "Чекан на связи", REPLY_WAIT, after=seen)
        if not hit:
            bad("бот не ответил на /start — дальше проверять нечего")
            return
        seen = hit[0]
        ok("настоящий бот ответил настоящему аккаунту владельца")

        print("2. Клавиатура снизу", flush=True)
        labels = await reply_keyboard(page)
        want = ["Список", "Следующий", "Пауза", "Помощь"]
        missing = [w for w in want if w not in labels]
        if missing:
            bad(f"в клиенте нет кнопок: {', '.join(missing)} (видно: {labels})")
        else:
            ok("клавиатура пришла в клиент: " + ", ".join(want))

        print("3. Вопрос уходит владельцу сам", flush=True)
        ask = ceh("ask", QUESTION, wait=False)
        hit = await wait_for(page, QUESTION, DELIVERY_WAIT, after=seen)
        if not hit:
            bad("вопрос не доехал в Телеграм за %d с" % DELIVERY_WAIT)
            ask.kill()
            return
        seen = hit[0]
        if "живая проверка связи" in hit[1]:
            ok("вопрос пришёл в чат сам, с именем прогона")
        else:
            bad("вопрос пришёл без имени прогона")

        print("4. Нажатие кнопки «Следующий»", flush=True)
        if missing:
            bad("кнопку нажать нечем — пропускаем")
        else:
            await press(page, "Следующий")
            hit = await wait_for(page, "Ответ пишите сюда обычным текстом", REPLY_WAIT, after=seen)
            if not hit:
                bad("нажатие кнопки не открыло прогон — клавиатура осталась картинкой")
            elif QUESTION not in hit[1]:
                bad("кнопка открыла не тот прогон")
            else:
                seen = hit[0]
                ok("нажатие кнопки выполнило команду, а не записалось ответом")

        print("5. Ответ владельца", flush=True)
        await tg.send(ANSWER)
        hit = await wait_for(page, "Ответ записан", REPLY_WAIT, after=seen)
        if hit:
            seen = hit[0]
            ok("бот принял ответ обычным текстом")
        else:
            bad("бот не подтвердил ответ")

        out = ""
        deadline = time.time() + 60
        while time.time() < deadline and ask.poll() is None:
            await asyncio.sleep(1.0)
        if ask.poll() is None:
            ask.kill()
            bad("`chekan ask` не вернулся после ответа")
        else:
            out = ask.communicate()[0] or ""
            if ask.returncode != 0:
                bad(f"`chekan ask` вернул {ask.returncode}")
            elif ANSWER not in out:
                bad("`chekan ask` не напечатал ответ дословно")
            else:
                ok("`chekan ask` вернул ответ дословно, код возврата 0")

        st = state()
        q = (st.get("questions") or [{}])[0]
        pause = (st.get("pauses") or [{}])[-1]
        if st.get("waiting_for") is None and q.get("status") == "answered" \
                and (q.get("answer") or {}).get("by") == "telegram" and pause.get("to"):
            ok("пауза ожидания снялась сама, ответ записан рядом с вопросом")
        else:
            bad("прогон остался в ожидании или ответ записан не оттуда")

        print("6. Список правится на месте", flush=True)
        if missing:
            bad("кнопку «Список» нажать нечем — пропускаем")
            return
        await press(page, "Список")
        if not await wait_for(page, "Прогоны проекта", REPLY_WAIT, after=seen):
            bad("список не пришёл")
            return
        await settle(page)
        lists = [(mid, text) for mid, text in await bubbles(page) if "Прогоны проекта" in text]
        list_mid, before = max(lists)   # правится последний отправленный
        # Мерка отсюда: сколько списков в чате было до того, как состояние на
        # диске изменилось. Правка не должна прибавить к этому числу ни одного.
        lists_before = len(lists)
        ceh("task", "add", "77", "--title", f"задача проверки {MARK}")
        deadline = time.time() + EDIT_WAIT
        after_text = before
        while time.time() < deadline:
            after_text = dict(await bubbles(page)).get(list_mid, before)
            if after_text != before:
                break
            await asyncio.sleep(1.0)
        lists_after = sum(1 for _, text in await bubbles(page) if "Прогоны проекта" in text)
        if after_text == before:
            bad("отправленный список не поправился на изменение состояния")
        elif "закрыто 0 из 1" not in after_text:
            bad("список поправился, но не тем, что на диске")
        else:
            ok("уже отправленное сообщение поправлено на месте")
        if lists_after > lists_before:
            bad(f"вместо правки прислан новый список ({lists_before} → {lists_after})")
        else:
            ok("нового сообщения со списком не прислано")


asyncio.run(main())
print()
print(f"Доказано живьём: {len(proved)}")
for line in failures:
    print(f"НЕ доказано: {line}")
raise SystemExit(1 if failures else 0)
PY
code=$?

if [ "$code" -eq 0 ]; then
  say "Живой проход через настоящий Телеграм: всё сошлось."
else
  say "Живой проход не сошёлся — смотрите строки «НЕ доказано» выше."
fi
exit "$code"
