"""Shared utilities for behavioral specs."""

from __future__ import annotations

import hashlib
import json
import time
from typing import TYPE_CHECKING, Callable, TypeVar

from coincurve import PrivateKey

if TYPE_CHECKING:
    from admin_core import AdminCore
    from mock_relay import MockRelay


# ---- key helpers -----------------------------------------------------------


def new_keypair() -> tuple[PrivateKey, str]:
    """Fresh BIP-340 keypair: returns ``(prv, pub)`` where ``prv`` is the
    coincurve private-key object (used for signing) and ``pub`` is the
    64-char lowercase hex public key. Use when the test will sign events.
    For pure pubkey assertions use :func:`new_pub`."""
    prv = PrivateKey()
    return prv, prv.public_key_xonly.format().hex()


def new_pub() -> str:
    """64-char lowercase hex public key of a fresh keypair (private discarded)."""
    return new_keypair()[1]


def signed_event(
    prv: PrivateKey,
    kind: int,
    *,
    content: str = "",
    created_at: int = 1,
    tags: list | None = None,
) -> dict:
    """Build a NIP-01 event signed with ``prv`` (real BIP-340 schnorr
    signature via coincurve). The canonical id is sha256 over
    ``[0, pubkey, created_at, kind, tags, content]`` with the NIP-01 JSON
    encoding rules — same format the ingest path validates against, so the
    event passes ``_verify_event`` and lands in ``events``."""
    tags = tags or []
    pub = prv.public_key_xonly.format().hex()
    canonical = json.dumps(
        [0, pub, created_at, kind, tags, content],
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode("utf-8")
    event_id = hashlib.sha256(canonical).hexdigest()
    sig = prv.sign_schnorr(bytes.fromhex(event_id)).hex()
    return {
        "id": event_id,
        "pubkey": pub,
        "created_at": created_at,
        "kind": kind,
        "tags": tags,
        "content": content,
        "sig": sig,
    }


# ---- polling ---------------------------------------------------------------

T = TypeVar("T")


def wait_until(
    predicate: Callable[[], T],
    *,
    timeout: float = 5.0,
    interval: float = 0.05,
    message: str = "",
) -> T:
    """Poll ``predicate()`` until it returns a truthy value, then return it.

    Raises ``AssertionError`` if ``timeout`` elapses with no truthy result;
    the last value seen is included in the error message so failures show
    *what was there* rather than just *what was missing*.
    """
    deadline = time.time() + timeout
    last: T | None = None
    while time.time() < deadline:
        last = predicate()
        if last:
            return last
        time.sleep(interval)
    raise AssertionError(
        f"wait_until timed out after {timeout}s — {message or '<no message>'}; "
        f"last value: {last!r}"
    )


# ---- relay assertions ------------------------------------------------------


class RelayHelper:
    """Assertions about NIP-01 frames received by a ``MockRelay``.

    Methods are static — the class is a namespace so tests read as one fluent
    line (``RelayHelper.expect_reqs(relay, [...])``) without each test
    needing its own polling loop.
    """

    @staticmethod
    def expect_reqs(
        relay: "MockRelay",
        filters: list[dict],
        *,
        timeout: float = 5.0,
        message: str | None = None,
    ) -> None:
        """Wait until every filter in ``filters`` appears as the body of a
        REQ frame on ``relay``. Order is irrelevant; extra REQs are tolerated.
        Raises ``AssertionError`` on timeout, with the actual REQ filters
        received so far for diagnostics.
        """
        def all_seen() -> bool:
            seen = [f[2] for f in relay.received if f[0] == "REQ"]
            return all(expected in seen for expected in filters)

        try:
            wait_until(
                all_seen,
                timeout=timeout,
                message=message or (
                    f"expected {len(filters)} REQ filter(s) on {relay.url}"
                ),
            )
        except AssertionError as exc:
            seen = [f[2] for f in relay.received if f[0] == "REQ"]
            raise AssertionError(
                f"{exc}\n  expected: {filters}\n  got REQs:  {seen}"
            ) from None


# ---- DB assertions ---------------------------------------------------------


class DbHelper:
    """Assertions about DuckDB state under ``AdminCore``.

    Methods are static — namespace for spec-side reads. Queries pass through
    ``core.run_sql`` (same path the MCP agent uses) and are written out in
    full at the call site so each spec shows exactly *what* it expects and
    *in which table* — no hidden domain logic in the helper.
    """

    @staticmethod
    def expect_row(
        core: "AdminCore",
        sql: str,
        params: list | None = None,
        *,
        timeout: float = 3.0,
        message: str | None = None,
    ) -> dict:
        """Wait until ``sql`` (parametrised with ``params``) returns
        *exactly one* row, then return it as a dict.

        Two or more rows is a hard error raised immediately, ignoring
        ``timeout`` — extra rows are a spec violation (duplicate ingest,
        stub left behind, query too broad) and waiting longer would only
        hide the bug. Use :meth:`expect_rows` when multiple rows are valid.
        """
        def fetched() -> dict | None:
            rows = core.run_sql(sql, params or [])
            if not rows:
                return None
            if len(rows) > 1:
                raise AssertionError(
                    f"expected exactly one row from {sql!r} params={params!r}, "
                    f"got {len(rows)}: {rows!r}"
                )
            return rows[0]

        return wait_until(
            fetched,
            timeout=timeout,
            message=message or f"expected one row from: {sql!r} params={params!r}",
        )

    @staticmethod
    def expect_rows(
        core: "AdminCore",
        sql: str,
        params: list | None = None,
        *,
        count: int | None = None,
        timeout: float = 3.0,
        message: str | None = None,
    ) -> list[dict]:
        """Wait until ``sql`` returns rows. If ``count`` is given, wait until
        at least that many rows are present; otherwise wait for any. Returns
        all rows in the order the query produced them — order yourself with
        ``ORDER BY`` if it matters."""
        def fetched() -> list[dict] | None:
            rows = core.run_sql(sql, params or [])
            if not rows:
                return None
            if count is not None and len(rows) < count:
                return None
            return rows

        target = f"at least {count}" if count is not None else "at least one"
        return wait_until(
            fetched,
            timeout=timeout,
            message=(
                message
                or f"expected {target} row(s) from: {sql!r} params={params!r}"
            ),
        )
