#!/usr/bin/env python3
"""Bank roster: Master Pi advertisement + peer register (TID, BANK_POSITION, hostname).

No arrow animation in this module — persist the map only.
"""
from __future__ import annotations

import json
import os
import socket
import threading
import time
from pathlib import Path
from typing import Any, Optional

ADVERTISE_PORT = int(os.environ.get("ETHERGOLD_BANK_ADVERT_PORT", "41236"))
ADVERTISE_MAGIC = "EG-MASTER-V1"
DEFAULT_ROSTER = Path(
    os.environ.get("ETHERGOLD_BANK_ROSTER", "/var/lib/ethergold/bank-roster.json")
)


def roster_path() -> Path:
    return Path(os.environ.get("ETHERGOLD_BANK_ROSTER", str(DEFAULT_ROSTER)))


def empty_roster(*, bank: int = 0, hostname: str = "") -> dict[str, Any]:
    return {
        "schema": "ethergold-bank-roster/1",
        "bank": int(bank),
        "master_hostname": hostname,
        "updated_unix": 0,
        "members": [],
    }


def load_roster(path: Path | None = None) -> dict[str, Any]:
    dest = path or roster_path()
    if not dest.is_file():
        return empty_roster()
    try:
        data = json.loads(dest.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return empty_roster()
    if not isinstance(data, dict):
        return empty_roster()
    data.setdefault("members", [])
    data.setdefault("schema", "ethergold-bank-roster/1")
    return data


def save_roster(roster: dict[str, Any], path: Path | None = None) -> Path:
    dest = path or roster_path()
    dest.parent.mkdir(parents=True, exist_ok=True)
    roster["updated_unix"] = int(time.time())
    tmp = dest.with_suffix(dest.suffix + ".tmp")
    tmp.write_text(json.dumps(roster, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    tmp.replace(dest)
    return dest


def upsert_member(roster: dict[str, Any], member: dict[str, Any]) -> dict[str, Any]:
    tid = str(member.get("tid") or member.get("term_number") or "").strip()
    hostname = str(member.get("hostname") or "").strip()
    key = tid or hostname
    if not key:
        return roster
    record = {
        "tid": tid,
        "term_number": int(member.get("term_number") or 0),
        "bank_position": int(member.get("bank_position") or 0),
        "hostname": hostname,
        "asset_id": str(member.get("asset_id") or ""),
        "machine_role": str(member.get("machine_role") or "").upper(),
        "seen_unix": int(time.time()),
    }
    members = list(roster.get("members") or [])
    replaced = False
    for i, existing in enumerate(members):
        same_tid = tid and str(existing.get("tid") or "") == tid
        same_host = hostname and str(existing.get("hostname") or "") == hostname
        if same_tid or (not tid and same_host):
            members[i] = {**existing, **record}
            replaced = True
            break
    if not replaced:
        members.append(record)
    members.sort(key=lambda m: (int(m.get("bank_position") or 0), str(m.get("hostname") or "")))
    roster["members"] = members
    return roster


def advertise_payload(
    *,
    bank: int,
    hostname: str,
    http_port: int = 31234,
    asset_id: str = "",
    term_number: int = 0,
) -> dict[str, Any]:
    return {
        "magic": ADVERTISE_MAGIC,
        "bank": int(bank),
        "hostname": hostname,
        "http_port": int(http_port),
        "asset_id": asset_id,
        "term_number": int(term_number),
        "unix": int(time.time()),
    }


def _udp_broadcast_loop(stop: threading.Event, payload_fn) -> None:
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        sock.settimeout(1.0)
        while not stop.is_set():
            blob = json.dumps(payload_fn()).encode("utf-8")
            try:
                sock.sendto(blob, ("255.255.255.255", ADVERTISE_PORT))
            except OSError:
                pass
            stop.wait(5.0)
    finally:
        sock.close()


def start_master_advertiser(payload_fn, *, daemon: bool = True) -> threading.Event:
    stop = threading.Event()
    thread = threading.Thread(
        target=_udp_broadcast_loop,
        args=(stop, payload_fn),
        name="eg-bank-advert",
        daemon=daemon,
    )
    thread.start()
    return stop


def listen_for_master(timeout_s: float = 2.0) -> Optional[dict[str, Any]]:
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind(("", ADVERTISE_PORT))
        sock.settimeout(max(0.2, float(timeout_s)))
        raw, _addr = sock.recvfrom(4096)
        data = json.loads(raw.decode("utf-8"))
        if isinstance(data, dict) and data.get("magic") == ADVERTISE_MAGIC:
            return data
    except (OSError, ValueError):
        return None
    finally:
        sock.close()
    return None
