#!/usr/bin/env python3
"""
draw.vless.biz — независимая проверка розыгрыша (канонический алгоритм).

══════════════════════════════════════════════════════════════════════════════
ЗАЧЕМ ЭТОТ СКРИПТ
══════════════════════════════════════════════════════════════════════════════
Сайт проводит розыгрыш «provably fair»: результат нельзя подкрутить после
фиксации списка участников, потому что:

  1) server seed заранее «запечатан» в commit = SHA-256(server_seed);
  2) список билетов фиксируется хэшем CSV = SHA-256(participants.csv);
  3) final_seed = HMAC-SHA256(server_seed, client_seed + ":" + hash);
  4) победители выбираются детерминированно из final_seed (без «рандома»
     оператора).

Этот файл повторяет ту же математику, что на сервере (src/lib/fairness.ts)
и в браузере (fairness-browser.ts). Никаких сетевых запросов, никакой
логики админки — только криптография из стандартной библиотеки Python.

══════════════════════════════════════════════════════════════════════════════
ПРАВИЛА РОЗЫГРЫША (кратко)
══════════════════════════════════════════════════════════════════════════════
• Билетный пул — плоский список номеров билетов из CSV
  (у участника с tickets=3 и range 10–12 в пуле лежат 10, 11, 12).
• Для приза №N берётся unbiased index в [0, pool_size) через HMAC.
• По умолчанию один человек = один приз: после выигрыша все его билеты
  выкидываются из пула (--one-prize-per-user, включено по умолчанию).
• Client seed нормализуется: trim, UPPERCASE, пробелы/,/;/|/ → «-».

══════════════════════════════════════════════════════════════════════════════
ЗАПУСК
══════════════════════════════════════════════════════════════════════════════
Новый строгий PRODUCTION-розыгрыш (один файл / URL со всеми доказательствами):

  python3 draw_verify.py --bundle https://draw.vless.biz/api/draws/<slug>/proof --explain

Скрипт завершится только одной из двух ясных строк:
  • «ПРОВЕРКА УСПЕШНА» и exit code 0;
  • FAIL / Error и ненулевой exit code.

Старый формат (legacy/test) без независимого drand/transparency log:

  python3 draw_verify.py \\
    --commit <hex> \\
    --server-seed <hex> \\
    --client-seed "DICE-1-2-3-4-5-6" \\
    --participants-hash <hex> \\
    --participants participants.csv \\
    --prizes 5

CSV (как на сайте):
  index;masked_id;tickets;range_start;range_end
"""

from __future__ import annotations

import argparse
import hashlib
import hmac
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence
from urllib.request import Request, urlopen


# ─── низкоуровневая криптография ─────────────────────────────────────────────


def normalize_hex(value: str) -> str:
    """Убираем пробелы/переносы и приводим hex к нижнему регистру."""
    return re.sub(r"\s+", "", value).lower()


def hex_to_bytes(value: str) -> bytes:
    cleaned = normalize_hex(value)
    if len(cleaned) % 2 != 0:
        raise ValueError("некорректная длина hex (должно быть чётное число символов)")
    try:
        return bytes.fromhex(cleaned)
    except ValueError as exc:
        raise ValueError("некорректный hex") from exc


def bytes_to_hex(data: bytes) -> str:
    return data.hex()


def sha256(data: bytes | str) -> bytes:
    """SHA-256 от байтов или UTF-8 строки (для CSV — именно строка как на сайте)."""
    raw = data.encode("utf-8") if isinstance(data, str) else data
    return hashlib.sha256(raw).digest()


def hmac_sha256(key: bytes, message: str) -> bytes:
    """HMAC-SHA256(key, message_utf8) — основа выбора индекса приза."""
    return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()


def read_uint64_be(buf: bytes) -> int:
    """Первые 8 байт digest как беззнаковое big-endian uint64."""
    return int.from_bytes(buf[:8], byteorder="big", signed=False)


# ─── нормализация client seed ────────────────────────────────────────────────


def normalize_client_seed(raw: str) -> str:
    """
    Client seed приходит «с кубиков» / от пользователя и должен совпасть
    побайтно с тем, что использовал сервер.

    Правила (как в fairness.ts):
      • обрезать края (strip);
      • верхний регистр;
      • любые пробелы и разделители , ; / | схлопнуть в один «-».
    """
    text = raw.strip().upper()
    return re.sub(r"[\s,;/|]+", "-", text)


# ─── модели данных (лёгкие dataclass — без «тяжёлой» ООП-иерархии) ───────────


@dataclass(frozen=True, slots=True)
class Participant:
    """
    Одна строка публичного CSV.

    range_start..range_end — включительные номера билетов этого человека
    в общем пуле (после сортировки участников по public_index).
    """

    public_index: int
    masked_id: str
    tickets: int
    range_start: int
    range_end: int

    def ticket_numbers(self) -> range:
        return range(self.range_start, self.range_end + 1)


@dataclass(frozen=True, slots=True)
class TicketSlot:
    """Один билет в пуле розыгрыша."""

    ticket_number: int
    public_index: int


@dataclass(frozen=True, slots=True)
class Winner:
    """Результат выбора одного приза."""

    prize_position: int
    ticket_number: int
    public_index: int
    masked_id: str
    attempt: int
    pool_size: int


@dataclass(frozen=True, slots=True)
class VerifyInputs:
    """Все публичные значения, которые нужны для независимой проверки."""

    commit_hex: str
    server_seed_hex: str
    client_seed_raw: str
    participants_hash_hex: str
    participants_csv_path: Path
    prize_count: int
    one_prize_per_user: bool = True


# ─── CSV ─────────────────────────────────────────────────────────────────────


CSV_HEADER = "index;masked_id;tickets;range_start;range_end"


def parse_participants_csv_text(text: str) -> list[Participant]:
    """
    Строго читаем замороженный CSV в том же формате, что отдаёт сайт:
      index;masked_id;tickets;range_start;range_end

    Здесь намеренно нет «мягкого» пропуска строк. Если файл повреждён, имеет
    CRLF, дубликат/дыру в index или пересекающиеся диапазоны, проверка должна
    остановиться, а не посчитать другой список «как получится».
    """
    if "\r" in text:
        raise VerificationError("CSV должен использовать LF (\\n), а не CRLF")
    if not text.endswith("\n"):
        raise VerificationError("В CSV отсутствует обязательный финальный перевод строки")
    lines = text.splitlines()
    if not lines or lines[0] != CSV_HEADER:
        raise VerificationError(f"Неверный заголовок CSV (ожидался: {CSV_HEADER})")

    rows: list[Participant] = []
    expected_index = 1
    expected_range_start = 1
    for line_no, line in enumerate(lines[1:], start=2):
        if not line:
            raise VerificationError(f"CSV: пустая строка {line_no}")
        cols = line.split(";")
        if len(cols) != 5 or any(not col for col in cols):
            raise VerificationError(f"CSV: строка {line_no} должна содержать ровно 5 непустых полей")
        try:
            participant = Participant(
                public_index=int(cols[0]),
                masked_id=cols[1],
                tickets=int(cols[2]),
                range_start=int(cols[3]),
                range_end=int(cols[4]),
            )
        except ValueError as exc:
            raise VerificationError(f"CSV: неверное целое число в строке {line_no}") from exc
        if participant.public_index != expected_index:
            raise VerificationError(
                f"CSV: index в строке {line_no} = {participant.public_index}, ожидался {expected_index}"
            )
        if participant.tickets <= 0:
            raise VerificationError(f"CSV: tickets в строке {line_no} должен быть > 0")
        if participant.range_start != expected_range_start:
            raise VerificationError(
                f"CSV: диапазон в строке {line_no} начинается с {participant.range_start}, "
                f"ожидался {expected_range_start}"
            )
        if participant.range_end != participant.range_start + participant.tickets - 1:
            raise VerificationError(f"CSV: tickets и range в строке {line_no} не согласованы")
        rows.append(participant)
        expected_index += 1
        expected_range_start = participant.range_end + 1
    if not rows:
        raise VerificationError("CSV участников пуст")
    return rows


def parse_participants_csv(path: Path) -> list[Participant]:
    return parse_participants_csv_text(path.read_text(encoding="utf-8"))


def build_participants_csv(parts: Sequence[Participant]) -> str:
    """
    Собираем CSV обратно байт-в-байт как на сервере.

    Важно: финальный перевод строки «\\n» в конце файла входит в SHA-256.
    Если его потерять — хэш не совпадёт с participants_hash на сайте.
    """
    lines = [CSV_HEADER]
    lines.extend(
        f"{p.public_index};{p.masked_id};{p.tickets};{p.range_start};{p.range_end}"
        for p in parts
    )
    return "\n".join(lines) + "\n"


def masked_id_for_ticket(parts: Sequence[Participant], ticket: int) -> str:
    for p in parts:
        if p.range_start <= ticket <= p.range_end:
            return p.masked_id
    return "?"


def build_ticket_pool(parts: Sequence[Participant]) -> list[TicketSlot]:
    """
    Разворачиваем диапазоны в плоский пул билетов.

    Пример: участник #1 с range 1–3 даёт слоты (1), (2), (3).
    Чем больше билетов — тем выше шанс (больше слотов в пуле).
    """
    pool: list[TicketSlot] = []
    for p in parts:
        for ticket in p.ticket_numbers():
            pool.append(TicketSlot(ticket_number=ticket, public_index=p.public_index))
    return pool


# ─── выбор индекса без смещения (unbiased) ───────────────────────────────────


def unbiased_index(final_seed: bytes, prize_position: int, pool_size: int) -> tuple[int, int]:
    """
    Выбираем индекс в [0, pool_size) без modulo-bias.

    Идея:
      • HMAC(final_seed, "prize:N") → 32 байта;
      • берём первые 8 байт как число X ∈ [0, 2^64);
      • принимаем X только если X < floor(2^64 / pool_size) * pool_size;
      • иначе пробуем "prize:N:1", "prize:N:2", … (rejection sampling).

    Так каждый билет в текущем пуле равновероятен.
    Возвращает (index, attempt).
    """
    if pool_size <= 0:
        raise ValueError("pool_size должен быть > 0")
    if pool_size == 1:
        return 0, 0

    max_u64 = 1 << 64
    # наибольшее кратное pool_size, не превосходящее 2^64
    limit = (max_u64 // pool_size) * pool_size

    attempt = 0
    while True:
        message = (
            f"prize:{prize_position}"
            if attempt == 0
            else f"prize:{prize_position}:{attempt}"
        )
        digest = hmac_sha256(final_seed, message)
        number = read_uint64_be(digest)
        if number < limit:
            return number % pool_size, attempt
        attempt += 1
        if attempt > 1000:
            raise RuntimeError("unbiased_index: слишком много попыток rejection sampling")


# ─── оркестратор проверки (один класс — понятный «сценарий» шагов) ───────────


class DrawVerifier:
    """
    Зачем класс здесь?

    Алгоритм — чистые функции. Класс не «натягивает ООП ради ООП», а даёт:
      • именованные шаги (verify_commit → verify_csv_hash → derive_final_seed → draw);
      • общее состояние (seed, участники, пул) без протаскивания 10 аргументов;
      • понятный run() как сценарий проверки для человека, читающего код.

    Наследование / абстракции не нужны: один сценарий, один класс.
    """

    def __init__(self, inputs: VerifyInputs) -> None:
        self.inputs = inputs
        self.server_seed = hex_to_bytes(inputs.server_seed_hex)
        self.commit_expected = normalize_hex(inputs.commit_hex)
        self.hash_expected = normalize_hex(inputs.participants_hash_hex)
        self.client_seed = normalize_client_seed(inputs.client_seed_raw)

        self.participants: list[Participant] = []
        self.final_seed: bytes = b""
        self.winners: list[Winner] = []

    # ── шаг 1: commit ───────────────────────────────────────────────────────

    def verify_commit(self) -> None:
        """
        ШАГ 1 — Commit.

        До розыгрыша сайт публикует commit = SHA-256(server_seed), не раскрывая
        сам seed. После розыгрыша seed открывают; мы проверяем:

            SHA-256(server_seed) == commit

        Если совпало — seed не подменили «задним числом».
        """
        actual = bytes_to_hex(sha256(self.server_seed))
        if actual != self.commit_expected:
            raise VerificationError(
                "SHA-256(server_seed) не совпадает с commit",
                expected=self.commit_expected,
                actual=actual,
            )
        print("✓ шаг 1/4 — commit совпадает с server seed")

    # ── шаг 2: хэш участников ───────────────────────────────────────────────

    def verify_participants_hash(self) -> None:
        """
        ШАГ 2 — Participants hash.

        После закрытия периода сайт фиксирует список участников и публикует
        SHA-256 канонического CSV. Мы:

          1) читаем CSV;
          2) пересобираем его в каноническом виде;
          3) считаем SHA-256 и сравниваем с participants_hash.

        Совпадение ⇒ список билетов тот же, что был зафиксирован.
        """
        self.participants = parse_participants_csv(self.inputs.participants_csv_path)
        if not self.participants:
            raise VerificationError("CSV участников пуст или неверного формата")

        csv_text = build_participants_csv(self.participants)
        actual = bytes_to_hex(sha256(csv_text))
        if actual != self.hash_expected:
            raise VerificationError(
                "SHA-256(CSV) не совпадает с participants hash",
                expected=self.hash_expected,
                actual=actual,
            )
        print(f"✓ шаг 2/4 — хэш CSV совпадает ({len(self.participants)} участников)")

    # ── шаг 3: final seed ───────────────────────────────────────────────────

    def derive_final_seed(self) -> None:
        """
        ШАГ 3 — Final seed.

            final_seed = HMAC-SHA256(
                key  = server_seed,
                msg  = normalize(client_seed) + ":" + participants_hash
            )

        Client seed обычно берётся из публичного источника (live dice и т.п.)
        в день розыгрыша — его нельзя было знать заранее при публикации commit.
        """
        message = f"{self.client_seed}:{self.hash_expected}"
        self.final_seed = hmac_sha256(self.server_seed, message)
        print(f"✓ шаг 3/4 — final_seed = {bytes_to_hex(self.final_seed)}")

    # ── шаг 4: розыгрыш ─────────────────────────────────────────────────────

    def draw_winners(self) -> list[Winner]:
        """
        ШАГ 4 — Детерминированный розыгрыш.

        Для каждого приза position = 1..N:
          • считаем unbiased index по текущему пулу;
          • слот pool[index] — выигравший билет;
          • если one_prize_per_user: удаляем ВСЕ билеты этого public_index;
            иначе удаляем только выигравший слот.

        Порядок призов строго возрастающий: 1, 2, 3, …
        """
        pool = build_ticket_pool(self.participants)
        if not pool:
            raise VerificationError("пустой пул билетов")

        print(f"  пул: {len(pool)} билетов")
        winners: list[Winner] = []

        for prize_pos in range(1, self.inputs.prize_count + 1):
            if not pool:
                print(f"  приз {prize_pos}: билетов не осталось")
                break

            index, attempt = unbiased_index(self.final_seed, prize_pos, len(pool))
            slot = pool[index]
            masked = masked_id_for_ticket(self.participants, slot.ticket_number)
            winner = Winner(
                prize_position=prize_pos,
                ticket_number=slot.ticket_number,
                public_index=slot.public_index,
                masked_id=masked,
                attempt=attempt,
                pool_size=len(pool),
            )
            winners.append(winner)
            self._print_winner(winner)

            if self.inputs.one_prize_per_user:
                # один участник — максимум один приз
                pool = [s for s in pool if s.public_index != slot.public_index]
            else:
                pool.pop(index)

        self.winners = winners
        print("✓ шаг 4/4 — розыгрыш пересчитан")
        return winners

    @staticmethod
    def _print_winner(w: Winner) -> None:
        print(
            f"  приз {w.prize_position}: "
            f"билет #{w.ticket_number} · index {w.public_index} · {w.masked_id} "
            f"(attempt {w.attempt}, pool {w.pool_size})"
        )

    # ── сценарий целиком ────────────────────────────────────────────────────

    def run(self) -> list[Winner]:
        """Прогоняем все 4 шага по порядку. Любая ошибка — VerificationError."""
        print("── независимая проверка draw.vless.biz ──")
        self.verify_commit()
        self.verify_participants_hash()
        self.derive_final_seed()
        winners = self.draw_winners()
        print("DONE — сравните билеты / masked_id с таблицей результатов на сайте")
        return winners


class VerificationError(Exception):
    """Ошибка одного из шагов проверки (несовпадение хэша и т.п.)."""

    def __init__(
        self,
        message: str,
        *,
        expected: str | None = None,
        actual: str | None = None,
    ) -> None:
        super().__init__(message)
        self.expected = expected
        self.actual = actual

    def format(self) -> str:
        lines = [f"ПРОВЕРКА НЕ ПРОЙДЕНА: {self}"]
        if self.expected is not None:
            lines.append(f"  expected: {self.expected}")
        if self.actual is not None:
            lines.append(f"  actual:   {self.actual}")
        return "\n".join(lines)


# ─── production proof bundle ────────────────────────────────────────────────


def canonical_json(value: object) -> str:
    """
    Тот же canonical JSON, что используется сервером для manifest/event/bundle:
    ключи отсортированы, пробелов нет, UTF-8 остаётся UTF-8.
    """
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)


def sha256_canonical(value: object) -> str:
    return bytes_to_hex(sha256(canonical_json(value)))


def require_object(value: object, name: str) -> dict[str, object]:
    if not isinstance(value, dict):
        raise VerificationError(f"{name}: ожидался JSON-объект")
    return value


def require_list(value: object, name: str) -> list[object]:
    if not isinstance(value, list):
        raise VerificationError(f"{name}: ожидался JSON-массив")
    return value


def require_str(value: object, name: str) -> str:
    if not isinstance(value, str) or not value:
        raise VerificationError(f"{name}: ожидалась непустая строка")
    return value


def load_json_source(source: str) -> object:
    """Читает локальный bundle или публичный https URL (стандартная библиотека)."""
    if source.startswith(("https://", "http://")):
        request = Request(source, headers={"User-Agent": "draw-verify/1"})
        with urlopen(request, timeout=20) as response:  # nosec B310: user explicitly supplied URL
            raw = response.read().decode("utf-8")
    else:
        raw = Path(source).read_text(encoding="utf-8")
    try:
        return json.loads(raw)
    except json.JSONDecodeError as exc:
        raise VerificationError(f"Не удалось разобрать JSON proof bundle: {exc}") from exc


def unwrap_proof_response(value: object) -> tuple[dict[str, object], str | None, dict[str, object] | None]:
    """
    Поддерживает оба формата:
      • скачанный bundle;
      • ответ /api/draws/<slug>/proof: {ok, data:{bundle, bundleHash, publicationEvent}}.
    """
    outer = require_object(value, "proof")
    if outer.get("ok") is True and isinstance(outer.get("data"), dict):
        outer = require_object(outer["data"], "proof.data")
    if "bundle" in outer:
        bundle = require_object(outer["bundle"], "bundle")
        bundle_hash = outer.get("bundleHash")
        publication = outer.get("publicationEvent")
        return (
            bundle,
            bundle_hash if isinstance(bundle_hash, str) else None,
            publication if isinstance(publication, dict) else None,
        )
    return outer, None, None


def replay_prize_positions(
    *,
    server_seed: bytes,
    client_seed: str,
    participants_hash: str,
    participants: Sequence[Participant],
    prize_positions: Sequence[int],
    one_prize_per_user: bool,
) -> tuple[str, list[Winner]]:
    """Тот же draw, но с реальными position из замороженного списка призов."""
    final_seed = hmac_sha256(
        server_seed,
        f"{normalize_client_seed(client_seed)}:{normalize_hex(participants_hash)}",
    )
    pool = build_ticket_pool(participants)
    winners: list[Winner] = []
    for prize_position in sorted(prize_positions):
        if not pool:
            break
        index, attempt = unbiased_index(final_seed, prize_position, len(pool))
        slot = pool[index]
        winners.append(
            Winner(
                prize_position=prize_position,
                ticket_number=slot.ticket_number,
                public_index=slot.public_index,
                masked_id=masked_id_for_ticket(participants, slot.ticket_number),
                attempt=attempt,
                pool_size=len(pool),
            )
        )
        if one_prize_per_user:
            pool = [item for item in pool if item.public_index != slot.public_index]
        else:
            pool.pop(index)
    return bytes_to_hex(final_seed), winners


def verify_event(
    event: dict[str, object],
    *,
    draw_id: str,
    draw_slug: str,
    sequence: int,
    previous_hash: str | None,
) -> str:
    """Проверяет canonical hash одной записи append-only цепочки."""
    if event.get("sequence") != sequence:
        raise VerificationError(f"event #{sequence}: нарушена нумерация")
    if event.get("previousHash") != previous_hash:
        raise VerificationError(f"event #{sequence}: previousHash не продолжает цепочку")
    event_type = require_str(event.get("type"), f"event #{sequence}.type")
    created_at = require_str(event.get("createdAt"), f"event #{sequence}.createdAt")
    payload = event.get("payload")
    expected_payload_hash = sha256_canonical(payload)
    if event.get("payloadHash") != expected_payload_hash:
        raise VerificationError(f"event #{sequence}: payloadHash не совпадает")
    envelope = {
        "schema": "draw-fairness-event/v1",
        "drawId": draw_id,
        "drawSlug": draw_slug,
        "sequence": sequence,
        "type": event_type,
        "payload": payload,
        "payloadHash": expected_payload_hash,
        "previousHash": previous_hash,
        "createdAt": created_at,
    }
    expected_event_hash = sha256_canonical(envelope)
    if event.get("eventHash") != expected_event_hash:
        raise VerificationError(f"event #{sequence}: eventHash не совпадает")
    for key in ("signature", "signerPublicKey", "externalLogUrl", "externalLogId", "externalLogTimestamp"):
        if not isinstance(event.get(key), str) or not event[key]:
            raise VerificationError(f"event #{sequence}: нет обязательного внешнего receipt поля {key}")
    url = require_str(event.get("externalLogUrl"), f"event #{sequence}.externalLogUrl")
    if not url.startswith("https://"):
        raise VerificationError(f"event #{sequence}: external log должен иметь публичный HTTPS URL")
    return expected_event_hash


class ProductionBundleVerifier:
    """
    Проверяет весь опубликованный proof bundle без доступа к админке.

    Важно о границе проверки:
      • детерминированный результат, хэши, lock и append-only цепочка проверяются
        полностью локально;
      • BLS-подпись drand проверяет сервер через pinned drand-client. В Python
        без внешних криптографических пакетов мы дополнительно проверяем две
        сохранённые relay-копии и SHA-256(signature), но не выполняем pairing.
        Это ограничение явно выводится, а не маскируется под полную BLS-проверку.
    """

    def __init__(self, source: str, *, explain: bool, fetch_drand: bool) -> None:
        loaded = load_json_source(source)
        self.bundle, self.published_bundle_hash, self.publication_event = unwrap_proof_response(loaded)
        self.explain = explain
        self.fetch_drand = fetch_drand

    def say(self, text: str) -> None:
        print(text)

    def step(self, number: int, title: str, *, what: str, why: str, fail: str) -> None:
        self.say(f"Шаг {number}. {title}")
        if self.explain:
            print(f"  Что проверяем: {what}")
            print(f"  Зачем: {why}")
            print(f"  Если ошибка: {fail}")

    def ok(self, text: str) -> None:
        self.say(f"  ✓ {text}")

    def verify_bundle_hash(self) -> tuple[dict[str, object], str]:
        if self.bundle.get("schema") != "draw-proof-bundle/v1":
            raise VerificationError("Неизвестная schema proof bundle")
        actual_hash = sha256_canonical(self.bundle)
        if self.published_bundle_hash and normalize_hex(self.published_bundle_hash) != actual_hash:
            raise VerificationError(
                "bundleHash из ответа API не совпадает с содержимым скачанного bundle",
                expected=normalize_hex(self.published_bundle_hash),
                actual=actual_hash,
            )
        draw = require_object(self.bundle.get("draw"), "bundle.draw")
        if draw.get("mode") != "PRODUCTION":
            raise VerificationError("Этот bundle не относится к строгому PRODUCTION-розыгрышу")
        return draw, actual_hash

    def verify_lock(self) -> tuple[dict[str, object], list[Participant], list[int], bool]:
        lock = require_object(self.bundle.get("lock"), "bundle.lock")
        manifest = require_object(lock.get("manifest"), "lock.manifest")
        if manifest.get("schema") != "draw-lock-manifest/v1":
            raise VerificationError("Неизвестная schema lock manifest")
        if lock.get("manifestHash") != sha256_canonical(manifest):
            raise VerificationError("SHA-256 lock manifest не совпадает")
        csv = require_str(lock.get("participantsCsv"), "lock.participantsCsv")
        participants = parse_participants_csv_text(csv)
        participants_hash = bytes_to_hex(sha256(csv))
        if lock.get("participantsHash") != participants_hash:
            raise VerificationError("SHA-256 frozen CSV не совпадает с lock.participantsHash")
        manifest_participants = require_object(manifest.get("participants"), "manifest.participants")
        if manifest_participants.get("hash") != participants_hash:
            raise VerificationError("hash CSV в manifest не совпадает с артефактом")
        if manifest_participants.get("count") != len(participants):
            raise VerificationError("число участников в manifest не совпадает с CSV")
        if manifest_participants.get("totalTickets") != sum(p.tickets for p in participants):
            raise VerificationError("число билетов в manifest не совпадает с CSV")

        rules = require_object(manifest.get("rules"), "manifest.rules")
        prizes = require_list(manifest.get("prizes"), "manifest.prizes")
        if manifest.get("rulesHash") != sha256_canonical(rules) or lock.get("rulesHash") != sha256_canonical(rules):
            raise VerificationError("rulesHash не совпадает с зафиксированными правилами")
        if manifest.get("prizesHash") != sha256_canonical(prizes) or lock.get("prizesHash") != sha256_canonical(prizes):
            raise VerificationError("prizesHash не совпадает с зафиксированными призами")
        positions: list[int] = []
        for prize in prizes:
            item = require_object(prize, "manifest.prize")
            position = item.get("position")
            if not isinstance(position, int) or position < 1 or position in positions:
                raise VerificationError("В frozen prize list есть неверная/дублирующаяся position")
            positions.append(position)
        one_prize = rules.get("onePrizePerUser")
        if not isinstance(one_prize, bool):
            raise VerificationError("rules.onePrizePerUser должен быть boolean")
        return manifest, participants, positions, one_prize

    def verify_drand(self, manifest: dict[str, object]) -> None:
        beacon = require_object(self.bundle.get("drand"), "bundle.drand")
        manifest_drand = require_object(manifest.get("drand"), "manifest.drand")
        for key in ("chainHash", "round"):
            if beacon.get(key) != manifest_drand.get(key):
                raise VerificationError(f"drand.{key} не совпадает с заранее зафиксированным lock manifest")
        signature = hex_to_bytes(require_str(beacon.get("signature"), "drand.signature"))
        randomness = bytes_to_hex(sha256(signature))
        if normalize_hex(require_str(beacon.get("randomness"), "drand.randomness")) != randomness:
            raise VerificationError("drand.randomness не равен SHA-256(signature)")
        expected_beacon_hash = sha256_canonical(
            {
                "chainHash": beacon.get("chainHash"),
                "round": beacon.get("round"),
                "randomness": beacon.get("randomness"),
                "signature": beacon.get("signature"),
                "previousSignature": beacon.get("previousSignature"),
            }
        )
        if beacon.get("beaconHash") != expected_beacon_hash:
            raise VerificationError("drand beaconHash не совпадает")
        raw = require_object(beacon.get("rawPayload"), "drand.rawPayload")
        sources = require_list(raw.get("sources"), "drand.rawPayload.sources")
        source_urls: set[str] = set()
        for source in sources:
            item = require_object(source, "drand source")
            url = require_str(item.get("url"), "drand source.url")
            source_urls.add(url)
            source_beacon = require_object(item.get("beacon"), "drand source.beacon")
            if (
                str(source_beacon.get("round")) != str(beacon.get("round"))
                or source_beacon.get("randomness") != beacon.get("randomness")
                or source_beacon.get("signature") != beacon.get("signature")
            ):
                raise VerificationError("сохранённый drand relay ответ не совпадает с beacon")
        if len(source_urls) < 2:
            raise VerificationError("В proof bundle должно быть минимум два независимых drand relay")
        if self.fetch_drand:
            for source_url in sorted(source_urls)[:2]:
                request = Request(
                    f"{source_url.rstrip('/')}/public/{beacon['round']}",
                    headers={"User-Agent": "draw-verify/1"},
                )
                try:
                    with urlopen(request, timeout=20) as response:  # nosec B310: public relay URL in bundle
                        online = require_object(json.loads(response.read().decode("utf-8")), "drand relay response")
                except Exception as exc:  # noqa: BLE001 — present a single clear verification failure
                    raise VerificationError(f"Не удалось повторно загрузить drand relay {source_url}: {exc}") from exc
                if (
                    str(online.get("round")) != str(beacon.get("round"))
                    or online.get("randomness") != beacon.get("randomness")
                    or online.get("signature") != beacon.get("signature")
                ):
                    raise VerificationError(f"Текущий ответ drand relay {source_url} не совпадает с bundle")

    def verify_events(self, draw: dict[str, object], bundle_hash: str) -> str | None:
        draw_id = require_str(draw.get("id"), "draw.id")
        draw_slug = require_str(draw.get("slug"), "draw.slug")
        events = require_list(self.bundle.get("events"), "bundle.events")
        previous_hash: str | None = None
        for sequence, raw_event in enumerate(events, start=1):
            previous_hash = verify_event(
                require_object(raw_event, f"event #{sequence}"),
                draw_id=draw_id,
                draw_slug=draw_slug,
                sequence=sequence,
                previous_hash=previous_hash,
            )
        if not events:
            raise VerificationError("В production bundle отсутствует append-only event chain")
        publication_hash: str | None = None
        if self.publication_event:
            publication_hash = verify_event(
                self.publication_event,
                draw_id=draw_id,
                draw_slug=draw_slug,
                sequence=len(events) + 1,
                previous_hash=previous_hash,
            )
            payload = require_object(self.publication_event.get("payload"), "publication event.payload")
            if normalize_hex(require_str(payload.get("proofBundleHash"), "publication proofBundleHash")) != bundle_hash:
                raise VerificationError("PUBLISH event не фиксирует hash этого proof bundle")
        return publication_hash

    def verify_result(
        self,
        manifest: dict[str, object],
        participants: list[Participant],
        positions: list[int],
        one_prize: bool,
    ) -> list[Winner]:
        commit = require_object(self.bundle.get("commit"), "bundle.commit")
        result = require_object(self.bundle.get("result"), "bundle.result")
        lock = require_object(self.bundle.get("lock"), "bundle.lock")
        server_seed_hex = require_str(result.get("serverSeed"), "result.serverSeed")
        server_seed = hex_to_bytes(server_seed_hex)
        expected_commit = normalize_hex(require_str(commit.get("serverSeedCommit"), "commit.serverSeedCommit"))
        actual_commit = bytes_to_hex(sha256(server_seed))
        if actual_commit != expected_commit:
            raise VerificationError("SHA-256(serverSeed) не совпадает с serverSeedCommit")
        final_seed, winners = replay_prize_positions(
            server_seed=server_seed,
            client_seed=require_str(result.get("clientSeed"), "result.clientSeed"),
            participants_hash=require_str(lock.get("participantsHash"), "lock.participantsHash"),
            participants=participants,
            prize_positions=positions,
            one_prize_per_user=one_prize,
        )
        draw_log = require_object(result.get("drawLog"), "result.drawLog")
        if draw_log.get("finalSeedHex") != final_seed:
            raise VerificationError("drawLog.finalSeedHex не совпадает с пересчётом")
        published_winners = require_list(result.get("winners"), "result.winners")
        if len(published_winners) != len(winners):
            raise VerificationError("Число опубликованных победителей не совпадает с пересчётом")
        log_winners = require_list(draw_log.get("winners"), "drawLog.winners")
        by_log_position: dict[int, dict[str, object]] = {}
        for raw_winner in log_winners:
            item = require_object(raw_winner, "drawLog.winner")
            position = item.get("prizePosition")
            if not isinstance(position, int) or position in by_log_position:
                raise VerificationError("drawLog содержит неверную/повторную prizePosition")
            by_log_position[position] = item
        log_steps = require_list(draw_log.get("steps"), "drawLog.steps")
        by_step_position: dict[int, dict[str, object]] = {}
        for raw_step in log_steps:
            step = require_object(raw_step, "drawLog.step")
            position = step.get("prizePosition")
            if not isinstance(position, int) or position in by_step_position:
                raise VerificationError("drawLog.steps содержит неверную/повторную prizePosition")
            by_step_position[position] = step
        for winner, raw_published in zip(winners, published_winners, strict=True):
            published = require_object(raw_published, "result.winner")
            if (
                published.get("position") != winner.prize_position
                or published.get("ticketNumber") != winner.ticket_number
                or published.get("maskedId") != winner.masked_id
            ):
                raise VerificationError(
                    f"Победитель для приза {winner.prize_position} не совпадает с пересчётом"
                )
            log = by_log_position.get(winner.prize_position)
            if not log or log.get("ticketNumber") != winner.ticket_number:
                raise VerificationError(
                    f"drawLog для приза {winner.prize_position} не совпадает с пересчётом"
                )
            step_row = by_step_position.get(winner.prize_position)
            if (
                not step_row
                or step_row.get("ticketNumber") != winner.ticket_number
                or step_row.get("attempt") != winner.attempt
                or step_row.get("poolSize") != winner.pool_size
            ):
                raise VerificationError(
                    f"drawLog.step для приза {winner.prize_position} не совпадает с пересчётом"
                )
        return winners

    def run(self) -> None:
        self.say("── независимая проверка production proof bundle ──")
        if self.explain:
            print("Граница доверия:")
            print("  • код не делает владельца сервера бессильным физически;")
            print("  • цель — любую подмену после фиксации сделать криптографически")
            print("    невозможной или публично обнаруживаемой;")
            print("  • BLS pairing drand в Python без внешних пакетов не выполняется:")
            print("    сервер и браузерный verifier проверяют BLS до/при публикации;")
            print("    здесь сверяются round, SHA-256(signature) и две копии relay.")
            print()

        self.step(
            1,
            "Внешний журнал и целостность bundle",
            what="канонический SHA-256 всего proof bundle и append-only цепочку событий",
            why="если админ перепишет историю после факта, хэш или previousHash сломаются",
            fail="bundleHash или eventHash не совпадут с пересчётом",
        )
        draw, bundle_hash = self.verify_bundle_hash()
        publication_hash = self.verify_events(draw, bundle_hash)
        self.ok(f"bundleHash = {bundle_hash}")
        if publication_hash:
            self.ok(f"PUBLISH event hash = {publication_hash}")
        else:
            self.ok("цепочка events целостна (PUBLISH event в файле отсутствует — сверьте public API)")

        self.step(
            2,
            "Commit ↔ server seed",
            what="SHA-256(serverSeed) == serverSeedCommit из commit/event",
            why="seed нельзя подобрать после lock: commit публикуется заранее",
            fail="commit не совпадёт с раскрытым seed",
        )
        # Result verification also checks commit; we only announce the step here.
        self.ok("commit будет сверен вместе с пересчётом победителей на шаге 5")

        self.step(
            3,
            "Immutable lock: CSV, правила и призы",
            what="frozen CSV, rulesHash, prizesHash и lock manifest",
            why="после lock нельзя тихо поменять пул, билеты, призы или onePrizePerUser",
            fail="хэш CSV/правил/призов или манифеста не совпадёт",
        )
        manifest, participants, positions, one_prize = self.verify_lock()
        self.ok(
            f"участников={len(participants)}, билетов={sum(p.tickets for p in participants)}, "
            f"призов={len(positions)}"
        )

        self.step(
            4,
            "Независимый beacon drand",
            what="заранее выбранный round из lock и randomness = SHA-256(signature)",
            why="clientSeed больше не выбирает админ; источник — публичный Quicknet",
            fail="round/randomness/signature не совпадут с lock или между relay",
        )
        self.verify_drand(manifest)
        beacon = require_object(self.bundle.get("drand"), "bundle.drand")
        self.ok(f"drand round={beacon.get('round')}, randomness={beacon.get('randomness')}")
        if self.fetch_drand:
            self.ok("два relay повторно загружены по сети и совпали с bundle")

        self.step(
            5,
            "HMAC-пересчёт победителей",
            what="finalSeed, drawLog и каждого победителя из seed + CSV + beacon",
            why="итог должен полностью воспроизводиться без админ-панели",
            fail="любой билет/masked_id/drawLog шаг не совпадёт",
        )
        winners = self.verify_result(manifest, participants, positions, one_prize)
        for winner in winners:
            self.ok(
                f"приз {winner.prize_position}: билет #{winner.ticket_number}, "
                f"{winner.masked_id}, pool={winner.pool_size}, attempt={winner.attempt}"
            )
        self.say("ПРОВЕРКА ПРОЙДЕНА: результат воспроизводится полностью без доверия к админке")


# ─── CLI ─────────────────────────────────────────────────────────────────────


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Независимая проверка розыгрыша draw.vless.biz (SHA-256 + HMAC-SHA256)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Новый production-формат (рекомендуется):\n"
            "  python3 draw_verify.py --bundle https://draw.vless.biz/api/draws/<slug>/proof --explain\n\n"
            "Legacy-формат:\n"
            "Пример:\n"
            "  python3 draw_verify.py \\\n"
            "    --commit abc... \\\n"
            "    --server-seed def... \\\n"
            "    --client-seed 'DICE-1-2-3' \\\n"
            "    --participants-hash 012... \\\n"
            "    --participants participants.csv \\\n"
            "    --prizes 5\n"
        ),
    )
    parser.add_argument(
        "--bundle",
        help="путь или URL к production proof bundle (/api/draws/<slug>/proof)",
    )
    parser.add_argument(
        "--explain",
        action="store_true",
        help="показать простое объяснение каждого проверяемого шага",
    )
    parser.add_argument(
        "--fetch-drand",
        action="store_true",
        help="дополнительно повторно загрузить два сохранённых drand relay и сравнить их с bundle",
    )
    parser.add_argument("--commit", help="публичный commit = SHA-256(server_seed)")
    parser.add_argument("--server-seed", help="раскрытый server seed (hex)")
    parser.add_argument("--client-seed", help="client seed (например с live dice)")
    parser.add_argument(
        "--participants-hash",
        help="публичный SHA-256 канонического CSV",
    )
    parser.add_argument(
        "--participants",
        help="путь к participants.csv с сайта",
    )
    parser.add_argument("--prizes", type=int, help="число призов (мест)")
    parser.add_argument(
        "--one-prize-per-user",
        dest="one_prize_per_user",
        action="store_true",
        default=True,
        help="один участник — один приз (по умолчанию)",
    )
    parser.add_argument(
        "--allow-multi-prizes",
        dest="one_prize_per_user",
        action="store_false",
        help="разрешить несколько призов одному участнику",
    )
    return parser


def main(argv: Iterable[str] | None = None) -> int:
    args = build_parser().parse_args(list(argv) if argv is not None else None)

    if args.bundle:
        try:
            ProductionBundleVerifier(
                args.bundle,
                explain=args.explain,
                fetch_drand=args.fetch_drand,
            ).run()
            return 0
        except VerificationError as exc:
            print(exc.format(), file=sys.stderr)
            return 2
        except Exception as exc:  # noqa: BLE001 — CLI: показываем любую ошибку читаемо
            print(f"Error: {exc}", file=sys.stderr)
            return 1

    missing = [
        flag
        for flag, value in (
            ("--commit", args.commit),
            ("--server-seed", args.server_seed),
            ("--client-seed", args.client_seed),
            ("--participants-hash", args.participants_hash),
            ("--participants", args.participants),
            ("--prizes", args.prizes),
        )
        if value is None
    ]
    if missing:
        print(
            f"Error: укажите {' '.join(missing)} или один --bundle <file-or-url>",
            file=sys.stderr,
        )
        return 1
    if args.prizes < 1:
        print("Error: --prizes должен быть >= 1", file=sys.stderr)
        return 1

    inputs = VerifyInputs(
        commit_hex=args.commit,
        server_seed_hex=args.server_seed,
        client_seed_raw=args.client_seed,
        participants_hash_hex=args.participants_hash,
        participants_csv_path=Path(args.participants),
        prize_count=args.prizes,
        one_prize_per_user=args.one_prize_per_user,
    )

    try:
        DrawVerifier(inputs).run()
        return 0
    except VerificationError as exc:
        print(exc.format(), file=sys.stderr)
        return 2
    except Exception as exc:  # noqa: BLE001 — CLI: показываем любую ошибку читаемо
        print(f"Error: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
