"""Постоянный адрес панели, занятый порт и два выключателя (R11, R12).

Владелец сказал дословно: «сделать так чтобы главная страница всегда могла
открываться по своей ссылке, но и была возможность ее отключить». Отсюда четыре
проверяемых правила:

1. Адрес постоянный — `http://localhost:8789`, одинаковый при каждом подъёме.
2. Порт занят — сказано кем, и панель не притворяется поднятой; код ненулевой.
   Своя панель другого проекта и чужой процесс — разные тексты.
3. Постоянный адрес отключается: ключ `fixed_port` в файле настроек и
   `CHEKAN_FIXED_PORT=0` поверх него. Выключено — случайный порт, как раньше.
4. Отключается и сама панель: `chekan dash --off` гасит её и запрещает подъём.

Тесты не трогают боевую панель владельца: свои серверы поднимаются на прогонах в
tmp и гасятся в finally, файл настроек подменяется через `CHEKAN_CONFIG`.
"""

from __future__ import annotations

import json
import os
import socket
import subprocess
import sys
import time
from pathlib import Path

import pytest

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

from chekan import dash  # noqa: E402
from chekan.store import Run  # noqa: E402


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


@pytest.fixture
def config(tmp_path, monkeypatch) -> Path:
    """Файл настроек в tmp: боевой `~/.config/chekan/config.json` неприкосновенен."""
    path = tmp_path / "config" / "config.json"
    monkeypatch.setenv("CHEKAN_CONFIG", str(path))
    monkeypatch.delenv(dash.PORT_ENV, raising=False)
    return path


def write_config(path: Path, **keys) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(keys, ensure_ascii=False), encoding="utf-8")


@pytest.fixture
def run(tmp_path) -> Run:
    repo = tmp_path / "repo"
    repo.mkdir()
    return Run.create(repo, "Прогон для проверки порта", "normal", "T1")


def free_port() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def port_of(url: str) -> int:
    return int(url.split("//", 1)[1].split("/", 1)[0].split(":")[1])


def occupy(port: int) -> socket.socket:
    """Занять порт чужим (не панельным) процессом — этим самым."""
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", port))
    s.listen(1)
    return s


def cli(cwd: Path, *args: str, env: dict | None = None) -> subprocess.CompletedProcess:
    full = dict(os.environ)
    full["PYTHONPATH"] = str(ROOT) + os.pathsep + full.get("PYTHONPATH", "")
    full.update(env or {})
    return subprocess.run([sys.executable, "-m", "chekan", *args], cwd=cwd,
                          capture_output=True, text=True, timeout=120, env=full)


# --- R11.1: адрес постоянный ---


def test_default_port_is_8789(config):
    """Настройки нет — постоянный адрес включён и это именно 8789.

    Номер сверяется числом, а не константой: подмена константы на соседний
    свободный порт — как раз то, чем ссылка перестаёт быть постоянной.
    """
    assert dash.fixed_port() == dash.FIXED_PORT == 8789


@pytest.fixture
def fixed(monkeypatch) -> int:
    """Тот же путь «настройки нет → постоянный порт», но на свободном номере.

    Боевой 8789 на машине владельца может быть занят соседним сервисом, а
    пропускать проверку постоянства адреса нельзя: именно её ломает рычаг порчи.
    Поэтому подменяется сама константа, а не способ её получить.
    """
    port = free_port()
    monkeypatch.setattr(dash, "FIXED_PORT", port)
    return port


def test_two_raises_give_the_same_address(config, run, fixed):
    """Два подъёма подряд — один адрес, и это адрес постоянного порта."""
    try:
        first, started = dash.serve(run)
        assert started
        second, again = dash.serve(run)
    finally:
        dash.stop(run, quiet=True)
    assert first == second
    assert first == f"http://localhost:{fixed}/{run.slug}/dashboard.html"
    assert not again  # второй раз сервер не поднимают заново


def test_address_survives_full_restart(config, run, fixed):
    """Панель погашена и поднята заново — ссылка та же, а не новая."""
    try:
        first, _ = dash.serve(run)
        dash.stop(run, quiet=True)
        dash._wait_free(fixed)
        second, started = dash.serve(run)
    finally:
        dash.stop(run, quiet=True)
    assert first == second
    assert port_of(first) == fixed
    assert started


# --- R12.1: постоянный адрес отключается ---


def test_config_zero_disables_fixed_address(config):
    write_config(config, fixed_port=0)
    assert dash.fixed_port() is None


def test_env_zero_beats_config(config, monkeypatch):
    """Переменная окружения бьёт файл: разовый запуск не правит настройки."""
    write_config(config, fixed_port=8789)
    monkeypatch.setenv(dash.PORT_ENV, "0")
    assert dash.fixed_port() is None


def test_env_beats_config_in_both_directions(config, monkeypatch):
    write_config(config, fixed_port=0)
    monkeypatch.setenv(dash.PORT_ENV, "8789")
    assert dash.fixed_port() == 8789


def test_disabled_means_random_port(config, run, monkeypatch):
    """Выключено — панель поднимается на случайном порту, как раньше."""
    monkeypatch.setenv(dash.PORT_ENV, "0")
    try:
        url, started = dash.serve(run)
    finally:
        dash.stop(run, quiet=True)
    assert started
    assert port_of(url) != dash.FIXED_PORT


def test_settings_neighbours_survive(config):
    """Рядом живёт выбор языка: запись выключателя не должна его стирать."""
    write_config(config, lang="en")
    dash.disable()
    data = json.loads(config.read_text(encoding="utf-8"))
    assert data["lang"] == "en" and data[dash.CONFIG_OFF] is True


# --- R11.2: порт занят — сказано кем ---


def test_foreign_process_on_port_refuses(config, run, monkeypatch):
    """Чужой процесс на порту: подъёма нет, в тексте порт и слово про чужого."""
    port = free_port()
    holder = occupy(port)
    monkeypatch.setenv(dash.PORT_ENV, str(port))
    try:
        with pytest.raises(dash.PortBusy) as err:
            dash.serve(run)
    finally:
        holder.close()
        dash.stop(run, quiet=True)
    text = str(err.value)
    assert str(port) in text
    assert "не панель Чекана" in text
    assert dash.PORT_ENV in text  # предложен выход


def test_panel_of_another_project_is_named(config, run, tmp_path, monkeypatch):
    """Своя же панель другого проекта: сказано, какой каталог она раздаёт."""
    other = tmp_path / "other" / ".chekan"
    other.mkdir(parents=True)
    port = free_port()
    env = dict(os.environ)
    env["PYTHONPATH"] = str(ROOT) + os.pathsep + env.get("PYTHONPATH", "")
    proc = subprocess.Popen([sys.executable, "-m", "chekan.serve_app", str(port), str(other)],
                            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)
    monkeypatch.setenv(dash.PORT_ENV, str(port))
    try:
        deadline = time.monotonic() + 10
        while not dash._listening(port) and time.monotonic() < deadline:
            time.sleep(0.05)
        assert dash._listening(port), "тестовая панель другого проекта не поднялась"
        with pytest.raises(dash.PortBusy) as err:
            dash.serve(run)
    finally:
        proc.terminate()
        proc.wait(timeout=10)
        dash.stop(run, quiet=True)
    text = str(err.value)
    assert str(other) in text  # какой каталог она раздаёт
    assert f"{dash.PORT_ENV}=0" in text  # предложен случайный порт


def test_busy_port_never_falls_back_silently(config, run, monkeypatch):
    """Панель не притворяется поднятой: сервера нет и pid-файла нет."""
    port = free_port()
    holder = occupy(port)
    monkeypatch.setenv(dash.PORT_ENV, str(port))
    try:
        with pytest.raises(dash.PortBusy):
            dash.serve(run)
        assert dash._all_ours(run) == []
        assert not (run.dir / dash.PIDFILE).is_file()
    finally:
        holder.close()
        # Сломанная проверка не должна оставлять после себя живой сервер: рычаг
        # порчи гоняется на этой же машине, где работает панель владельца.
        dash.stop(run, quiet=True)


def test_cli_returns_nonzero_on_busy_port(config, run, tmp_path):
    """Код возврата ненулевой, а не бодрое «панель поднята»."""
    port = free_port()
    holder = occupy(port)
    try:
        done = cli(run.root, "dash", "--no-open",
                   env={dash.PORT_ENV: str(port), "CHEKAN_CONFIG": str(config)})
    finally:
        holder.close()
        dash.stop(run, quiet=True)
    assert done.returncode != 0, done.stdout + done.stderr
    assert str(port) in done.stdout + done.stderr


# --- R12.2: отключается и сама панель ---


def test_off_forbids_serving(config, run):
    dash.disable()
    try:
        with pytest.raises(dash.PanelOff) as err:
            dash.serve(run)
        assert "--on" in str(err.value)  # сказано, чем включить
        assert dash._all_ours(run) == []
    finally:
        dash.stop(run, quiet=True)


def test_on_returns_the_panel(config, run, monkeypatch):
    monkeypatch.setenv(dash.PORT_ENV, "0")  # чужой 8789 не должен мешать проверке
    dash.disable()
    dash.enable()
    try:
        url, started = dash.serve(run)
    finally:
        dash.stop(run, quiet=True)
    assert started and url.startswith("http://localhost:")


def test_cli_off_then_dash_refuses(config, run):
    off = cli(run.root, "dash", "--off", env={"CHEKAN_CONFIG": str(config)})
    assert off.returncode == 0, off.stdout + off.stderr
    assert json.loads(config.read_text(encoding="utf-8"))[dash.CONFIG_OFF] is True
    try:
        done = cli(run.root, "dash", "--no-open", env={"CHEKAN_CONFIG": str(config)})
    finally:
        dash.stop(run, quiet=True)
    assert done.returncode != 0, done.stdout + done.stderr
    assert "chekan dash --on" in done.stdout
    assert dash._all_ours(run) == []
