"""Shared NIP-01 helpers used by the storage layer.

The old ``_RelayDaemonBase`` + ``RelayConn`` model (one sync WS thread per
relay) has been replaced by ``relay_bus.RelayBus`` (single async loop). What
remains here is the pure relay-protocol utilities: URL canonicalization and
NIP-01 tag flattening into the row shape the storage layer wants.
"""

import logging
import os
import re

log = logging.getLogger("ingest")

POLL_INTERVAL_S = 5.0
BACKFILL_TOTAL_TIMEOUT_S = 10.0

# Fraction of physical RAM DuckDB is allowed to use. DuckDB defaults to ~80%,
# which plus the Python process, 70+ daemon threads, and the OS overruns RAM
# on a swapless box and gets the whole process OOM-killed (the event_tags
# migration died at ~6 GB on a 7.8 GB host this way). 60% leaves headroom.
DUCKDB_MEMORY_FRACTION = 0.60


def apply_duckdb_runtime_config(con, db_path: str) -> None:
    """Cap a DuckDB connection's memory budget below physical RAM and point it
    at an on-disk spill directory, so heavy joins/inserts page to disk instead
    of overrunning RAM and being OOM-killed by the kernel (this host has no
    swap). Applied to every read-write connection the daemon opens; safe to
    call on multiple connections to the same file — they share one DuckDB
    instance, so the limit is set once and re-applied identically.
    """
    total_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
    limit_gb = max(1, round(total_bytes / (1024 ** 3) * DUCKDB_MEMORY_FRACTION))
    tmp_dir = os.path.join(os.path.dirname(os.path.abspath(db_path)), "tmp")
    os.makedirs(tmp_dir, exist_ok=True)
    con.execute(f"SET memory_limit='{limit_gb}GB'")
    con.execute(f"SET temp_directory='{tmp_dir}'")
    log.info("DuckDB memory_limit=%dGB temp_directory=%s", limit_gb, tmp_dir)

_RELAY_HOST_LABEL_RE = re.compile(
    r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"
)


# ---- shared utilities --------------------------------------------------------


def _normalize_relay_url(raw: str | None) -> str | None:
    """Return a canonical ws(s):// relay URL, or None if `raw` isn't one.

    Strips whitespace and trailing slashes so trailing-slash and bare-host
    forms collapse to one logical relay. Also validates the hostname: each
    label must be 1..63 chars of ``[A-Za-z0-9-]`` (no leading/trailing
    hyphen, no empty labels). IDNA would otherwise raise ``UnicodeError:
    label empty or too long`` when the URL is later passed to
    ``getaddrinfo``, which used to take down the mention-sweep fan-out.

    Returns None for anything that doesn't look like a usable relay URL —
    callers that ingest tag-derived URLs rely on this filter to drop the
    free-form garbage some relays embed in NIP-65 ``r`` tags.
    """
    if not raw:
        return None
    s = raw.strip()
    if not (s.startswith("ws://") or s.startswith("wss://")):
        return None
    # Embedded whitespace (literal newlines have shown up in real NIP-65
    # events) is never legal in a URL — reject before it reaches DNS.
    if any(ch.isspace() for ch in s):
        return None
    s = s.rstrip("/")
    scheme, _, rest = s.partition("://")
    host_port, sep, path = rest.partition("/")
    host = host_port.split(":", 1)[0]
    # A trailing dot is a valid FQDN marker but breaks IDNA — strip it.
    host = host.rstrip(".")
    if not host:
        return None
    if not all(_RELAY_HOST_LABEL_RE.match(L) for L in host.split(".")):
        return None
    # Rebuild with the cleaned host so callers see the canonical form.
    port = host_port[len(host_port.split(":", 1)[0]):]  # ':port' or ''
    return f"{scheme}://{host}{port}" + (f"/{path}" if sep else "")


def _prepare_tags(tags: list) -> list[dict]:
    """Flatten NIP-01 tags into uniform rows — one dict per tag, carrying its
    original index and full element list. No per-tag special-casing: there is
    no reference typing, no `a`-coordinate parsing, and no `r`-relay
    normalization here. Those are concerns of the point of use (the NIP-65
    handler canonicalizes relay URLs; the `addressables` view splits the
    coordinate; callers read `p`/`e` targets as `elements[1]`). `idx` reflects
    the original position so the storage row key stays stable across re-ingest.
    """
    out = []
    for idx, tag in enumerate(tags):
        if not isinstance(tag, list) or not tag:
            continue
        name = tag[0] if isinstance(tag[0], str) else str(tag[0])
        elements = [str(x) if not isinstance(x, str) else x for x in tag]
        out.append({"idx": idx, "name": name, "elements": elements})
    return out


def _p_target(t: dict) -> str | None:
    """Target pubkey of a prepared `p`-tag — the primary value at element
    position 1. None when the tag carries no value (a bare ``["p"]``). The
    flat ``event_tags`` storage mirrors this: the primary value lives at
    ``elem_idx=1``, with relay-hint/petname (if any) at positions 2+.
    """
    els = t["elements"]
    return els[1] if len(els) >= 2 else None


_PROFILE_FIELDS = (
    "name", "display_name", "about", "picture", "banner",
    "nip05", "lud16", "lud06", "website",
)
