"""Чекан — CLI автопилота доставки.

Все переходы состояния идут через этот CLI. Файлы в `.chekan/` руками не правятся:
одна битая запятая в состоянии дороже, чем любая сэкономленная команда.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from . import __version__, checkpoint, detach, gates, phases, proof, runner, zonelock
from .store import MODES, TIERS, Run, StateError, find_root


def _(text: str) -> str:
    try:
        from .i18n import translate
        return translate(text)
    except Exception:
        return text


def out(*lines: str) -> None:
    print("\n".join(lines))


def fail(*lines: str) -> int:
    print("\n".join(lines), file=sys.stderr)
    return 1


# --- команды ---


def cmd_init(args) -> int:
    root = find_root()
    existing = Run.active_slug(root)
    if existing and not args.force:
        return fail(
            f"в {root} уже идёт прогон «{existing}».",
            "Закройте его (`chekan report`) или начните поверх: `chekan init ... --force`",
        )
    tier = phases.SCALE_CODE.get(args.scale, args.scale)
    # Позиционный аргумент — бриф целиком. Имя прогона либо названо владельцем,
    # либо выводится из брифа: заголовком абзац больше не становится (R20.1).
    run = Run.create(root, args.title or args.brief, args.mode, tier, brief=args.brief)
    run.enter_phase("preflight")
    lines = [
        f"Прогон заведён: {run.dir.relative_to(root)}",
        f"Имя: {run.state['title']}",
        f"Режим {args.mode}, масштаб — {phases.scale_label(tier)}",
    ]
    if not args.no_dash:
        from . import dash

        try:
            url, _ = dash.serve(run)
            lines.append(f"Панель: {url}")
            if dash.open_in_browser(url):
                lines.append("Открыта в браузере.")
        except Exception as exc:  # панель не обязана ронять прогон
            lines.append(f"Панель не поднялась ({exc}) — прогон это не останавливает.")
    lines += ["", "Дальше: `chekan next`"]
    out(*lines)
    return 0


PAUSE_BANNER = "⏸  ПРОГОН НА ПАУЗЕ с {since}. Новое в работу не берётся. Снять: `chekan resume`"


def _notify(build) -> None:
    """Сообщить в Телеграм, если связь настроена.

    Текст собирается лямбдой, потому что модуль подтягивается лениво. Ни одна
    ошибка отсюда не выходит: уведомление о работе не смеет ронять саму работу.
    """
    try:
        from . import telegram

        telegram.notify(build(telegram))
    except Exception:
        pass


def cmd_next(args) -> int:
    run = Run.load()
    if run.is_paused():
        out(PAUSE_BANNER.format(since=run.paused_since()), "")
    out(phases.render(run))
    return 0


def cmd_pause(args) -> int:
    run = Run.load()
    if not run.pause():
        out(f"Прогон уже на паузе с {run.paused_since()}.")
        return 0
    _notify(lambda tg: tg.pause_text(run, True))
    out(
        f"Прогон на паузе с {run.paused_since()}.",
        "Уже запущенные исполнители доводят свои задачи до конца — оборванная",
        "на середине задача хуже свежей. Новые в работу не берутся.",
        "Продолжить: `chekan resume` или кнопкой на панели.",
    )
    return 0


def cmd_resume(args) -> int:
    from . import ask as ask_mod

    run = Run.load()
    pending = ask_mod.waiting(run)
    if pending is not None:
        return fail(
            f"прогон ждёт ответа на {pending['id']}: «{pending['text']}».",
            "  Это не ручная пауза — снять её значит продолжить без ответа.",
            f"  Ответить: chekan answer {pending['id']} \"<текст>\"",
        )
    if not run.resume():
        out("Прогон и так не на паузе.")
        return 0
    _notify(lambda tg: tg.pause_text(run, False))
    out("Прогон продолжается. Время простоя в длительности задач не попадёт.")
    return 0


def _refuse_if_paused(run) -> int | None:
    if not run.is_paused():
        return None
    from . import ask as ask_mod

    pending = ask_mod.waiting(run)
    if pending is not None:
        return fail(
            f"прогон ждёт ответа владельца на {pending['id']}: «{pending['text']}».",
            "  Новое в работу не берётся, пока ответа нет. Это не ручная пауза.",
            f"  Ответить: chekan answer {pending['id']} \"<текст>\"",
        )
    return fail(
        f"прогон на паузе с {run.paused_since()} — новое в работу не берётся.",
        "  Снять паузу: `chekan resume` или кнопкой на панели.",
    )


def cmd_ask(args) -> int:
    """Спросить владельца и ждать. Ответ печатается дословно, код возврата 0."""
    from . import ask as ask_mod

    run = Run.load()
    if run.state.get("mode") == "auto":
        return fail(*ask_mod.AUTO_REFUSAL)
    q = ask_mod.create(run, args.text, task=args.task or None)
    lines = [
        f"Вопрос {q['id']} записан: «{q['text']}»",
        "Прогон стоит, пока ответа нет: новое в работу не берётся.",
    ]
    if not ask_mod.panel_running(run):
        lines += [line.format(qid=q["id"]) for line in ask_mod.PANEL_SILENT]
    out(*lines)
    answered = ask_mod.wait_for_answer(run, q["id"])
    a = answered.get("answer") or {}
    out(
        "",
        f"Ответ на {q['id']} ({a.get('by', '?')}, {str(a.get('at', ''))[:19].replace('T', ' ')}):",
        a.get("text", ""),
    )
    return 0


def cmd_answer(args) -> int:
    """Второй канал ответа — и единственный, когда панель не запущена."""
    from . import ask as ask_mod

    run = Run.load()
    q = ask_mod.answer(run, args.id, args.text, by=args.by or "cli")
    a = q["answer"]
    out(f"Ответ на {q['id']} записан ({a['at'][:19].replace('T', ' ')}, {a['by']}).")
    if run.is_paused():
        out("Прогон остаётся на ручной паузе — её ставил владелец: `chekan resume`.")
    else:
        out("Ожидание снято, прогон продолжается.")
    return 0


def cmd_advance(args) -> int:
    run = Run.load()
    stop = _refuse_if_paused(run)
    if stop is not None:
        return stop
    nxt = phases.advance(run)
    if nxt is None:
        current, blockers = phases.resolve(run)
        if blockers:
            return fail(
                f"фаза «{phases.PHASE_RU[current]}» не закрыта:",
                *[f"  · {b}" for b in blockers],
            )
        out("Фаз больше нет. Закрывайте прогон: `chekan report`")
        return 0
    out(f"Фаза: {phases.PHASE_RU[nxt]}", "", phases.render(run))
    return 0


def cmd_status(args) -> int:
    run = Run.load()
    st = run.state
    if args.json:
        out(json.dumps(st, ensure_ascii=False, indent=2))
        return 0
    current, blockers = phases.resolve(run)
    phase_label = _(phases.PHASE_RU[current])
    scale_label = _(phases.SCALE_RU[st["tier"]])
    out(
        f"{st['title']}  [{st['slug']}]",
        f"{_('Фаза:')} {phase_label}   {_('Режим:')} {st['mode']}   {_('Масштаб:')} {scale_label}",
    )
    if st["requirements"]:
        by_status: dict[str, int] = {}
        for r in st["requirements"]:
            by_status[r["status"]] = by_status.get(r["status"], 0) + 1
        out(_("Требования:") + " " + ", ".join(f"{k} {v}" for k, v in sorted(by_status.items())))
    for t in st["tasks"]:
        mark = "✓" if t["status"] == "done" else "·"
        green = run.green_evidence(t["id"])
        proofmark = "" if t["status"] != "done" else (_(" ⟵ доказано") if green else _(" ⚠ БЕЗ ДОКАЗАТЕЛЬСТВА"))
        out(f"  {mark} {t['id']} {t['title']} ({t['status']}){proofmark}")
    if blockers:
        out("", _("Блокирует:"))
        for b in blockers:
            out(f"  · {_(b)}")
    return 0


def cmd_req(args) -> int:
    run = Run.load()
    if args.action != "list" and not args.id:
        return fail(
            f"команде `req {args.action}` нужен идентификатор требования.",
            f"Например: chekan req {args.action} R01 …",
        )
    if args.action == "add":
        run.add_requirement(args.id, args.quote, args.note or "")
        out(f"{args.id} добавлено")
    elif args.action == "status":
        run.set_requirement_status(args.id, args.value)
        out(f"{args.id} → {args.value}")
    elif args.action == "drop":
        if not args.quote:
            return fail("снять требование можно только с цитатой решения владельца: --quote")
        req = run.requirement(args.id)
        if req is None:
            return fail(f"нет требования {args.id}")
        st = run.state
        target = next(r for r in st["requirements"] if r["id"] == args.id)
        target["note"] = (target.get("note", "") + " | снято владельцем: " + args.quote).strip(" |")
        run.save(st)
        run.set_requirement_status(args.id, "dropped", by_user=True)
        out(f"{args.id} снято по решению владельца")
    else:
        for r in run.state["requirements"]:
            tasks = ", ".join(r.get("tasks", [])) or "—"
            out(f"{r['id']}  [{r['status']}]  таски: {tasks}")
            out(f"      «{r['quote']}»")
    return 0


def cmd_task(args) -> int:
    run = Run.load()
    if args.action == "add":
        run.add_task(
            args.id,
            args.title,
            args.requirements or [],
            deps=args.deps or [],
            zone=args.zone or "",
            verify=args.verify or "",
        )
        out(f"таск {args.id} добавлен")
    elif args.action == "start":
        stop = _refuse_if_paused(run)
        if stop is not None:
            return stop
        held = zonelock.holder(run.state, args.id)
        if held is not None:
            return fail(*zonelock.refusal(args.id, held[0], held[1]))
        if args.model:
            run.set_task_model(args.id, args.model)
        run.set_task_status(args.id, "in-progress")
        who = f", исполнитель {args.model}" if args.model else ""
        out(f"задача {args.id} взята в работу{who}")
    elif args.action == "done":
        tokens = getattr(args, "tokens", None)
        if tokens is not None and tokens < 0:
            return fail("расход токенов не бывает отрицательным: это сумма входа и выхода")
        if getattr(args, "model", None):
            run.set_task_model(args.id, args.model)
        # Отрыв ещё не доиграл — исход неизвестен, и закрывать задачу нечем:
        # зелёным её сделает как раз та запись, которой пока нет.
        waiting = detach.pending(run, args.id)
        if waiting:
            return fail(*detach.refusal(run, waiting))
        ok, messages = proof.close_task(run, args.id)
        if ok:
            spend = run.record_spend(args.id, tokens=tokens)
            messages.append(
                f"расход: {spend['model'] or 'модель не названа'} · "
                + (
                    f"токены {spend['tokens']} (со слов исполнителя)"
                    if spend["tokens"] is not None
                    else "токены не измерены — их назвать некому"
                )
                + f" · время {spend['duration_ms']} мс и прогонов {spend['runs']} (измерено)"
            )
            _notify(lambda tg: tg.task_done_text(run, args.id))
        out(*messages)
        return 0 if ok else 1
    elif args.action == "fail":
        run.set_task_status(args.id, "failed")
        out(f"таск {args.id} → failed")
    elif args.action == "reset":
        t = run.task(args.id)
        if t is None:
            return fail(f"нет таска {args.id}")
        run.set_task_status(args.id, "pending")
        out(f"таск {args.id} сброшен в pending")
    elif args.action == "show":
        t = run.task(args.id)
        if t is None:
            return fail(f"нет таска {args.id}")
        green = run.green_evidence(args.id)
        out(
            f"{t['id']}  {t['title']}",
            f"  статус:      {t['status']}",
            f"  зона:        {t.get('zone') or '—'}",
            f"  после:       {', '.join(t.get('deps') or []) or '—'}",
            f"  требования:  {', '.join(t.get('requirements') or []) or '—'}",
            f"  проверка:    {t.get('verify') or '— не назначена, задайте `chekan task set …  --verify`'}",
            f"  исполнитель: {t.get('model') or '—'}   харнесс: {t.get('agent') or '—'}",
            f"  коммит:      {t.get('commit') or '—'}",
        )
        rows = run.evidence(args.id)
        out(f"  прогонов:    {len(rows)}" + (f", последний зелёный: `{green['cmd']}`" if green else ", зелёного нет"))
    elif args.action == "set":
        t = run.task(args.id)
        if t is None:
            return fail(f"нет таска {args.id}")
        st = run.state
        target = next(x for x in st["tasks"] if x["id"] == args.id)
        changed = []
        for field, value in (("verify", args.verify), ("zone", args.zone),
                             ("title", args.title), ("model", args.model)):
            if value:
                target[field] = value
                changed.append(field)
        if getattr(args, "tokens", None) is not None:
            if args.tokens < 0:
                return fail("расход токенов не бывает отрицательным: это сумма входа и выхода")
            spend = target.get("spend")
            if not isinstance(spend, dict):
                spend = {
                    "model": target.get("model") or None,
                    "duration_ms": 0,
                    "duration_source": "измерено",
                    "runs": 0,
                    "runs_source": "измерено",
                    "tokens": args.tokens,
                    "tokens_source": "со слов исполнителя",
                }
            else:
                spend["tokens"] = args.tokens
                spend["tokens_source"] = "со слов исполнителя"
            target["spend"] = spend
            changed.append("tokens")
        if args.deps is not None:
            # Пустой список — это «снять зависимости», а не «не менять»: явная
            # `--deps` без значений отличается от отсутствия флага (см. add_argument).
            for dep in args.deps:
                if not run.task(dep):
                    return fail(f"таск {dep} не существует — нечего указывать ему в зависимости")
            target["deps"] = list(args.deps)
            changed.append("deps")
        if not changed:
            return fail("нечего менять: укажите --verify, --zone, --title, --model, --tokens или --deps")
        run.save(st)
        out(f"таск {args.id}: обновлено — {', '.join(changed)}")
    else:
        for t in run.state["tasks"]:
            deps = ", ".join(t.get("deps", [])) or "—"
            out(f"{t['id']}  [{t['status']}]  зона {t['zone'] or '—'}  после: {deps}")
            out(f"      {t['title']}")
    return 0


def _age_phrase(seconds) -> str:
    s = int(seconds or 0)
    if s < 60:
        return f"{s} с"
    if s < 3600:
        return f"{s // 60} мин"
    return f"{s // 3600} ч"


def cmd_run(args) -> int:
    run = Run.load()
    # Точка снимается ДО запуска: смысл в том, чтобы уцелело дерево, с которым
    # проверка началась. Вырожденные случаи прогон не роняют — только говорят.
    out(checkpoint.capture(run.root, run.slug)["text"])

    if getattr(args, "detach", False):
        if (getattr(args, "lever", "") or "").strip():
            return fail(
                "рычаг порчи с --detach не сочетается: рычаг ломает код прямо сейчас, "
                "а отрыв проверяет его потом — покраснело бы не то, что портили.",
                "Прогоните проверку с рычагом обычным вызовом, без --detach.",
            )
        info = detach.start(
            run, args.command, task=args.task, kind=args.kind,
            model=args.model or None, reuse=not args.no_reuse,
        )
        out(
            f"$ {args.command}",
            f"→ ушло в отрыв {info['id']} (pid {info['pid']}) — управление возвращено, "
            "проверка доиграет сама",
            f"  журнал процесса: {info['log']}",
            "  ход и исход: `chekan board`",
        )
        return 0

    row = proof.run_command(
        run, args.command, task=args.task, kind=args.kind, model=args.model or None,
        reuse=not args.no_reuse,
    )
    status = "зелено" if row["exit"] == 0 else f"красно (код {row['exit']})"
    if row.get("reused"):
        out(
            f"$ {row['cmd']}",
            f"→ переиспользовано: {_age_phrase(row.get('reused_age_s'))} назад эта команда "
            "прогнана на этом же коде — зелено, заново не запускалась",
            "  Прогнать по-настоящему: тот же вызов с --no-reuse",
            "",
        )
    else:
        out(f"$ {row['cmd']}", f"→ {status}, {row['duration_ms']} мс", "")
    if row.get("out_tail"):
        out(row["out_tail"])
    if row.get("err_tail"):
        out("--- stderr ---", row["err_tail"])

    lever = (getattr(args, "lever", "") or "").strip()
    if row["exit"] != 0:
        if lever:
            out("", "Рычаг порчи не гонялся: сама проверка красная — ломать нечего.")
        return 1
    if not lever:
        return 0

    lrow = runner.run_lever(run, lever, row, task=args.task, model=args.model or None)
    verdict = (
        f"красно (код {lrow['exit']}) — проверка ловит порчу"
        if runner.is_red(lrow)
        else "зелено (код 0)"
    )
    out("", f"$ {lrow['cmd']}", f"→ рычаг порчи: {verdict}, {lrow['duration_ms']} мс", "")
    if lrow.get("out_tail"):
        out(lrow["out_tail"])
    if lrow.get("err_tail"):
        out("--- stderr ---", lrow["err_tail"])
    if not runner.is_red(lrow):
        out("")
        return fail(*runner.refusal_text(lrow))
    return 0


def cmd_gate(args) -> int:
    run = Run.load()
    if args.coverage:
        _attach(run, "G2", "coverage", args.coverage)
    if args.blind:
        _attach(run, "G4", "blind", args.blind)
    names = [args.name] if args.name else phases.applicable_gates(run)
    failed = False
    for name in names:
        problems = gates.check(run, name)
        gates.record(run, name, problems)
        _notify(lambda tg, n=name, p=problems: tg.gate_text(run, n, p))
        if problems:
            failed = True
            out(f"{name} ({_(gates.GATE_TITLE[name])}): {_('НЕ ПРОЙДЕН')}")
            for p in problems:
                out(f"  · {_(p)}")
        else:
            out(f"{name} ({_(gates.GATE_TITLE[name])}): {_('пройден')}")
    if not args.name:
        skipped = [g for g in gates.CHECKS if g not in names]
        for name in skipped:
            scale_name = _(phases.SCALE_RU[run.state['tier']])
            out(f"{name} ({_(gates.GATE_TITLE[name])}): {_('неприменим')} {_('при масштабе')} {scale_name}")
    return 1 if failed else 0


SHAPES = {"coverage": ("unresolved",), "blind": ("done", "missing")}


def _attach(run: Run, gate: str, key: str, path: str) -> None:
    src = Path(path)
    if not src.is_file():
        raise StateError(f"нет файла {src}")
    try:
        payload = json.loads(src.read_text(encoding="utf-8"))
    except ValueError as exc:
        raise StateError(f"{src} — не JSON ({exc})") from exc
    if not isinstance(payload, dict):
        raise StateError(
            f"{src}: ожидается объект с полями {', '.join(SHAPES[key])}, "
            f"а пришёл {type(payload).__name__}. Битая форма кирпичит прогон: "
            "команды перестают читать состояние."
        )
    for field in SHAPES[key]:
        value = payload.get(field, [])
        if not isinstance(value, list):
            raise StateError(f"{src}: поле «{field}» должно быть списком строк")
    st = run.state
    st.setdefault("gates", {}).setdefault(gate, {})[key] = payload
    run.save(st)


def cmd_why(args) -> int:
    """Почему стоим: претензии красных гейтов с адресом починки."""
    from . import why as why_mod

    run = Run.load()
    lines = why_mod.render(run)
    out(*lines)
    return 1 if why_mod.claims(run) else 0


def cmd_report(args) -> int:
    from .report import build

    from . import dash
    from .store import now

    run = Run.load()
    st = run.state
    if not st.get("finished_at"):
        st["finished_at"] = now()
        # Закрываем и текущую фазу: иначе прогон помечен завершённым, а последняя
        # фаза висит «идёт» — панель показывает два противоречащих факта разом.
        stage = st["stages"].get(st["phase"])
        if stage and stage["status"] == "active":
            stage["status"] = "done"
            stage["finished_at"] = st["finished_at"]
        run.save(st)
    text = build(run)
    dash.build_page(run)
    _notify(lambda tg: tg.finished_text(run))
    dash.stop(run, quiet=True)
    path = run.dir / "report.md"
    path.write_text(text, encoding="utf-8")
    out(text, "", f"— записано в {path.relative_to(run.root)}")
    return 0


def cmd_doctor(args) -> int:
    from .doctor import report

    return report(verbose=args.verbose)


def cmd_accept(args) -> int:
    from .accept import run_acceptance

    return run_acceptance(Run.load(), explicit=args.with_skill)


def cmd_board(args) -> int:
    from .board import render

    out(render(Run.load()))
    return 0


def cmd_agents(args) -> int:
    from . import agents

    root = Path(__file__).resolve().parent.parent
    if args.list:
        for a in agents.registry():
            mark = "✓" if a.present else "·"
            out(f"  {mark} {a.name:24} {a.home}")
        return 0
    return agents.apply(root, dry=not args.apply, remove=args.remove)


def cmd_handoff(args) -> int:
    from .handoff import build

    out(build(Run.load(), full=args.full))
    return 0


def cmd_manifest(args) -> int:
    from . import manifest

    run = Run.load()
    path = run.dir / "manifest.md"
    before = path.read_text(encoding="utf-8") if path.is_file() else None
    text = manifest.render(run.state)
    path.write_text(text, encoding="utf-8")
    if before is None:
        out(f"{path.relative_to(run.root)} создан")
    elif before != text:
        out(f"{path.relative_to(run.root)} пересобран — он отставал от состояния")
    else:
        out(f"{path.relative_to(run.root)} уже совпадал с состоянием")
    return 0


def cmd_dash(args) -> int:
    from . import dash

    run = Run.load()
    if args.off:
        dash.stop(run, quiet=True)
        path = dash.disable()
        out("Панель выключена: подъём запрещён.",
            f"Включить обратно: `chekan dash --on` (настройка в {path}).")
        return 0
    if args.on:
        dash.enable()
        out("Панель включена: `chekan dash` снова её поднимет.")
        return 0
    if args.stop:
        if not dash.stop(run):
            out("Панель не запущена.")
        return 0
    try:
        url, started = dash.serve(run)
    except (dash.PanelOff, dash.PortBusy) as exc:
        out(str(exc))
        return 1
    out(f"Панель: {url}", "поднята" if started else "уже работала")
    if not args.no_open and dash.open_in_browser(url):
        out("Открыта в браузере.")
    return 0


def cmd_sync(args) -> int:
    from .sync import harvest, synchronise

    if args.harvest:
        return harvest(dry_run=not args.apply)
    return synchronise(dry_run=not args.apply)


def cmd_brief(args) -> int:
    from . import brief as brief_mod

    run = Run.load()
    try:
        path = brief_mod.write(run, args.task)
    except StateError as exc:
        return fail(str(exc), "Список задач: `chekan task list`")
    out(
        f"Бриф собран: {path.relative_to(run.root)}",
        "Отдайте исполнителю путь к нему, а не текст: спеку, интерфейсы и память",
        "проекта он больше открывать не должен.",
    )
    return 0


def cmd_skills(args) -> int:
    from . import skills as skills_mod

    out(f"Скиллы машины ({skills_mod.CANON}):", "")
    out(*skills_mod.render())
    out("", "Подходящие задаче скиллы дописывает `chekan brief NN`.")
    return 0


def cmd_prompt(args) -> int:
    run = Run.load()
    kind = args.kind.lower()
    brief_file = run.dir / "brief.md"
    brief_text = (
        brief_file.read_text(encoding="utf-8").strip()
        if brief_file.is_file()
        else run.state.get("title", "")
    )
    if kind in ("g2", "reader"):
        spec_file = run.dir / "spec.md"
        spec_text = (
            spec_file.read_text(encoding="utf-8").strip()
            if spec_file.is_file()
            else ""
        )
        out(
            f'Ты — независимый читатель спецификации Чекана. Твоя задача — объективно сопоставить бриф и спецификацию.\n\n'
            f'Тебе даны ТОЛЬКО два документа:\n'
            f'1. brief.md:\n«{brief_text}»\n\n'
            f'2. spec.md:\n«{spec_text}»\n\n'
            f'Ответь, покрывает ли спека бриф.\n'
            f'Верни строго JSON:\n'
            f'```json\n{{\n  "unresolved": [],\n  "notes": ["описание соответствия спеки брифу"]\n}}\n```'
        )
        return 0
    elif kind in ("g4", "blind"):
        out(
            f'Ты — слепой приёмщик Чекана. Твоя задача — объективно проверить реализацию задачи по брифу и репозиторию {run.root}.\n\n'
            f'Тебе дан ТОЛЬКО бриф:\n«{brief_text}»\n\n'
            f'Проверь код и тесты в {run.root}.\n'
            f'Верни строго JSON в формате:\n'
            f'```json\n{{\n  "done": [\n    "строка описания того, что работает",\n    ...\n  ],\n  "missing": [\n    "строка описания того, что заказано, но не работает (если всё работает — пустой список [])"\n  ]\n}}\n```'
        )
        return 0
    return 1


# --- разбор аргументов ---


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="chekan", description="Чекан — автопилот доставки")
    p.add_argument("--version", action="version", version=f"Чекан {__version__}")
    sub = p.add_subparsers(dest="cmd", required=True)

    i = sub.add_parser("init", help="завести прогон")
    i.add_argument("brief", help="что нужно построить — своими словами, целиком")
    i.add_argument(
        "--title", default="",
        help="короткое имя прогона; не назвали — выведется из первого звена брифа",
    )
    i.add_argument("--mode", choices=MODES, default="normal")
    i.add_argument(
        "--scale", "--tier", dest="scale", default="малая",
        choices=[*phases.SCALE_RU.values(), *TIERS],
        help="масштаб работы: точечная, малая, средняя, крупная",
    )
    i.add_argument("--force", action="store_true")
    i.add_argument("--no-dash", action="store_true", help="не поднимать панель")
    i.set_defaults(func=cmd_init)

    n = sub.add_parser("next", help="где мы и что делать дальше")
    n.set_defaults(func=cmd_next)

    sub.add_parser("pause", help="остановить прогон: новое в работу не берётся").set_defaults(func=cmd_pause)
    sub.add_parser("resume", help="продолжить прогон после паузы").set_defaults(func=cmd_resume)

    ak = sub.add_parser("ask", help="спросить владельца и ждать ответа: прогон стоит")
    ak.add_argument("text", help="вопрос владельцу, дословно")
    ak.add_argument("--task", default="", help="задача, на которой упёрлись")
    ak.set_defaults(func=cmd_ask)

    an = sub.add_parser("answer", help="ответить на вопрос прогона")
    an.add_argument("id", help="идентификатор вопроса, например Q1")
    an.add_argument("text", help="ответ, дословно")
    an.add_argument("--by", default="cli", help="кто ответил")
    an.set_defaults(func=cmd_answer)

    a = sub.add_parser("advance", help="перейти к следующей фазе")
    a.set_defaults(func=cmd_advance)

    s = sub.add_parser("status", help="краткая сводка")
    s.add_argument("--json", action="store_true")
    s.set_defaults(func=cmd_status)

    r = sub.add_parser("req", help="требования")
    r.add_argument("action", choices=["add", "list", "status", "drop"])
    # id не нужен только для list; для остальных требуем его здесь, иначе ошибка
    # всплывала уже из валидации состояния и читалась как внутренняя поломка.
    r.add_argument("id", nargs="?")
    r.add_argument("--quote", default="")
    r.add_argument("--note", default="")
    r.add_argument("--value", choices=["open", "in-spec", "in-task", "done", "placeholder", "deferred"])
    r.set_defaults(func=cmd_req)

    t = sub.add_parser("task", help="таски")
    t.add_argument("action", choices=["add", "list", "show", "set", "start", "done", "fail", "reset"])
    t.add_argument("id", nargs="?")
    t.add_argument("--title", default="")
    t.add_argument("--requirements", nargs="*")
    t.add_argument("--deps", nargs="*")
    t.add_argument("--zone", default="")
    t.add_argument("--verify", default="", help="команда, которой доказывается эта задача")
    t.add_argument("--model", default="", help="какая модель делает задачу (из окружения не узнать)")
    t.add_argument(
        "--tokens",
        type=int,
        default=None,
        help="расход токенов за задачу, вход плюс выход (со слов исполнителя: измерить их нечем)",
    )
    t.set_defaults(func=cmd_task)

    ru = sub.add_parser("run", help="прогнать команду и записать доказательство")
    ru.add_argument("command")
    ru.add_argument("--task")
    ru.add_argument("--kind", default="check")
    ru.add_argument("--model", default="", help="какая модель прогоняет")
    ru.add_argument(
        "--no-reuse",
        action="store_true",
        help="прогнать по-настоящему, даже если тот же код этой командой уже проверен",
    )
    ru.add_argument(
        "--detach",
        action="store_true",
        help="вернуть управление сразу: проверка доиграет отдельным процессом и "
        "допишет исход в журнал, даже если родителя убьют",
    )
    ru.add_argument(
        "--lever",
        default="",
        help="рычаг порчи: команда, которая ломает код и гоняет проверку. Обязана "
        "вернуть ненулевой код — иначе проверка не краснеет там, где должна",
    )
    ru.set_defaults(func=cmd_run)

    g = sub.add_parser("gate", help="проверить гейт")
    g.add_argument("name", nargs="?", choices=list(gates.CHECKS))
    g.add_argument("--coverage", help="JSON от независимого читателя (гейт G2)")
    g.add_argument("--blind", help="JSON от слепого приёмщика (гейт G4)")
    g.set_defaults(func=cmd_gate)

    ac = sub.add_parser("accept", help="приёмка профильным проверяльщиком")
    ac.add_argument("--with-skill", help="навязать конкретный скилл вместо автоопределения")
    ac.set_defaults(func=cmd_accept)

    wy = sub.add_parser("why", help="почему прогон стоит: что не сходится, чем чинится, кто чинит")
    wy.set_defaults(func=cmd_why)

    rp = sub.add_parser("report", help="итоговый отчёт")
    rp.set_defaults(func=cmd_report)

    d = sub.add_parser("doctor", help="что установлено на этой машине")
    d.add_argument("-v", "--verbose", action="store_true")
    d.set_defaults(func=cmd_doctor)

    bd = sub.add_parser("board", help="обзор прогона в терминале: прогресс, задачи, проверки")
    bd.set_defaults(func=cmd_board)

    ag = sub.add_parser("agents", help="разложить скилл и указатель по другим агентам")
    ag.add_argument("--list", action="store_true", help="только показать найденных агентов")
    ag.add_argument("--apply", action="store_true", help="без него — сухой прогон")
    ag.add_argument("--remove", action="store_true", help="убрать раскладку")
    ag.set_defaults(func=cmd_agents)

    ho = sub.add_parser(
        "handoff", help="передать прогон другому агенту: полный ввод в курс с нуля"
    )
    ho.add_argument("--full", action="store_true", help="плюс правила текущей фазы целиком")
    ho.set_defaults(func=cmd_handoff)

    mf = sub.add_parser("manifest", help="пересобрать manifest.md из состояния")
    mf.set_defaults(func=cmd_manifest)

    dsh = sub.add_parser("dash", help="панель прогона в браузере")
    dsh.add_argument("--stop", action="store_true", help="остановить сервер панели")
    dsh.add_argument("--off", action="store_true",
                     help="выключить панель совсем: погасить и запретить подъём")
    dsh.add_argument("--on", action="store_true", help="включить панель обратно")
    dsh.add_argument("--no-open", action="store_true", help="не открывать браузер")
    dsh.set_defaults(func=cmd_dash)

    sy = sub.add_parser("sync", help="синхронизировать канон и скиллы между машинами")
    sy.add_argument("--apply", action="store_true", help="без него — только показать дифф")
    sy.add_argument(
        "--harvest",
        action="store_true",
        help="обратное направление: собрать канон С ЭТОЙ машины в репозиторий",
    )
    sy.set_defaults(func=cmd_sync)

    br = sub.add_parser("brief", help="собрать бриф задачи одним файлом")
    br.add_argument("task", help="номер задачи, например 06")
    br.set_defaults(func=cmd_brief)

    sk = sub.add_parser("skills", help="скиллы машины: чем можно делать задачу")
    sk.set_defaults(func=cmd_skills)

    pr = sub.add_parser("prompt", help="генератор изолированных промптов для слепых субагентов")
    pr.add_argument("kind", choices=["g2", "g4", "reader", "blind"], help="тип промпта: g2 (читатель спеки) или g4 (слепой приёмщик)")
    pr.set_defaults(func=cmd_prompt)

    return p


# Команды, которые правят прогон. На закрытом прогоне они молча дописывали в
# состояние, и сданный владельцу отчёт устаревал без предупреждения.
MUTATING = {"req", "task", "run", "gate", "advance", "accept", "ask", "answer"}


def _guard_finished(args) -> int | None:
    if args.cmd not in MUTATING:
        return None
    try:
        run = Run.load()
    except StateError:
        return None
    finished = run.state.get("finished_at")
    if not finished:
        return None
    return fail(
        f"прогон закрыт {finished[:16].replace('T', ' ')} — правки в него уже не войдут "
        "в сданный отчёт.",
        "Новая работа — новый прогон: `chekan init \"<что нужно построить>\"`.",
    )


def main(argv: list[str] | None = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    # Без аргументов и с терминалом на входе — разговорный режим. Труба (панель,
    # хуки, чужие агенты) идёт прежним путём: ни режима, ни лишней строки.
    if not argv:
        from . import shell

        if shell.is_interactive():
            return shell.start()
    args = build_parser().parse_args(argv)
    try:
        blocked = _guard_finished(args)
        if blocked is not None:
            return blocked
        return args.func(args)
    except StateError as exc:
        return fail(f"Чекан: {exc}")
    except KeyboardInterrupt:
        return fail("прервано")
    except PermissionError as exc:
        return fail(
            f"Чекан: нет доступа к {exc.filename or 'файлу прогона'}.",
            "Проверьте права на каталог .chekan/ — состояние не тронуто.",
        )
    except IsADirectoryError as exc:
        return fail(
            f"Чекан: вместо файла {exc.filename or ''} лежит каталог.",
            "Уберите его — прогон не может писать состояние поверх каталога.",
        )
    except OSError as exc:
        return fail(f"Чекан: файловая ошибка — {exc}")
    except Exception as exc:  # никакой голый трейсбек не помогает пользователю
        return fail(
            f"Чекан: непредвиденная ошибка — {type(exc).__name__}: {exc}",
            "Состояние на диске цело. Проверьте его: `chekan doctor`.",
        )


if __name__ == "__main__":
    sys.exit(main())
