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

Владелец заводит бота у @BotFather и вставляет токен и свой идентификатор на главной
странице панели. Дальше бот показывает прогоны всех зарегистрированных проектов как
чаты, отвечает на `/status`, `/pause`, `/resume`, `/help` и кнопки снизу, сам присылает
уведомления о закрытых задачах, гейтах, паузе и завершении прогона, а вопрос исполнителя
(`chekan ask`) доставляет владельцу и возвращает ответ обратно в прогон.

Две вещи определяют устройство модуля.

**Токен — секрет.** Он живёт вне проекта (`~/.config/chekan/telegram.json`,
права 0600), потому что `.chekan/` коммитится в репозиторий. Целиком он не
печатается нигде: ни на странице, ни в журнале, ни в тексте ошибки — только
последние четыре знака. Адрес запроса к Телеграму содержит токен, поэтому любое
сообщение об ошибке проходит через `scrub()` прежде, чем куда-то попасть.

**Опрос идёт из сервера панели.** Бот отвечает, только пока панель работает:
своего демона Чекан не заводит. Погасили панель — Телеграм замолчал, и об этом
честно сказано и на странице, и в `/help`.
"""

from __future__ import annotations

import json
import os
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

API_ENV = "CHEKAN_TG_API"
CONFIG_ENV = "CHEKAN_TG_CONFIG"
DEFAULT_API = "https://api.telegram.org"

# Длинный опрос: соединение висит до 25 секунд и возвращается сразу, как пришло
# сообщение. Короткие опросы в цикле — это либо секунды задержки, либо тысячи
# пустых запросов в час.
POLL_TIMEOUT = 25

# Круг доставки наружу. Он НЕ ходит в Телеграм, чтобы узнать новости: вопрос
# рождается на диске, и увидеть его можно, ничего не спрашивая. Поэтому здесь
# доли секунды, а не десятки: круг без изменений на диске не даёт ни одного
# обращения наружу, и «часто» тут ничего не стоит. Раньше доставка ехала
# прицепом к длинному опросу и ждала его конца — до 25 секунд от `chekan ask` до
# сообщения владельцу.
TICK_SECONDS = 0.4

# Конфигурацию теперь правят два круга одного процесса: входящий пишет смещение,
# исходящий — память о списке и открытый прогон. Без замка чтение-правка-запись
# одного затирает поле другого: потерянное смещение — это разобранная во второй
# раз команда владельца.
_CFG_LOCK = threading.RLock()

# Тик идёт из двух мест (свой круг и цикл опроса). Одновременно они дали бы один
# вопрос, отправленный дважды: оба видят его недоставленным. По очереди — второй
# перечитает состояние с диска и увидит отметку.
_TICK_LOCK = threading.Lock()


class TelegramError(Exception):
    """Ошибка обращения к Телеграму. Текст показывается владельцу дословно."""


# --- секрет ---


def mask(token: str | None) -> str:
    """Токен для показа: только последние четыре знака."""
    if not token:
        return "—"
    tail = token[-4:]
    return "…" + tail if len(token) > 4 else "…"


def scrub(text: str, token: str | None) -> str:
    """Убрать токен из произвольного текста.

    Через это проходит КАЖДОЕ сообщение об ошибке: urllib вкладывает в свои
    исключения полный адрес, а токен стоит прямо в пути запроса.
    """
    if not token:
        return text
    out = text.replace(token, mask(token))
    head = token.split(":")[0]
    if len(head) >= 6:
        out = out.replace(head, "…")
    return out


# --- хранение ---


def config_path() -> Path:
    override = os.environ.get(CONFIG_ENV)
    if override:
        return Path(override)
    return Path.home() / ".config" / "chekan" / "telegram.json"


def load() -> dict:
    path = config_path()
    if not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return {}
    return data if isinstance(data, dict) else {}


def save(cfg: dict) -> Path:
    """Записать конфигурацию: каталог 0700, файл 0600.

    Права ставятся ДО записи содержимого — иначе между созданием файла и
    `chmod` есть окно, в котором токен читается кем угодно.
    """
    path = config_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    os.chmod(path.parent, 0o700)
    tmp = path.with_suffix(".tmp")
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        json.dump(cfg, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    os.replace(tmp, path)
    os.chmod(path, 0o600)
    return path


def update(**fields) -> dict:
    """Правка отдельных полей чтением-записью: конфигурацию трогают два процесса."""
    with _CFG_LOCK:
        cfg = load()
        cfg.update(fields)
        save(cfg)
    return cfg


def owner(cfg: dict | None = None) -> str | None:
    """Хозяин бота — единственный источник ответа на вопрос «с кем говорить».

    Приоритет назван: заданный вручную идентификатор главнее самоназначения.
    Пусто и там и там — хозяина нет, и первый `/start` его назначит.
    """
    cfg = load() if cfg is None else cfg
    pinned = str(cfg.get("owner_id") or "").strip()
    if pinned:
        return pinned
    chat = cfg.get("chat_id")
    return None if chat is None else str(chat)


def clean_owner(value: str | int | None) -> str:
    """Идентификатор с формы: пусто — хозяина не задавали.

    Не-число отбивается сразу: молчащий бот и так молчит, и опечатка в поле
    выглядела бы ровно как исправная связь.
    """
    text = str(value if value is not None else "").strip()
    if not text:
        return ""
    body = text[1:] if text.startswith("-") else text
    if not body.isdigit():
        raise TelegramError("идентификатор владельца — число из Телеграма, а не имя")
    return text


def forget() -> bool:
    path = config_path()
    if not path.is_file():
        return False
    path.unlink()
    return True


def status() -> dict:
    """Состояние связи для страницы. Токена здесь нет — только хвост."""
    cfg = load()
    if not cfg.get("token"):
        return {"connected": False}
    return {
        "connected": True,
        "bot": cfg.get("bot") or "",
        "token_tail": mask(cfg.get("token")),
        "owner_id": owner(cfg) or "",
        "owner_pinned": bool(str(cfg.get("owner_id") or "").strip()),
        "chat_linked": bool(owner(cfg)),
        "connected_at": cfg.get("connected_at"),
        "last_seen": cfg.get("last_seen"),
    }


# --- обращение к Телеграму ---


def api_base() -> str:
    return os.environ.get(API_ENV) or DEFAULT_API


def call(token: str, method: str, params: dict | None = None, timeout: int = 10) -> dict:
    """Вызов метода Bot API. Возвращает `result`, иначе бросает TelegramError."""
    url = f"{api_base()}/bot{token}/{method}"
    data = urllib.parse.urlencode(params or {}).encode("utf-8")
    try:
        with urllib.request.urlopen(url, data=data, timeout=timeout) as resp:
            payload = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        body = ""
        try:
            body = json.loads(exc.read().decode("utf-8")).get("description", "")
        except Exception:
            pass
        if exc.code in (401, 404):
            raise TelegramError("Телеграм не принял токен: такого бота нет") from None
        raise TelegramError(scrub(f"Телеграм ответил {exc.code}: {body or exc.reason}", token)) from None
    except urllib.error.URLError as exc:
        raise TelegramError(scrub(f"нет связи с Телеграмом: {exc.reason}", token)) from None
    except (TimeoutError, OSError) as exc:
        raise TelegramError(scrub(f"нет связи с Телеграмом: {exc}", token)) from None
    except ValueError:
        raise TelegramError("Телеграм ответил не по-своему — это не Bot API") from None
    if not payload.get("ok"):
        raise TelegramError(scrub(f"Телеграм отказал: {payload.get('description', '?')}", token))
    return payload.get("result")


def connect(token: str, owner_id: str | int | None = None) -> dict:
    """Проверить токен через getMe и запомнить его. Ошибка — ничего не пишем.

    Хозяин переживает сохранение токена: раньше здесь стоял `chat_id: None`, и
    повторное «Подключить» тихо отвязывало владельца от собственного бота.
    """
    token = (token or "").strip()
    if not token:
        raise TelegramError("токен пуст")
    prev = load()
    pinned = clean_owner(owner_id) if owner_id is not None else str(prev.get("owner_id") or "")
    me = call(token, "getMe")
    if not isinstance(me, dict) or not me.get("username"):
        raise TelegramError("Телеграм не назвал бота — токен не от бота")
    from .store import now

    save({
        "token": token,
        "bot": me["username"],
        "connected_at": now(),
        "owner_id": pinned or None,
        "chat_id": prev.get("chat_id"),
        # Смещение — у каждого бота своё: чужое отбросило бы обновления нового.
        "offset": (prev.get("offset") or 0) if prev.get("token") == token else 0,
        "last_seen": prev.get("last_seen"),
    })
    return status()


def post(text: str, chat_id: int | str | None = None, markup: str | None = None) -> dict | None:
    """Отправить сообщение и вернуть, что ответил Телеграм. Не ушло — None.

    Отличается от `send()` только возвратом: правка уже отправленного сообщения
    (R11) требует его идентификатора, а «да/нет» его не несёт.
    """
    cfg = load()
    token = cfg.get("token")
    target = chat_id if chat_id is not None else owner(cfg)
    if not token or target is None:
        return None
    params = {"chat_id": target, "text": text}
    if markup:
        params["reply_markup"] = markup
    try:
        result = call(token, "sendMessage", params, timeout=10)
    except TelegramError:
        # Уведомление — не работа. Упавшая отправка не должна ронять команду,
        # которая её вызвала: прогон важнее сообщения о прогоне.
        return None
    return result if isinstance(result, dict) else {}


def send(text: str, chat_id: int | str | None = None, markup: str | None = None) -> bool:
    """Отправить сообщение владельцу. Молчит, если связь не настроена."""
    return post(text, chat_id=chat_id, markup=markup) is not None


def notify(text: str) -> bool:
    return send(text)


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


def task_done_text(run, tid: str) -> str:
    task = run.task(tid) or {}
    st = run.state
    tasks = st.get("tasks", [])
    done = sum(1 for t in tasks if t.get("status") == "done")
    green = run.green_evidence(tid)
    proof = f"доказано: {green['cmd']}" if green else "без доказательства"
    return (
        f"✅ Задача {tid} закрыта — {task.get('title', '')}\n"
        f"{proof}\n"
        f"Закрыто {done} из {len(tasks)} · «{st.get('title', '')}»"
    )


def gate_text(run, name: str, problems: list[str]) -> str:
    from . import gates

    title = gates.GATE_TITLE.get(name, name)
    if not problems:
        return f"✅ Гейт {name} пройден — {title}\n«{run.state.get('title', '')}»"
    lines = "\n".join(f"· {p}" for p in problems[:5])
    more = f"\n… и ещё {len(problems) - 5}" if len(problems) > 5 else ""
    return f"❌ Гейт {name} НЕ пройден — {title}\n{lines}{more}"


def pause_text(run, paused: bool) -> str:
    if paused:
        return (
            f"⏸ Прогон «{run.state.get('title', '')}» на паузе с "
            f"{(run.paused_since() or '')[:19].replace('T', ' ')}.\n"
            "Новое в работу не берётся."
        )
    return f"▶ Прогон «{run.state.get('title', '')}» продолжается."


def finished_text(run) -> str:
    st = run.state
    tasks = st.get("tasks", [])
    done = sum(1 for t in tasks if t.get("status") == "done")
    bad = sum(1 for t in tasks if t.get("status") == "failed")
    tail = f", провалено {bad}" if bad else ""
    return (
        f"🏁 Прогон завершён — «{st.get('title', '')}»\n"
        f"Задач закрыто {done} из {len(tasks)}{tail}.\n"
        "Отчёт: report.md в каталоге прогона."
    )


def status_text(run) -> str:
    """Ответ на /status: где прогон, сколько закрыто, какая фаза, что мешает."""
    from . import phases

    st = run.state
    current, blockers = phases.resolve(run)
    tasks = st.get("tasks", [])
    done = sum(1 for t in tasks if t.get("status") == "done")
    live = sum(1 for t in tasks if t.get("status") in ("in-progress", "review", "repair"))
    bad = sum(1 for t in tasks if t.get("status") == "failed")
    lines = [
        f"«{st.get('title', '')}»",
        f"Фаза: {phases.PHASE_RU.get(current, current)}",
        f"Задачи: закрыто {done} из {len(tasks)}" + (f", идёт {live}" if live else "")
        + (f", провалено {bad}" if bad else ""),
    ]
    gates_st = st.get("gates", {})
    marks = [
        f"{g} {'✅' if gates_st[g].get('status') == 'passed' else '❌'}"
        for g in sorted(gates_st)
        if isinstance(gates_st.get(g), dict) and gates_st[g].get("status")
    ]
    if marks:
        lines.append("Гейты: " + ", ".join(marks))
    if st.get("finished_at"):
        lines.append(f"Прогон закрыт {st['finished_at'][:16].replace('T', ' ')}.")
    elif run.is_paused():
        lines.append(f"⏸ На паузе с {(run.paused_since() or '')[:19].replace('T', ' ')}.")
    if blockers:
        lines.append("Мешает:")
        lines += [f"· {b}" for b in blockers[:5]]
        if len(blockers) > 5:
            lines.append(f"… и ещё {len(blockers) - 5}")
    elif not st.get("finished_at"):
        lines.append("Ничего не мешает.")
    return "\n".join(lines)


# --- прогоны как чаты ---

# Кнопки снизу. Телеграм шлёт нажатие обычным сообщением — без косой черты,
# ровно тем текстом, что написан на кнопке. Поэтому подписи и разбор команд
# держатся одной таблицей: разъедутся — клавиатура станет декоративной.
BTN_LIST = "Список"
BTN_NEXT = "Следующий"
BTN_PAUSE = "Пауза"
BTN_HELP = "Помощь"

BUTTONS = {
    BTN_LIST.lower(): "list",
    BTN_NEXT.lower(): "next",
    BTN_PAUSE.lower(): "pause",
    BTN_HELP.lower(): "help",
}

# Ключи в конфигурации: очередь и открытый прогон переживают перезапуск панели,
# иначе «следующий» после перезапуска начинал бы круг заново.
OPEN_KEY = "open_run"
QUEUE_KEY = "queue_seen"
# Последнее отправленное сообщение со списком: чат, идентификатор и текст. Живёт
# там же, потому что правится оно тиком опроса — то есть уже другим кругом, а то
# и другим запуском панели, чем тот, что его отправил.
LIST_KEY = "list_message"

EMPTY_QUEUE = (
    "Никто не ждёт вашего ответа — очередь пуста.\n"
    f"Что где стоит — «{BTN_LIST}»."
)

NO_RUNS = "Прогонов в этом проекте ещё нет. Заведите: chekan init «что нужно построить»"


def keyboard() -> str:
    """`ReplyKeyboardMarkup` для Bot API: команды лежат кнопками снизу."""
    return json.dumps(
        {
            "keyboard": [[BTN_LIST, BTN_NEXT], [BTN_PAUSE, BTN_HELP]],
            "resize_keyboard": True,
        },
        ensure_ascii=False,
    )


# --- реестр проектов и владение опросом (R18) ---


def register_project(runs_root: Path | str) -> list[str]:
    """Зарегистрировать каталог прогонов в конфигурации связи (R18)."""
    path = Path(runs_root).resolve()
    path_str = str(path)
    with _CFG_LOCK:
        cfg = load()
        projs = [p for p in (cfg.get("projects") or []) if Path(p).is_dir() or Path(p).parent.is_dir()]
        if path_str not in projs:
            projs.append(path_str)
        cfg["projects"] = projs
        save(cfg)
    return projs


def unregister_project(runs_root: Path | str) -> list[str]:
    """Убрать каталог прогонов из конфигурации связи."""
    path_str = str(Path(runs_root).resolve())
    with _CFG_LOCK:
        cfg = load()
        projs = [p for p in (cfg.get("projects") or []) if p != path_str and (Path(p).is_dir() or Path(p).parent.is_dir())]
        cfg["projects"] = projs
        save(cfg)
    return projs


def registered_projects(fallback: Path | str | None = None) -> list[Path]:
    """Все зарегистрированные каталоги .chekan. Если пусто — fallback."""
    cfg = load()
    projs = [Path(p) for p in (cfg.get("projects") or []) if Path(p).is_dir() or Path(p).parent.is_dir()]
    if fallback is not None:
        fb = Path(fallback).resolve()
        if (fb.is_dir() or fb.parent.is_dir()) and fb not in projs:
            projs.append(fb)
    return projs


def poller_pid_path() -> Path:
    return config_path().parent / "poller.pid"


def _pid_alive(pid: int) -> bool:
    """Проверка существования процесса в ОС с корректной обработкой PermissionError."""
    if pid <= 0:
        return False
    if pid == os.getpid():
        return True
    try:
        os.kill(pid, 0)
        return True
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    except (ValueError, OSError):
        return False


def is_poller_alive() -> tuple[bool, int | None]:
    """Проверить, жив ли процесс, владеющий опросом Телеграма (R18)."""
    path = poller_pid_path()
    if not path.is_file():
        return False, None
    try:
        pid = int(path.read_text(encoding="utf-8").strip())
        if _pid_alive(pid):
            return True, pid
        return False, None
    except (ValueError, OSError):
        return False, None


def claim_poller() -> bool:
    """Попытаться стать владельцем опроса Телеграма."""
    with _CFG_LOCK:
        alive, pid = is_poller_alive()
        if alive and pid != os.getpid():
            return False
        path = poller_pid_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(f"{os.getpid()}\n", encoding="utf-8")
        return True


def release_poller() -> None:
    """Освободить владение опросом при завершении процесса."""
    try:
        path = poller_pid_path()
        if path.is_file():
            pid = int(path.read_text(encoding="utf-8").strip())
            if pid == os.getpid():
                path.unlink(missing_ok=True)
    except Exception:
        pass


def all_runs(runs_root: Path | None = None) -> list:
    """Все прогоны из всех зарегистрированных проектов (R18).

    Объекты прогонов создаются заново на каждый вызов и ничего не помнят между
    ними: состояние читается с диска в момент запроса, поэтому правка `state.json`
    видна следующим же показом, без перезапуска панели.
    """
    from .store import Run

    roots = registered_projects(runs_root)
    found = []
    seen = set()
    for base in roots:
        root = base.resolve().parent
        for path in sorted(base.glob("*/state.json")):
            key = (str(root), path.parent.name)
            if key in seen:
                continue
            seen.add(key)
            run = Run(root, path.parent.name)
            try:
                run.state  # noqa: B018 — битое состояние в список не пускаем
            except Exception:
                continue
            found.append(run)
    found.sort(key=lambda r: (r.state.get("started_at") or "", r.slug))
    return found


def waiting_question(run) -> dict | None:
    """Переходник к механизму вопросов: ждущий вопрос прогона, прочитанный с диска.

    Сам механизм (`chekan ask`, запись вопроса, признак ожидания) живёт в `ceh/ask.py`
    и в состоянии прогона. Здесь нужно ровно две вещи — ждёт ли прогон ответа и
    какой у него текст вопроса, — и обе берутся из `state.json`, а не из памяти
    процесса. Доставка вопроса владельцу цепляется к этой же функции.
    """
    st = run.state
    qid = st.get("waiting_for")
    if not isinstance(qid, str) or not qid:
        return None
    for q in st.get("questions") or []:
        if isinstance(q, dict) and q.get("id") == qid:
            return None if q.get("status") == "answered" else q
    return None


def waiting_runs(runs_root: Path | None = None) -> list:
    """Прогоны, ждущие ОТВЕТА владельца. Ручная пауза сюда не входит."""
    return [r for r in all_runs(runs_root) if waiting_question(r) is not None]


def waiting_count(runs_root: Path | None = None) -> int:
    return len(waiting_runs(runs_root))


def open_slug(cfg: dict | None = None) -> str | None:
    """Прогон, которому уйдёт обычный текст без команды. Не открыт — None."""
    cfg = load() if cfg is None else cfg
    slug = str(cfg.get(OPEN_KEY) or "").strip()
    return slug or None


def set_open(slug: str | None) -> None:
    update(**{OPEN_KEY: slug or None})


def run_line(run) -> str:
    """Строка прогона в списке: название, фаза, доля закрытых задач и чем занят."""
    from . import phases

    st = run.state
    tasks = st.get("tasks", []) or []
    done = sum(1 for t in tasks if t.get("status") == "done")
    phase = phases.PHASE_RU.get(st.get("phase", ""), st.get("phase", ""))
    line = f"· «{st.get('title', '')}» — {phase}, закрыто {done} из {len(tasks)}"
    q = waiting_question(run)
    if q is not None:
        line += f" · ⏳ ждёт ответа ({q.get('id', '')})"
    elif not st.get("finished_at") and run.is_paused():
        line += " · ⏸ на паузе"
    return line


def runs_text(runs_root: Path | None = None) -> str:
    """Список прогонов: идущие отдельно, архив отдельно, счётчик ждущих внизу (R18)."""
    from .store import Run

    runs = all_runs(runs_root)
    if not runs:
        return NO_RUNS

    roots = registered_projects(runs_root)
    current_slugs = {
        (str(base.resolve().parent), Run.active_slug(base.resolve().parent))
        for base in roots
        if Run.active_slug(base.resolve().parent)
    }

    live, archive = [], []
    for run in runs:
        key = (str(run.root), run.slug)
        if not run.state.get("finished_at") and key in current_slugs:
            live.append(run)
        else:
            archive.append(run)

    lines = ["Прогоны проекта", "", "Идут:"]
    lines += [run_line(r) for r in live] or ["· пусто"]
    if archive:
        lines += ["", "Архив:"]
        for run in archive:
            tail = " · завершён" if run.state.get("finished_at") else " · брошен"
            lines.append(run_line(run) + tail)
    n = sum(1 for r in runs if waiting_question(r) is not None)
    lines.append("")
    lines.append(
        f"Ждут ответа: {n} — открыть очередной: «{BTN_NEXT}»"
        if n
        else "Ждущих ответа нет."
    )
    return "\n".join(lines)


def next_text(runs_root: Path | None = None) -> str:
    """«Следующий»: открыть очередной ждущий прогон и сделать его адресатом ответа."""
    runs = waiting_runs(runs_root)
    slugs = [r.slug for r in runs]
    seen = [s for s in (load().get(QUEUE_KEY) or []) if s in slugs]
    if not runs:
        update(**{QUEUE_KEY: []})
        return EMPTY_QUEUE

    run = next((r for r in runs if r.slug not in seen), None)
    if run is None:
        update(**{QUEUE_KEY: []})
        return (
            f"Все ждущие прогоны вы уже открывали — их {len(runs)}.\n"
            "Ответьте на открытый: обычный текст уходит в него.\n"
            f"Нажмёте «{BTN_NEXT}» ещё раз — пойду по кругу заново."
        )

    update(**{QUEUE_KEY: seen + [run.slug], OPEN_KEY: run.slug})
    from . import phases

    st = run.state
    q = waiting_question(run) or {}
    tasks = st.get("tasks", []) or []
    done = sum(1 for t in tasks if t.get("status") == "done")
    phase = phases.PHASE_RU.get(st.get("phase", ""), st.get("phase", ""))
    return (
        f"«{st.get('title', '')}» — {phase}, закрыто {done} из {len(tasks)}\n"
        f"Вопрос {q.get('id', '')}: {q.get('text', '')}\n\n"
        "Ответ пишите сюда обычным текстом — он уйдёт в этот прогон."
    )


# --- доставка вопроса и самообновление списка ---


def question_text(run, q: dict, runs_root: Path | None = None) -> str:
    """Сообщение о вопросе: текст вопроса, имя прогона и счётчик (R05.2, R09)."""
    return (
        f"❓ «{run.state.get('title', '')}» ждёт вашего ответа\n"
        f"{q.get('id', '')}: {q.get('text', '')}\n\n"
        f"Ждут ответа: {waiting_count(runs_root)}\n"
        f"Открыть для ответа — «{BTN_NEXT}», дальше пишите ответ обычным текстом."
    )


def deliver_questions(runs_root: Path | None = None) -> int:
    """Разослать владельцу вопросы по всем проектам, которые ещё не уходили (R18)."""
    from . import ask as ask_mod

    cfg = load()
    target = owner(cfg)
    if not cfg.get("token") or target is None:
        return 0
    sent = 0
    for run in all_runs(runs_root):
        for q in ask_mod.undelivered(run):
            if post(question_text(run, q, runs_root), chat_id=target, markup=keyboard()) is None:
                continue
            ask_mod.mark_delivered(run, q["id"], to=target)
            sent += 1
    return sent


def remember_list(chat_id: int | str, message_id: int | None, text: str) -> None:
    """Запомнить отправленный список, чтобы дальше его править, а не слать заново."""
    if message_id is None:
        return
    update(**{LIST_KEY: {"chat_id": str(chat_id), "message_id": message_id, "text": text}})


def refresh_list(runs_root: Path | None = None) -> bool:
    """Поправить уже отправленный список, если состояние на диске изменилось (R11, R18)."""
    cfg = load()
    saved = cfg.get(LIST_KEY)
    token = cfg.get("token")
    if not token or not isinstance(saved, dict) or not saved.get("message_id"):
        return False
    fresh = runs_text(runs_root)
    if fresh == saved.get("text"):
        return False
    try:
        call(
            token,
            "editMessageText",
            {
                "chat_id": saved.get("chat_id"),
                "message_id": saved["message_id"],
                "text": fresh,
            },
            timeout=10,
        )
    except TelegramError:
        update(**{LIST_KEY: None})
        return False
    update(**{LIST_KEY: {**saved, "text": fresh}})
    return True


def tick(runs_root: Path | None = None) -> dict:
    """Круг самостоятельной работы: раздать вопросы по всем проектам, поправить список."""
    with _TICK_LOCK:
        return {"delivered": deliver_questions(runs_root), "edited": refresh_list(runs_root)}


HELP = (
    "Чекан на связи.\n\n"
    f"Команды — кнопками снизу:\n"
    f"«{BTN_LIST}» — прогоны: идущие, архив, сколько ждёт ответа\n"
    f"«{BTN_NEXT}» — открыть очередной ждущий прогон\n"
    f"«{BTN_PAUSE}» — остановить: новое в работу не берётся\n"
    f"«{BTN_HELP}» — это сообщение\n\n"
    f"Обычный текст без кнопки — ответ открытому прогону. Открыть — «{BTN_NEXT}».\n\n"
    "Отвечаю, пока работает панель прогона. Погасите её (`chekan dash --stop`) — "
    "замолчу до следующего запуска."
)

NO_RUN = "Активного прогона нет. Заведите: chekan init «что нужно построить»"


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


def parse_command(text: str) -> str | None:
    """Текст кнопки → имя команды. Не кнопка — None (R16)."""
    text = (text or "").strip()
    if not text:
        return None
    if text.lower().startswith("/start"):
        return "start"
    return BUTTONS.get(text.lower())


NO_OPEN_RUN = (
    "Открытого прогона нет — этот текст записывать некуда.\n"
    f"Откройте «{BTN_NEXT}»: покажу очередной ждущий вопрос, и следующий текст "
    "уйдёт в него.\n"
    f"Что где стоит — «{BTN_LIST}»."
)

WAIT_FOR_COMMAND = f"Жду команду — нажмите кнопку снизу. «{BTN_HELP}» — что я умею."


def run_by_slug(runs_root: Path | None, slug: str | None):
    """Прогон по имени каталога среди всех проектов (R18)."""
    if not slug:
        return None
    for run in all_runs(runs_root):
        if run.slug == slug:
            return run
    return None


def answer_text(text: str, runs_root: Path | None = None) -> str:
    """Обычный текст без команды — ответ на вопрос ОТКРЫТОГО прогона (R06.4)."""
    text = (text or "").strip()
    if not text:
        return WAIT_FOR_COMMAND
    run = run_by_slug(runs_root, open_slug())
    if run is None:
        return NO_OPEN_RUN
    title = run.state.get("title", "")
    q = waiting_question(run)
    if q is None:
        return (
            f"«{title}» больше не ждёт ответа — записывать некуда.\n"
            f"Очередной ждущий — «{BTN_NEXT}»."
        )
    from . import ask as ask_mod
    from .store import StateError

    try:
        ask_mod.answer(run, q["id"], text, by="telegram")
    except StateError as exc:
        return f"Ответ не записан: {exc}"
    return f"Ответ записан в «{title}» ({q['id']}): {text}\nПрогон продолжается."


def active_run(runs_root: Path | None = None):
    """Текущий активный прогон."""
    from .store import Run

    if runs_root is not None:
        root = Path(runs_root).resolve().parent
        slug = Run.active_slug(root)
        if slug:
            r = run_by_slug(runs_root, slug)
            if r:
                return r
    for base in registered_projects():
        root = base.resolve().parent
        slug = Run.active_slug(root)
        if slug:
            r = run_by_slug(base, slug)
            if r:
                return r
    return None


def handle_command(cmd: str, runs_root: Path | None = None) -> str:
    """Ответ на команду с кнопки."""
    if cmd in ("help", "start"):
        return HELP
    if cmd in ("list", "runs"):
        return runs_text(runs_root)
    if cmd == "next":
        return next_text(runs_root)
    run = active_run(runs_root)
    if run is None:
        return NO_RUN
    if cmd == "status":
        return status_text(run)
    if cmd == "pause":
        if not run.pause():
            return f"Прогон уже на паузе с {(run.paused_since() or '')[:19].replace('T', ' ')}."
        return pause_text(run, True)
    if cmd == "resume":
        qid = run.waiting_for()
        if qid:
            return (
                f"Прогон ждёт ответа на {qid} — это не ручная пауза.\n"
                f"Снимет её сам ответ: откройте «{BTN_NEXT}» и напишите его "
                "обычным текстом."
            )
        if not run.resume():
            return "Прогон и так не на паузе."
        return pause_text(run, False)
    return "Не знаю такой команды.\n\n" + HELP


def handle_update(event: dict, runs_root: Path | None = None) -> tuple[int | None, str | None]:
    """Что ответить на одно обновление и кому. Молчание — (chat, None)."""
    message = event.get("message") or event.get("edited_message") or {}
    chat = (message.get("chat") or {}).get("id")
    text = message.get("text") or ""
    if chat is None:
        return None, None

    cfg = load()
    known = owner(cfg)
    cmd = parse_command(text)

    if known is None:
        if cmd != "start":
            return chat, None
        from .store import now

        update(chat_id=chat, last_seen=now())
        return chat, (
            "Связь установлена. Теперь я присылаю сюда, что происходит с прогоном.\n\n" + HELP
        )

    if str(known) != str(chat):
        return chat, None

    from .store import now

    update(last_seen=now())
    if cmd is None:
        return chat, answer_text(text, runs_root)
    return chat, handle_command(cmd, runs_root)


def poll_once(runs_root: Path | None = None, timeout: int = 0) -> int:
    """Забрать порцию обновлений и ответить. Возвращает, сколько обработано."""
    cfg = load()
    token = cfg.get("token")
    if not token:
        return 0
    params = {"timeout": timeout, "offset": cfg.get("offset") or 0}
    updates = call(token, "getUpdates", params, timeout=timeout + 15)
    if not isinstance(updates, list):
        return 0
    handled = 0
    last_id = None
    for event in updates:
        if not isinstance(event, dict):
            continue
        last_id = event.get("update_id", last_id)
        chat, reply = handle_update(event, runs_root)
        if chat is not None and reply:
            message = event.get("message") or event.get("edited_message") or {}
            listing = parse_command(message.get("text") or "") in ("list", "runs")
            result = post(reply, chat_id=chat, markup=None if listing else keyboard()) or {}
            if listing:
                remember_list(chat, result.get("message_id"), reply)
        handled += 1
    if last_id is not None:
        update(offset=last_id + 1)
    return handled


def _pause(stop: threading.Event | None, seconds: float) -> bool:
    """Подождать, но проснуться сразу, как велели остановиться. True — остановка."""
    if stop is None:
        time.sleep(seconds)
        return False
    return stop.wait(seconds)


def tick_forever(
    runs_root: Path, stop: threading.Event | None = None, interval: float = TICK_SECONDS
) -> None:
    """Круг доставки наружу: доставляет вопросы по всем зарегистрированным проектам."""
    root = Path(runs_root)
    while stop is None or not stop.is_set():
        pause = interval
        try:
            alive, pid = is_poller_alive()
            # Доставку ведёт владелец опроса
            if pid == os.getpid() or not alive:
                if load().get("token"):
                    tick(root)
                else:
                    pause = 3
            else:
                pause = 1.0
        except Exception:
            pause = 5
        if _pause(stop, pause):
            return


def poll_forever(runs_root: Path, stop: threading.Event | None = None) -> None:
    """Цикл опроса для сервера панели с надзором за лидером (R18)."""
    root = Path(runs_root)
    register_project(root)
    while stop is None or not stop.is_set():
        if claim_poller():
            try:
                if not load().get("token"):
                    if _pause(stop, 3):
                        return
                    continue
                tick(root)
                poll_once(root, timeout=POLL_TIMEOUT)
            except TelegramError:
                if _pause(stop, 5):
                    return
            except Exception:
                if _pause(stop, 5):
                    return
        else:
            # Опрос ведёт другой процесс, мы не воруем getUpdates
            if _pause(stop, 2):
                return


def start_poller(runs_root: Path, stop: threading.Event | None = None) -> list[threading.Thread]:
    """Два круга: наружу (tick) и внутрь (poll_forever с надзором за лидером)."""
    root = Path(runs_root)
    register_project(root)
    threads = [
        threading.Thread(
            target=tick_forever, args=(root, stop), name="ceh-telegram-out", daemon=True
        ),
        threading.Thread(
            target=poll_forever, args=(root, stop), name="ceh-telegram", daemon=True
        ),
    ]
    for thread in threads:
        thread.start()
    return threads
