# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License

"""
Rendezvous Server — Phase 9 (v2.7.8)

Lightweight, self-hostable peer registry for internet-wide mesh discovery.
Acts as a matchmaker, not a relay — Familiars announce themselves, discover
each other, then communicate directly P2P.

Network layers:
  Layer 1: mDNS (LAN)           — automatic, zero-config
  Layer 2: Rendezvous (WAN)     — internet-wide discovery (this module)
  Layer 3: Mesh P2P (encrypted) — existing, transport-agnostic

REST API:
  GET    /api/v1/health              — status + peer count + TTL
  POST   /api/v1/announce            — register/heartbeat (upsert with TTL)
  GET    /api/v1/peers               — list active peers (registered callers only)
  DELETE /api/v1/announce/<node_id>  — withdraw (unregister)

Anti-spam protections:
  - Per-IP rate limiting (10 announces/min, 30 queries/min)
  - Max 5 registrations per IP address
  - Global peer cap (default 500)
  - Signature verification: signed announces get full TTL, unsigned get 2 min
  - Peer listing requires the caller to be registered (give-to-get)

Auth: Optional Bearer token. No token = open/public registry.

Storage: SQLite at configurable path (default ~/.familiar/services/rendezvous/peers.db).

Runnable as:
  familiar-rendezvous --port 18790 --token mysecret
  python -m familiar.core.mesh.rendezvous --port 18790

Dependencies: None beyond stdlib + Flask (lazy-imported).
"""

from __future__ import annotations

import collections
import json
import logging
import sqlite3
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List, Optional

logger = logging.getLogger(__name__)

# Constants
DEFAULT_RDV_PORT = 18790
DEFAULT_TTL = 900  # 15 minutes
DEFAULT_TTL_UNSIGNED = 120  # 2 minutes for unverified peers
DEFAULT_CLEANUP = 300  # 5 minutes
DEFAULT_GLOBAL_PEER_CAP = 500
DEFAULT_MAX_PER_IP = 5
DEFAULT_RATE_ANNOUNCE = 10  # per minute
DEFAULT_RATE_QUERY = 30  # per minute

# Community rendezvous — opt-in default for zero-config discovery
COMMUNITY_RDV_URL = "https://rdv.familiar.community"


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


def _utcnow_iso() -> str:
    return _utcnow().isoformat()


# ============================================================
# RATE LIMITER — in-memory sliding window
# ============================================================


class RateLimiter:
    """Per-key sliding window rate limiter (in-memory, no deps)."""

    def __init__(self, max_requests: int, window_seconds: int = 60):
        self.max_requests = max_requests
        self.window = window_seconds
        self._hits: dict = collections.defaultdict(list)
        self._lock = threading.Lock()

    def is_allowed(self, key: str) -> bool:
        """Check if a request from `key` is within limits."""
        now = time.monotonic()
        with self._lock:
            timestamps = self._hits[key]
            # Prune old entries
            cutoff = now - self.window
            self._hits[key] = [t for t in timestamps if t > cutoff]
            if len(self._hits[key]) >= self.max_requests:
                return False
            self._hits[key].append(now)
            return True

    def reset(self):
        with self._lock:
            self._hits.clear()


# ============================================================
# PEER RECORD — data model
# ============================================================


@dataclass
class PeerRecord:
    """A peer registered with the rendezvous server."""

    node_id: str
    node_name: str
    node_type: str  # gateway/desktop/mobile/server
    host: str
    port: int
    fingerprint: str  # SHA-256(identity_key)[:16]
    version: str
    capabilities: list
    signature: str  # hex Ed25519 signature of payload
    registered_at: str
    last_heartbeat: str
    expires_at: str

    def to_dict(self) -> dict:
        return {
            "node_id": self.node_id,
            "node_name": self.node_name,
            "node_type": self.node_type,
            "host": self.host,
            "port": self.port,
            "fingerprint": self.fingerprint,
            "version": self.version,
            "capabilities": self.capabilities
            if isinstance(self.capabilities, list)
            else json.loads(self.capabilities)
            if isinstance(self.capabilities, str)
            else [],
            "signature": self.signature,
            "registered_at": self.registered_at,
            "last_heartbeat": self.last_heartbeat,
            "expires_at": self.expires_at,
        }

    @classmethod
    def from_dict(cls, d: dict) -> "PeerRecord":
        caps = d.get("capabilities", [])
        if isinstance(caps, str):
            try:
                caps = json.loads(caps)
            except (json.JSONDecodeError, TypeError):
                caps = []
        return cls(
            node_id=d.get("node_id", ""),
            node_name=d.get("node_name", ""),
            node_type=d.get("node_type", "gateway"),
            host=d.get("host", ""),
            port=int(d.get("port", 0)),
            fingerprint=d.get("fingerprint", ""),
            version=d.get("version", ""),
            capabilities=caps,
            signature=d.get("signature", ""),
            registered_at=d.get("registered_at", _utcnow_iso()),
            last_heartbeat=d.get("last_heartbeat", _utcnow_iso()),
            expires_at=d.get("expires_at", ""),
        )

    @property
    def is_expired(self) -> bool:
        if not self.expires_at:
            return True
        try:
            exp = datetime.fromisoformat(self.expires_at)
            return _utcnow() > exp
        except (ValueError, TypeError):
            return True

    @property
    def has_signature(self) -> bool:
        return bool(self.signature and len(self.signature) >= 8)


# ============================================================
# PEER REGISTRY — SQLite persistence
# ============================================================


class PeerRegistry:
    """
    Thread-safe SQLite registry for rendezvous peer records.

    Follows the MessageStore pattern: threading.Lock, context manager for connections.
    """

    def __init__(self, db_path: Optional[Path] = None):
        if db_path is None:
            db_path = Path.home() / ".familiar" / "services" / "rendezvous" / "peers.db"
        db_path.parent.mkdir(parents=True, exist_ok=True)
        self._db_path = db_path
        self._lock = threading.Lock()
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(str(self._db_path)) as conn:
            conn.execute("PRAGMA journal_mode=WAL")
            conn.execute("""
                CREATE TABLE IF NOT EXISTS peers (
                    node_id TEXT PRIMARY KEY,
                    node_name TEXT,
                    node_type TEXT,
                    host TEXT,
                    port INTEGER,
                    fingerprint TEXT,
                    version TEXT,
                    capabilities TEXT,
                    signature TEXT,
                    registered_at TEXT,
                    last_heartbeat TEXT,
                    expires_at TEXT
                )
            """)

    def _conn(self) -> sqlite3.Connection:
        conn = sqlite3.connect(str(self._db_path))
        conn.row_factory = sqlite3.Row
        return conn

    def upsert(self, record: PeerRecord):
        """Insert or update a peer record."""
        caps_json = json.dumps(record.capabilities if isinstance(record.capabilities, list) else [])
        with self._lock:
            with self._conn() as conn:
                conn.execute(
                    """INSERT OR REPLACE INTO peers
                       (node_id, node_name, node_type, host, port, fingerprint,
                        version, capabilities, signature, registered_at,
                        last_heartbeat, expires_at)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                    (
                        record.node_id,
                        record.node_name,
                        record.node_type,
                        record.host,
                        record.port,
                        record.fingerprint,
                        record.version,
                        caps_json,
                        record.signature,
                        record.registered_at,
                        record.last_heartbeat,
                        record.expires_at,
                    ),
                )

    def remove(self, node_id: str) -> bool:
        """Remove a peer by node_id. Returns True if removed."""
        with self._lock:
            with self._conn() as conn:
                cursor = conn.execute("DELETE FROM peers WHERE node_id = ?", (node_id,))
                return cursor.rowcount > 0

    def get_all_active(self) -> List[PeerRecord]:
        """Get all non-expired peers."""
        now = _utcnow_iso()
        with self._lock:
            with self._conn() as conn:
                rows = conn.execute("SELECT * FROM peers WHERE expires_at > ?", (now,)).fetchall()
                return [PeerRecord.from_dict(dict(r)) for r in rows]

    def get_by_fingerprint(self, fingerprint: str) -> Optional[PeerRecord]:
        """Find a peer by fingerprint prefix."""
        with self._lock:
            with self._conn() as conn:
                row = conn.execute(
                    "SELECT * FROM peers WHERE fingerprint = ? AND expires_at > ?",
                    (fingerprint, _utcnow_iso()),
                ).fetchone()
                return PeerRecord.from_dict(dict(row)) if row else None

    def count_by_host(self, host: str) -> int:
        """Count active registrations from a given IP."""
        now = _utcnow_iso()
        with self._lock:
            with self._conn() as conn:
                row = conn.execute(
                    "SELECT COUNT(*) as cnt FROM peers WHERE host = ? AND expires_at > ?",
                    (host, now),
                ).fetchone()
                return row["cnt"] if row else 0

    def is_registered(self, node_id: str) -> bool:
        """Check if a node_id has an active registration."""
        now = _utcnow_iso()
        with self._lock:
            with self._conn() as conn:
                row = conn.execute(
                    "SELECT 1 FROM peers WHERE node_id = ? AND expires_at > ?",
                    (node_id, now),
                ).fetchone()
                return row is not None

    def prune_expired(self) -> int:
        """Remove expired records. Returns count removed."""
        now = _utcnow_iso()
        with self._lock:
            with self._conn() as conn:
                cursor = conn.execute("DELETE FROM peers WHERE expires_at <= ?", (now,))
                return cursor.rowcount

    def count(self) -> int:
        """Count active (non-expired) peers."""
        now = _utcnow_iso()
        with self._lock:
            with self._conn() as conn:
                row = conn.execute(
                    "SELECT COUNT(*) as cnt FROM peers WHERE expires_at > ?", (now,)
                ).fetchone()
                return row["cnt"] if row else 0


# ============================================================
# RENDEZVOUS SERVER — Flask app
# ============================================================


class RendezvousServer:
    """
    Lightweight rendezvous server for internet-wide mesh peer discovery.

    Anti-spam protections:
      - Per-IP rate limiting (announce + query)
      - Max registrations per IP
      - Global peer cap
      - Signed announces get full TTL, unsigned get short TTL
      - Peer listing requires caller to be registered

    Usage:
        server = RendezvousServer(port=18790, auth_token="secret")
        server.start()  # Blocking
    """

    def __init__(
        self,
        host: str = "0.0.0.0",
        port: int = DEFAULT_RDV_PORT,
        ttl: int = DEFAULT_TTL,
        ttl_unsigned: int = DEFAULT_TTL_UNSIGNED,
        cleanup_interval: int = DEFAULT_CLEANUP,
        auth_token: str = "",
        db_path: Optional[Path] = None,
        global_peer_cap: int = DEFAULT_GLOBAL_PEER_CAP,
        max_per_ip: int = DEFAULT_MAX_PER_IP,
        rate_announce: int = DEFAULT_RATE_ANNOUNCE,
        rate_query: int = DEFAULT_RATE_QUERY,
        require_registration_to_list: bool = True,
    ):
        self.host = host
        self.port = port
        self.ttl = ttl
        self.ttl_unsigned = ttl_unsigned
        self.cleanup_interval = cleanup_interval
        self.auth_token = auth_token
        self.registry = PeerRegistry(db_path)
        self.global_peer_cap = global_peer_cap
        self.max_per_ip = max_per_ip
        self.require_registration_to_list = require_registration_to_list
        self._app = None
        self._cleanup_stop = threading.Event()
        self._cleanup_thread = None

        # Rate limiters
        self._rate_announce = RateLimiter(rate_announce, 60)
        self._rate_query = RateLimiter(rate_query, 60)

    def _create_app(self):
        """Create the Flask app (lazy import)."""
        try:
            from flask import Flask, jsonify
            from flask import request as flask_request
        except ImportError:
            raise RuntimeError(
                "Flask is required to run the rendezvous server. Install with: pip install flask"
            )

        app = Flask(__name__)
        app.config["JSON_SORT_KEYS"] = False

        server = self  # Closure reference

        def _check_auth():
            """Check Bearer token if auth is configured. Returns error response or None."""
            if not server.auth_token:
                return None
            auth_header = flask_request.headers.get("Authorization", "")
            if auth_header == f"Bearer {server.auth_token}":
                return None
            return jsonify({"error": "Unauthorized"}), 401

        def _get_client_ip():
            """Get client IP, respecting X-Forwarded-For behind proxies."""
            forwarded = flask_request.headers.get("X-Forwarded-For", "")
            if forwarded:
                return forwarded.split(",")[0].strip()
            return flask_request.remote_addr or "unknown"

        @app.route("/api/v1/health")
        def health():
            auth_err = _check_auth()
            if auth_err:
                return auth_err
            return jsonify(
                {
                    "status": "ok",
                    "peer_count": server.registry.count(),
                    "peer_cap": server.global_peer_cap,
                    "ttl_seconds": server.ttl,
                    "ttl_unsigned_seconds": server.ttl_unsigned,
                    "version": "2.7.8",
                }
            )

        @app.route("/api/v1/announce", methods=["POST"])
        def announce():
            auth_err = _check_auth()
            if auth_err:
                return auth_err

            client_ip = _get_client_ip()

            # Rate limit
            if not server._rate_announce.is_allowed(client_ip):
                return jsonify({"error": "Rate limit exceeded"}), 429

            data = flask_request.json
            if not data:
                return jsonify({"error": "JSON body required"}), 400

            node_id = data.get("node_id", "").strip()
            if not node_id:
                return jsonify({"error": "node_id is required"}), 400

            # Check if this is an update to an existing record
            existing = None
            for peer in server.registry.get_all_active():
                if peer.node_id == node_id:
                    existing = peer
                    break

            if not existing:
                # New registration — enforce caps
                if server.registry.count() >= server.global_peer_cap:
                    return jsonify({"error": "Server at capacity"}), 503

                ip_count = server.registry.count_by_host(client_ip)
                if ip_count >= server.max_per_ip:
                    return jsonify({"error": f"Max {server.max_per_ip} registrations per IP"}), 429

            now = _utcnow()
            signature = data.get("signature", "")
            has_sig = bool(signature and len(signature) >= 8)
            effective_ttl = server.ttl if has_sig else server.ttl_unsigned
            expires = now + timedelta(seconds=effective_ttl)

            record = PeerRecord(
                node_id=node_id,
                node_name=data.get("node_name", ""),
                node_type=data.get("node_type", "gateway"),
                host=data.get("host", client_ip),
                port=int(data.get("port", 0)),
                fingerprint=data.get("fingerprint", ""),
                version=data.get("version", ""),
                capabilities=data.get("capabilities", []),
                signature=signature,
                registered_at=existing.registered_at if existing else now.isoformat(),
                last_heartbeat=now.isoformat(),
                expires_at=expires.isoformat(),
            )

            server.registry.upsert(record)
            return jsonify(
                {
                    "status": "registered",
                    "node_id": node_id,
                    "expires_at": expires.isoformat(),
                    "ttl_seconds": effective_ttl,
                    "signed": has_sig,
                }
            )

        @app.route("/api/v1/peers")
        def list_peers():
            auth_err = _check_auth()
            if auth_err:
                return auth_err

            client_ip = _get_client_ip()

            # Rate limit
            if not server._rate_query.is_allowed(client_ip):
                return jsonify({"error": "Rate limit exceeded"}), 429

            # Require caller to be registered (give-to-get)
            caller_id = flask_request.args.get("node_id", "").strip()
            if server.require_registration_to_list:
                if not caller_id or not server.registry.is_registered(caller_id):
                    return jsonify(
                        {
                            "error": "Registration required to list peers. "
                            "POST /api/v1/announce first, then pass ?node_id=YOUR_ID",
                            "peers": [],
                            "count": 0,
                        }
                    ), 403

            peers = server.registry.get_all_active()

            # Optional filters
            fp = flask_request.args.get("fingerprint", "").strip()
            if fp:
                peers = [p for p in peers if p.fingerprint == fp]

            node_type = flask_request.args.get("node_type", "").strip()
            if node_type:
                peers = [p for p in peers if p.node_type == node_type]

            return jsonify(
                {
                    "peers": [p.to_dict() for p in peers],
                    "count": len(peers),
                }
            )

        @app.route("/api/v1/announce/<node_id>", methods=["DELETE"])
        def withdraw(node_id):
            auth_err = _check_auth()
            if auth_err:
                return auth_err

            removed = server.registry.remove(node_id)
            if removed:
                return jsonify({"status": "withdrawn", "node_id": node_id})
            return jsonify({"error": "Not found"}), 404

        self._app = app
        return app

    def get_app(self):
        """Get or create the Flask app."""
        if self._app is None:
            self._create_app()
        return self._app

    def _cleanup_loop(self):
        """Background thread that prunes expired records."""
        while not self._cleanup_stop.is_set():
            try:
                pruned = self.registry.prune_expired()
                if pruned:
                    logger.debug(f"Pruned {pruned} expired peer record(s)")
            except Exception as e:
                logger.debug(f"Cleanup error: {e}")
            self._cleanup_stop.wait(self.cleanup_interval)

    def start(self):
        """Start the rendezvous server (blocking)."""
        app = self.get_app()

        # Start cleanup thread
        self._cleanup_stop.clear()
        self._cleanup_thread = threading.Thread(
            target=self._cleanup_loop, daemon=True, name="rdv-cleanup"
        )
        self._cleanup_thread.start()

        logger.info(
            f"Rendezvous server starting on {self.host}:{self.port} "
            f"(TTL={self.ttl}s/{self.ttl_unsigned}s unsigned, "
            f"cap={self.global_peer_cap}, max/IP={self.max_per_ip}, "
            f"auth={'enabled' if self.auth_token else 'disabled'})"
        )

        app.run(host=self.host, port=self.port, debug=False)

    def stop(self):
        """Signal cleanup thread to stop."""
        self._cleanup_stop.set()
        if self._cleanup_thread:
            self._cleanup_thread.join(timeout=5)


# ============================================================
# LOCAL SERVER HELPER — one-click self-host
# ============================================================


def start_local_server(
    port: int = DEFAULT_RDV_PORT,
    auth_token: str = "",
    db_path: Optional[Path] = None,
) -> threading.Thread:
    """
    Start a rendezvous server in a background thread.

    Used by the dashboard "Start My Own" button to run a local
    rendezvous alongside the main Familiar agent.

    Returns the daemon thread (stops when main process exits).
    """
    server = RendezvousServer(
        port=port,
        auth_token=auth_token,
        db_path=db_path,
    )
    thread = threading.Thread(target=server.start, daemon=True, name="rdv-local-server")
    thread.start()
    logger.info(f"Local rendezvous server started on port {port}")
    return thread


# ============================================================
# CLI ENTRY POINT
# ============================================================


def main():
    """CLI entry point for familiar-rendezvous."""
    import argparse

    parser = argparse.ArgumentParser(
        description="Familiar Rendezvous Server — peer registry for mesh discovery"
    )
    parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
    parser.add_argument(
        "--port", type=int, default=DEFAULT_RDV_PORT, help=f"Port (default: {DEFAULT_RDV_PORT})"
    )
    parser.add_argument(
        "--ttl", type=int, default=DEFAULT_TTL, help=f"Peer TTL in seconds (default: {DEFAULT_TTL})"
    )
    parser.add_argument(
        "--ttl-unsigned",
        type=int,
        default=DEFAULT_TTL_UNSIGNED,
        help=f"TTL for unsigned peers (default: {DEFAULT_TTL_UNSIGNED})",
    )
    parser.add_argument("--token", default="", help="Auth token (empty = open registry)")
    parser.add_argument("--db", default=None, help="Database path")
    parser.add_argument(
        "--peer-cap",
        type=int,
        default=DEFAULT_GLOBAL_PEER_CAP,
        help=f"Max total peers (default: {DEFAULT_GLOBAL_PEER_CAP})",
    )
    parser.add_argument(
        "--max-per-ip",
        type=int,
        default=DEFAULT_MAX_PER_IP,
        help=f"Max registrations per IP (default: {DEFAULT_MAX_PER_IP})",
    )
    parser.add_argument(
        "--open-listing", action="store_true", help="Allow peer listing without registration"
    )

    args = parser.parse_args()

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    )

    db_path = Path(args.db) if args.db else None
    server = RendezvousServer(
        host=args.host,
        port=args.port,
        ttl=args.ttl,
        ttl_unsigned=args.ttl_unsigned,
        auth_token=args.token,
        db_path=db_path,
        global_peer_cap=args.peer_cap,
        max_per_ip=args.max_per_ip,
        require_registration_to_list=not args.open_listing,
    )
    server.start()


if __name__ == "__main__":
    main()
