"""Сторож списка команд и кнопок на главной (R13, R14).

Заказ владельца дословно: «все команды должны быть продублированы на главной
странице в дашборде кратко и по делу», «Все команды должна быть возможность
исполнить в дашборде в формате кнопок, доп консоль в дашборде web не нужна».

Что здесь проверяется:

1. Список команд на главной сверяется с разбором аргументов CLI **в обе
   стороны**: пропала команда — красное, появилась лишняя — красное. Копии
   списка руками больше нет; до этой правки такая копия успела разойтись с
   правдой (в ней не было ни `why`, ни `prompt`).
2. Кнопка исполняет команду и показывает вывод; команда с аргументами несёт
   готовый набор аргументов.
3. Веб-консоли нет: ни поля на странице, ни приёма произвольной строки сервером.
4. `chekan run` с панели запускает только записанную команду проверки задачи —
   проверяется именно этим путём, а не отсутствием слова «push».
"""

from __future__ import annotations

import json
import re
import shutil
import subprocess
import sys
import threading
from functools import partial
from http.client import HTTPConnection
from http.server import ThreadingHTTPServer
from pathlib import Path

import pytest

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

from chekan import dash, serve_app  # noqa: E402
from chekan.__main__ import build_parser  # noqa: E402
from chekan.store import Run  # noqa: E402


def argparse_commands() -> set[str]:
    """Истина о командах: сам разбор аргументов, никаких копий."""
    import argparse

    for action in build_parser()._actions:
        if isinstance(action, argparse._SubParsersAction):
            return set(action.choices)
    raise AssertionError("у разбора аргументов нет подкоманд")


class Panel:
    def __init__(self, directory: Path):
        handler = partial(serve_app.Handler, directory=str(directory))
        self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
        self.port = self.httpd.server_address[1]
        threading.Thread(target=self.httpd.serve_forever, daemon=True).start()

    def close(self) -> None:
        self.httpd.shutdown()
        self.httpd.server_close()

    def _read(self, resp) -> tuple[int, dict | str]:
        raw = resp.read().decode("utf-8")
        try:
            return resp.status, json.loads(raw)
        except ValueError:
            return resp.status, raw

    def get(self, path: str) -> tuple[int, dict | str]:
        conn = HTTPConnection("127.0.0.1", self.port, timeout=30)
        conn.request("GET", path)
        out = self._read(conn.getresponse())
        conn.close()
        return out

    def post(self, path: str, body: dict | bytes) -> tuple[int, dict | str]:
        conn = HTTPConnection("127.0.0.1", self.port, timeout=60)
        data = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
        conn.request("POST", path, body=data, headers={"Content-Type": "application/json"})
        out = self._read(conn.getresponse())
        conn.close()
        return out


@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("R13", "команды продублированы кнопками")
    r.add_task("01", "задача 1", ["R13"], verify="echo проверка-задачи-01", zone="app.py")
    dash.build_index(repo / ".chekan")
    return r


@pytest.fixture
def panel(run: Run):
    srv = Panel(run.root / ".chekan")
    try:
        yield srv
    finally:
        srv.close()


def start(run: Run) -> None:
    """Взять задачу в работу: кнопка `run` появляется только у задачи в работе."""
    run.set_task_status("01", "in-progress")


def built_index(run: Run) -> str:
    return (run.root / ".chekan" / "index.html").read_text(encoding="utf-8")


# --- 1. список на главной против разбора аргументов, в обе стороны -----------


def test_catalog_matches_argparse_both_ways(panel: Panel):
    code, data = panel.get("/api/commands")
    assert code == 200
    listed = [c["name"] for c in data["commands"]]
    assert len(listed) == len(set(listed)), "команда названа дважды"
    real = argparse_commands()
    assert not (real - set(listed)), f"на главной нет команд: {sorted(real - set(listed))}"
    assert not (set(listed) - real), f"на главной лишние команды: {sorted(set(listed) - real)}"


def test_catalog_has_the_commands_the_handwritten_copy_lost(panel: Panel):
    """Именно на этих двух разошлась прежняя копия списка — сторож их видит."""
    _, data = panel.get("/api/commands")
    listed = {c["name"] for c in data["commands"]}
    assert {"why", "prompt"} <= listed


def test_no_handwritten_command_list_left_in_the_server():
    """Копии списка руками в сервере больше нет — иначе она снова разойдётся."""
    assert not hasattr(serve_app, "COMMANDS_HELP")
    source = Path(serve_app.__file__).read_text(encoding="utf-8")
    assert "from .shell import commands" in source


def test_help_is_one_short_line(panel: Panel):
    _, data = panel.get("/api/commands")
    for cmd in data["commands"]:
        text = cmd["help"]
        assert text, f"у команды {cmd['name']} нет пояснения"
        assert "\n" not in text, f"пояснение {cmd['name']} многострочное"
        assert len(text) <= 92, f"пояснение {cmd['name']} длиннее строки: {text}"


def test_page_has_no_command_list_of_its_own(run: Run):
    """Список рисуется из ответа сервера, а не набран в разметке."""
    html = built_index(run)
    assert 'fetch("/api/commands' in html
    assert "data-cmd=" not in html, "в разметке остался руками набранный список кнопок"
    for name in argparse_commands():
        assert f">chekan {name}<" not in html, f"кнопка {name} вбита в разметку"


@pytest.mark.skipif(shutil.which("node") is None, reason="нет node — движок JS не поднять")
def test_page_prints_every_command(panel: Panel, run: Run):
    """Настоящий рисовальщик страницы: каждая команда видна, лишней нет."""
    _, data = panel.get("/api/commands")
    block = re.search(r"<script>\s*(const SLUGS.*?)</script>", built_index(run), re.S)
    assert block, "не нашёл главный скрипт страницы"
    pure = block.group(1)
    program = pure[: pure.index("async function load(")] + """
const data = JSON.parse(require("fs").readFileSync(0, "utf8"));
process.stdout.write(cmdRows(data.commands));
"""
    proc = subprocess.run(["node", "-e", program], input=json.dumps(data),
                          capture_output=True, text=True, timeout=60)
    assert proc.returncode == 0, proc.stderr
    drawn = proc.stdout
    shown = set(re.findall(r"chekan ([a-z]+)", drawn))
    real = argparse_commands()
    assert not (real - shown), f"страница не нарисовала: {sorted(real - shown)}"
    assert not (shown - real), f"страница нарисовала лишнее: {sorted(shown - real)}"
    for cmd in data["commands"]:
        assert cmd["help"] in drawn or cmd["help"].replace('"', "&quot;") in drawn


# --- 2. кнопки исполняют команды --------------------------------------------


def test_every_command_has_a_button_or_a_named_reason(panel: Panel, run: Run):
    """Команда без кнопки объясняет себя словом, а не пропадает молча."""
    start(run)  # без задачи в работе `run` запускать нечего — см. проверку ниже
    _, data = panel.get("/api/commands")
    for cmd in data["commands"]:
        assert cmd["actions"] or cmd["note"], f"у {cmd['name']} ни кнопки, ни причины"
    without = {c["name"] for c in data["commands"] if not c["actions"]}
    assert without == set(serve_app.NEEDS_WORDS), (
        "кнопок нет только у команд, которым нужны слова владельца")


def test_button_runs_the_command_and_shows_output(panel: Panel):
    code, data = panel.post("/api/cli", {"action": "doctor"})
    assert code == 200
    assert data["ok"] is True and data["code"] == 0
    assert "git" in data["output"]
    assert data["args"] == ["doctor"]


def test_button_with_arguments_carries_them_ready(panel: Panel):
    """`task` и `req` без аргумента не работают — кнопка несёт готовый набор."""
    _, data = panel.get("/api/commands")
    by_name = {c["name"]: c for c in data["commands"]}
    assert by_name["task"]["actions"][0]["args"] == ["task", "list"]
    assert by_name["req"]["actions"][0]["args"] == ["req", "list"]

    code, out = panel.post("/api/cli", {"action": "task:list"})
    assert code == 200 and out["ok"] is True, out
    assert "задача 1" in out["output"]


def test_failing_button_returns_its_code(panel: Panel):
    """Красная команда показывается красной, а не проглатывается."""
    code, data = panel.post("/api/cli", {"action": "gate"})
    assert code == 200
    assert data["ok"] is False and data["code"] != 0, data
    assert "НЕ ПРОЙДЕН" in data["output"], data["output"]


# --- 3. веб-консоли нет -----------------------------------------------------


def test_no_free_text_field_on_the_page(run: Run):
    html = built_index(run)
    assert 'id="clicmd"' not in html, "поле произвольной команды осталось на странице"
    assert 'id="cliform"' not in html, "форма произвольной команды осталась на странице"
    section = html[html.index('id="clicmds"'):html.index('id="tg"')]
    assert "<input" not in section and "<textarea" not in section, section[:400]


@pytest.mark.parametrize("body", [
    {"command": "doctor"},
    {"command": "run \"rm -rf /\""},
    {"cmd": "doctor"},
    {"args": ["doctor"]},
    {"argv": ["doctor"]},
    {"action": "doctor", "args": ["run", "echo беда"]},
])
def test_free_form_request_is_rejected(panel: Panel, body: dict):
    """Рычаг порчи: приём произвольной строки снят — запрос отбивается."""
    code, data = panel.post("/api/cli", body)
    assert code == 400, data
    assert "error" in data


@pytest.mark.parametrize("action", ["", "   ", "doctor -v", "chekan doctor", "unknown", "run"])
def test_unknown_button_is_rejected(panel: Panel, action: str):
    code, data = panel.post("/api/cli", {"action": action})
    assert code == 400, data


def test_empty_and_broken_body_rejected(panel: Panel):
    assert panel.post("/api/cli", {})[0] == 400
    assert panel.post("/api/cli", b"not-a-json")[0] == 400
    assert panel.post("/api/cli", '"строка"'.encode("utf-8"))[0] == 400


def test_panel_is_local_only(monkeypatch, run: Run):
    """Кнопки и список принимаются только с этого компьютера."""
    handler = object.__new__(serve_app.Handler)
    handler.client_address = ("192.168.1.100", 12345)
    handler.directory = str(run.root / ".chekan")
    written: list = []
    monkeypatch.setattr(handler, "_json", lambda code, payload: written.append((code, payload)))

    handler.path = "/api/cli"
    handler.do_POST()
    assert written and written[0][0] == 403
    written.clear()
    handler.path = "/api/commands"
    handler.do_GET()
    assert written and written[0][0] == 403


# --- 4. `run` с панели: только записанная команда проверки -------------------


def test_run_button_appears_only_for_a_task_in_work(panel: Panel, run: Run):
    def run_actions():
        _, data = panel.get("/api/commands")
        return [c for c in data["commands"] if c["name"] == "run"][0]

    entry = run_actions()
    assert entry["actions"] == [], "задача ещё не в работе — запускать нечего"
    assert entry["note"], "у `run` без кнопок должна стоять причина"

    start(run)
    entry = run_actions()
    assert [a["args"] for a in entry["actions"]] == [
        ["run", "echo проверка-задачи-01", "--task", "01"]], entry


def test_run_button_executes_exactly_the_recorded_check(panel: Panel, run: Run):
    start(run)
    code, data = panel.post("/api/cli", {"action": "run:01"})
    assert code == 200, data
    assert data["args"] == ["run", "echo проверка-задачи-01", "--task", "01"]
    assert data["ok"] is True, data["output"]

    recorded = [json.loads(line) for line in
                run.evidence_path.read_text(encoding="utf-8").splitlines() if line.strip()]
    assert recorded, "прогон не записан доказательством"
    assert recorded[-1]["cmd"] == "echo проверка-задачи-01"


@pytest.mark.parametrize("action", [
    "run:01 && echo беда",
    "run:touch /tmp/chekan-lever",
    "run:02",
    "run::",
])
def test_run_with_a_foreign_command_is_refused(panel: Panel, run: Run, action: str):
    """Рычаг порчи: `chekan run` — полная оболочка, чужую строку она не получает."""
    start(run)
    code, data = panel.post("/api/cli", {"action": action})
    assert code == 400, data
    assert "error" in data


def test_no_shell_string_reaches_run_from_the_page(run: Run):
    """Страница не умеет составить строку для `run`: она шлёт только имя кнопки."""
    html = built_index(run)
    body = html[html.index("async function runAction"):html.index("$(\"clilist\").addEventListener")]
    assert "JSON.stringify({action: id})" in body
    assert "command" not in body and "prompt(" not in body


def test_recorded_check_is_the_only_source_of_the_run_string(run: Run):
    """Аргументы кнопки `run` собраны из состояния, а не откуда-то ещё."""
    start(run)
    actions = serve_app._run_actions(run.root / ".chekan")
    verify = [t for t in run.state["tasks"] if t["id"] == "01"][0]["verify"]
    assert [a["args"] for a in actions] == [["run", verify, "--task", "01"]]
