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

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

Covers: PeerRecord, PeerRegistry, REST API, auth, anti-spam protections,
        discovery client, service spec, and health check factory.
"""

from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

import pytest

# ============================================================
# PEER RECORD
# ============================================================


class TestPeerRecord:
    """Test PeerRecord dataclass."""

    def test_to_dict(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        now = datetime.now(timezone.utc).isoformat()
        record = PeerRecord(
            node_id="abc123",
            node_name="Test Node",
            node_type="gateway",
            host="192.168.1.10",
            port=18789,
            fingerprint="aabbccdd",
            version="1.0.0",
            capabilities=["chat", "tools"],
            signature="deadbeef",
            registered_at=now,
            last_heartbeat=now,
            expires_at=now,
        )
        d = record.to_dict()
        assert d["node_id"] == "abc123"
        assert d["node_name"] == "Test Node"
        assert d["capabilities"] == ["chat", "tools"]

    def test_from_dict(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        d = {
            "node_id": "xyz789",
            "node_name": "Remote",
            "node_type": "desktop",
            "host": "10.0.0.5",
            "port": 18789,
            "fingerprint": "11223344",
            "version": "2.0.0",
            "capabilities": ["chat"],
            "signature": "",
            "registered_at": "2026-01-01T00:00:00+00:00",
            "last_heartbeat": "2026-01-01T00:00:00+00:00",
            "expires_at": "2026-01-01T01:00:00+00:00",
        }
        record = PeerRecord.from_dict(d)
        assert record.node_id == "xyz789"
        assert record.port == 18789

    def test_from_dict_string_capabilities(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        d = {
            "node_id": "n1",
            "capabilities": '["chat","tools"]',
        }
        record = PeerRecord.from_dict(d)
        assert record.capabilities == ["chat", "tools"]

    def test_is_expired_true(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="x", node_name="", node_type="", host="", port=0,
            fingerprint="", version="", capabilities=[], signature="",
            registered_at=past, last_heartbeat=past, expires_at=past,
        )
        assert record.is_expired is True

    def test_is_expired_false(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="x", node_name="", node_type="", host="", port=0,
            fingerprint="", version="", capabilities=[], signature="",
            registered_at=future, last_heartbeat=future, expires_at=future,
        )
        assert record.is_expired is False

    def test_has_signature_true(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        now = datetime.now(timezone.utc).isoformat()
        record = PeerRecord(
            node_id="x", node_name="", node_type="", host="", port=0,
            fingerprint="", version="", capabilities=[], signature="abcdef1234567890",
            registered_at=now, last_heartbeat=now, expires_at=now,
        )
        assert record.has_signature is True

    def test_has_signature_false_empty(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        now = datetime.now(timezone.utc).isoformat()
        record = PeerRecord(
            node_id="x", node_name="", node_type="", host="", port=0,
            fingerprint="", version="", capabilities=[], signature="",
            registered_at=now, last_heartbeat=now, expires_at=now,
        )
        assert record.has_signature is False

    def test_has_signature_false_short(self):
        from familiar.core.mesh.rendezvous import PeerRecord

        now = datetime.now(timezone.utc).isoformat()
        record = PeerRecord(
            node_id="x", node_name="", node_type="", host="", port=0,
            fingerprint="", version="", capabilities=[], signature="abc",
            registered_at=now, last_heartbeat=now, expires_at=now,
        )
        assert record.has_signature is False


# ============================================================
# RATE LIMITER
# ============================================================


class TestRateLimiter:
    """Test in-memory sliding window rate limiter."""

    def test_allows_within_limit(self):
        from familiar.core.mesh.rendezvous import RateLimiter

        limiter = RateLimiter(max_requests=5, window_seconds=60)
        for _ in range(5):
            assert limiter.is_allowed("key1") is True

    def test_blocks_over_limit(self):
        from familiar.core.mesh.rendezvous import RateLimiter

        limiter = RateLimiter(max_requests=3, window_seconds=60)
        for _ in range(3):
            assert limiter.is_allowed("key1") is True
        assert limiter.is_allowed("key1") is False

    def test_separate_keys(self):
        from familiar.core.mesh.rendezvous import RateLimiter

        limiter = RateLimiter(max_requests=2, window_seconds=60)
        assert limiter.is_allowed("a") is True
        assert limiter.is_allowed("a") is True
        assert limiter.is_allowed("a") is False
        # Different key should still be allowed
        assert limiter.is_allowed("b") is True

    def test_reset(self):
        from familiar.core.mesh.rendezvous import RateLimiter

        limiter = RateLimiter(max_requests=1, window_seconds=60)
        assert limiter.is_allowed("key") is True
        assert limiter.is_allowed("key") is False
        limiter.reset()
        assert limiter.is_allowed("key") is True


# ============================================================
# PEER REGISTRY
# ============================================================


class TestPeerRegistry:
    """Test PeerRegistry SQLite store."""

    def test_upsert_and_count(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="n1", node_name="Node1", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="fp1", version="1.0",
            capabilities=["chat"], signature="sig1",
            registered_at=future, last_heartbeat=future, expires_at=future,
        )
        registry.upsert(record)
        assert registry.count() == 1

    def test_upsert_updates_existing(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="n1", node_name="Old", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="fp1", version="1.0",
            capabilities=[], signature="",
            registered_at=future, last_heartbeat=future, expires_at=future,
        )
        registry.upsert(record)
        record.node_name = "New"
        registry.upsert(record)
        peers = registry.get_all_active()
        assert len(peers) == 1
        assert peers[0].node_name == "New"

    def test_remove(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="n1", node_name="Node", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="fp1", version="1.0",
            capabilities=[], signature="",
            registered_at=future, last_heartbeat=future, expires_at=future,
        )
        registry.upsert(record)
        assert registry.remove("n1") is True
        assert registry.count() == 0

    def test_remove_nonexistent(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        assert registry.remove("nope") is False

    def test_get_all_active_excludes_expired(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()

        r1 = PeerRecord(
            node_id="active", node_name="Active", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="fp1", version="1.0",
            capabilities=[], signature="",
            registered_at=future, last_heartbeat=future, expires_at=future,
        )
        r2 = PeerRecord(
            node_id="expired", node_name="Expired", node_type="gateway",
            host="5.6.7.8", port=18789, fingerprint="fp2", version="1.0",
            capabilities=[], signature="",
            registered_at=past, last_heartbeat=past, expires_at=past,
        )
        registry.upsert(r1)
        registry.upsert(r2)
        active = registry.get_all_active()
        assert len(active) == 1
        assert active[0].node_id == "active"

    def test_get_by_fingerprint(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="n1", node_name="Node", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="uniquefp", version="1.0",
            capabilities=[], signature="",
            registered_at=future, last_heartbeat=future, expires_at=future,
        )
        registry.upsert(record)
        found = registry.get_by_fingerprint("uniquefp")
        assert found is not None
        assert found.node_id == "n1"

    def test_get_by_fingerprint_not_found(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        assert registry.get_by_fingerprint("nope") is None

    def test_prune_expired(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="old", node_name="Old", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="fp", version="1.0",
            capabilities=[], signature="",
            registered_at=past, last_heartbeat=past, expires_at=past,
        )
        registry.upsert(record)
        pruned = registry.prune_expired()
        assert pruned == 1
        assert registry.count() == 0

    def test_count_empty(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        assert registry.count() == 0

    def test_multiple_peers(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        for i in range(5):
            record = PeerRecord(
                node_id=f"n{i}", node_name=f"Node{i}", node_type="gateway",
                host=f"10.0.0.{i}", port=18789, fingerprint=f"fp{i}",
                version="1.0", capabilities=[], signature="",
                registered_at=future, last_heartbeat=future, expires_at=future,
            )
            registry.upsert(record)
        assert registry.count() == 5
        assert len(registry.get_all_active()) == 5

    def test_count_by_host(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        for i in range(3):
            record = PeerRecord(
                node_id=f"n{i}", node_name=f"Node{i}", node_type="gateway",
                host="1.2.3.4", port=18789, fingerprint=f"fp{i}",
                version="1.0", capabilities=[], signature="",
                registered_at=future, last_heartbeat=future, expires_at=future,
            )
            registry.upsert(record)
        assert registry.count_by_host("1.2.3.4") == 3
        assert registry.count_by_host("9.9.9.9") == 0

    def test_is_registered(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="n1", node_name="Node1", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="fp1", version="1.0",
            capabilities=[], signature="",
            registered_at=future, last_heartbeat=future, expires_at=future,
        )
        registry.upsert(record)
        assert registry.is_registered("n1") is True
        assert registry.is_registered("unknown") is False

    def test_is_registered_expired(self, tmp_path):
        from familiar.core.mesh.rendezvous import PeerRecord, PeerRegistry

        registry = PeerRegistry(db_path=tmp_path / "peers.db")
        past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
        record = PeerRecord(
            node_id="expired", node_name="Old", node_type="gateway",
            host="1.2.3.4", port=18789, fingerprint="fp", version="1.0",
            capabilities=[], signature="",
            registered_at=past, last_heartbeat=past, expires_at=past,
        )
        registry.upsert(record)
        assert registry.is_registered("expired") is False


# ============================================================
# RENDEZVOUS API
# ============================================================


class TestRendezvousAPI:
    """Test the RendezvousServer Flask REST API."""

    @pytest.fixture
    def server(self, tmp_path):
        from familiar.core.mesh.rendezvous import RendezvousServer

        srv = RendezvousServer(
            port=0, ttl=60, db_path=tmp_path / "test.db",
            require_registration_to_list=False,
        )
        return srv

    @pytest.fixture
    def client(self, server):
        app = server.get_app()
        app.config["TESTING"] = True
        return app.test_client()

    def test_health(self, client, server):
        resp = client.get("/api/v1/health")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "ok"
        assert data["peer_count"] == 0
        assert data["ttl_seconds"] == 60
        assert data["peer_cap"] == 500
        assert data["version"] == "2.7.8"

    def test_announce(self, client):
        resp = client.post("/api/v1/announce", json={
            "node_id": "test1",
            "node_name": "Test Node",
            "node_type": "gateway",
            "host": "1.2.3.4",
            "port": 18789,
            "fingerprint": "abcd",
        })
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "registered"
        assert data["node_id"] == "test1"
        assert data["signed"] is False

    def test_announce_signed_gets_full_ttl(self, client, server):
        resp = client.post("/api/v1/announce", json={
            "node_id": "signed1",
            "node_name": "Signed Node",
            "host": "1.2.3.4",
            "port": 18789,
            "signature": "abcdef1234567890",
        })
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["signed"] is True
        assert data["ttl_seconds"] == server.ttl

    def test_announce_unsigned_gets_short_ttl(self, client, server):
        resp = client.post("/api/v1/announce", json={
            "node_id": "unsigned1",
            "node_name": "Unsigned Node",
            "host": "1.2.3.4",
            "port": 18789,
        })
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["signed"] is False
        assert data["ttl_seconds"] == server.ttl_unsigned

    def test_announce_updates_heartbeat(self, client):
        for _ in range(3):
            resp = client.post("/api/v1/announce", json={
                "node_id": "test1",
                "node_name": "Test",
                "host": "1.2.3.4",
                "port": 18789,
            })
            assert resp.status_code == 200

        # Should still be one peer
        resp = client.get("/api/v1/peers")
        data = resp.get_json()
        assert data["count"] == 1

    def test_announce_requires_node_id(self, client):
        resp = client.post("/api/v1/announce", json={"node_name": "No ID"})
        assert resp.status_code == 400

    def test_announce_requires_json(self, client):
        resp = client.post("/api/v1/announce", content_type="application/json")
        assert resp.status_code == 400

    def test_list_peers_empty(self, client):
        resp = client.get("/api/v1/peers")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["count"] == 0
        assert data["peers"] == []

    def test_list_peers_after_announce(self, client):
        client.post("/api/v1/announce", json={
            "node_id": "peer1",
            "node_name": "Peer 1",
            "host": "10.0.0.1",
            "port": 18789,
            "capabilities": ["chat", "tools"],
        })
        resp = client.get("/api/v1/peers")
        data = resp.get_json()
        assert data["count"] == 1
        assert data["peers"][0]["node_id"] == "peer1"
        assert data["peers"][0]["capabilities"] == ["chat", "tools"]

    def test_filter_by_fingerprint(self, client):
        client.post("/api/v1/announce", json={
            "node_id": "a", "node_name": "A", "host": "1.1.1.1",
            "port": 1, "fingerprint": "fp_a",
        })
        client.post("/api/v1/announce", json={
            "node_id": "b", "node_name": "B", "host": "2.2.2.2",
            "port": 2, "fingerprint": "fp_b",
        })
        resp = client.get("/api/v1/peers?fingerprint=fp_a")
        data = resp.get_json()
        assert data["count"] == 1
        assert data["peers"][0]["node_id"] == "a"

    def test_filter_by_node_type(self, client):
        client.post("/api/v1/announce", json={
            "node_id": "g", "node_name": "G", "host": "1.1.1.1",
            "port": 1, "node_type": "gateway",
        })
        client.post("/api/v1/announce", json={
            "node_id": "m", "node_name": "M", "host": "2.2.2.2",
            "port": 2, "node_type": "mobile",
        })
        resp = client.get("/api/v1/peers?node_type=mobile")
        data = resp.get_json()
        assert data["count"] == 1
        assert data["peers"][0]["node_id"] == "m"

    def test_withdraw(self, client):
        client.post("/api/v1/announce", json={
            "node_id": "gone",
            "node_name": "Gone",
            "host": "1.1.1.1",
            "port": 1,
        })
        resp = client.delete("/api/v1/announce/gone")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "withdrawn"

        resp = client.get("/api/v1/peers")
        assert resp.get_json()["count"] == 0

    def test_withdraw_not_found(self, client):
        resp = client.delete("/api/v1/announce/nope")
        assert resp.status_code == 404


# ============================================================
# ANTI-SPAM PROTECTIONS
# ============================================================


class TestAntiSpam:
    """Test rate limiting, per-IP caps, global cap, and give-to-get."""

    @pytest.fixture
    def spam_server(self, tmp_path):
        from familiar.core.mesh.rendezvous import RendezvousServer

        return RendezvousServer(
            port=0, ttl=60, db_path=tmp_path / "spam.db",
            global_peer_cap=3,
            max_per_ip=2,
            rate_announce=5,
            rate_query=5,
            require_registration_to_list=True,
        )

    @pytest.fixture
    def spam_client(self, spam_server):
        app = spam_server.get_app()
        app.config["TESTING"] = True
        return app.test_client()

    def test_announce_rate_limit(self, tmp_path):
        """Announce should return 429 when rate limit exceeded."""
        from familiar.core.mesh.rendezvous import RendezvousServer

        srv = RendezvousServer(
            port=0, ttl=60, db_path=tmp_path / "rate.db",
            rate_announce=2,
            require_registration_to_list=False,
        )
        app = srv.get_app()
        app.config["TESTING"] = True
        client = app.test_client()

        # First two should succeed
        for i in range(2):
            resp = client.post("/api/v1/announce", json={
                "node_id": f"n{i}", "node_name": f"N{i}", "host": "1.2.3.4", "port": 1,
            })
            assert resp.status_code == 200

        # Third should be rate-limited
        resp = client.post("/api/v1/announce", json={
            "node_id": "n2", "node_name": "N2", "host": "1.2.3.4", "port": 1,
        })
        assert resp.status_code == 429
        assert "Rate limit" in resp.get_json()["error"]

    def test_query_rate_limit(self, tmp_path):
        """Peers endpoint should return 429 when rate limit exceeded."""
        from familiar.core.mesh.rendezvous import RendezvousServer

        srv = RendezvousServer(
            port=0, ttl=60, db_path=tmp_path / "qrate.db",
            rate_query=2,
            require_registration_to_list=False,
        )
        app = srv.get_app()
        app.config["TESTING"] = True
        client = app.test_client()

        for _ in range(2):
            resp = client.get("/api/v1/peers")
            assert resp.status_code == 200

        resp = client.get("/api/v1/peers")
        assert resp.status_code == 429

    def test_per_ip_cap(self, spam_client, spam_server):
        """Max registrations per IP should be enforced."""
        # max_per_ip=2; don't pass host so it defaults to client_ip (127.0.0.1)
        for i in range(2):
            resp = spam_client.post("/api/v1/announce", json={
                "node_id": f"n{i}", "node_name": f"N{i}", "port": 1,
            })
            assert resp.status_code == 200

        # Third from same IP should fail
        resp = spam_client.post("/api/v1/announce", json={
            "node_id": "n2", "node_name": "N2", "port": 1,
        })
        assert resp.status_code == 429
        assert "per IP" in resp.get_json()["error"]

    def test_global_peer_cap(self, tmp_path):
        """Global peer cap should return 503."""
        from familiar.core.mesh.rendezvous import RendezvousServer

        srv = RendezvousServer(
            port=0, ttl=60, db_path=tmp_path / "cap.db",
            global_peer_cap=2,
            max_per_ip=10,
            require_registration_to_list=False,
        )
        app = srv.get_app()
        app.config["TESTING"] = True
        client = app.test_client()

        for i in range(2):
            resp = client.post("/api/v1/announce", json={
                "node_id": f"n{i}", "node_name": f"N{i}", "host": f"10.0.0.{i}", "port": 1,
            })
            assert resp.status_code == 200

        resp = client.post("/api/v1/announce", json={
            "node_id": "overflow", "node_name": "Over", "host": "10.0.0.99", "port": 1,
        })
        assert resp.status_code == 503
        assert "capacity" in resp.get_json()["error"]

    def test_give_to_get_enforced(self, spam_client):
        """Listing peers requires registration when enabled."""
        # Not registered — should get 403
        resp = spam_client.get("/api/v1/peers")
        assert resp.status_code == 403
        assert "Registration required" in resp.get_json()["error"]

    def test_give_to_get_with_unknown_node_id(self, spam_client):
        """Passing an unregistered node_id should still be 403."""
        resp = spam_client.get("/api/v1/peers?node_id=unknown")
        assert resp.status_code == 403

    def test_give_to_get_after_announce(self, spam_client):
        """After announcing, listing should work."""
        spam_client.post("/api/v1/announce", json={
            "node_id": "registered1",
            "node_name": "R1",
            "host": "1.2.3.4",
            "port": 1,
        })
        resp = spam_client.get("/api/v1/peers?node_id=registered1")
        assert resp.status_code == 200
        assert resp.get_json()["count"] == 1

    def test_upsert_bypasses_ip_cap(self, spam_client):
        """Updating an existing record should not count as new registration."""
        # Register 2 (max_per_ip=2); don't pass host so it defaults to client_ip
        for i in range(2):
            resp = spam_client.post("/api/v1/announce", json={
                "node_id": f"n{i}", "node_name": f"N{i}", "port": 1,
            })
            assert resp.status_code == 200

        # Update n0 — should succeed (existing record)
        resp = spam_client.post("/api/v1/announce", json={
            "node_id": "n0", "node_name": "Updated", "port": 1,
        })
        assert resp.status_code == 200
        assert resp.get_json()["status"] == "registered"

    def test_host_defaults_to_client_ip(self, tmp_path):
        """When host is not provided, it should default to client IP."""
        from familiar.core.mesh.rendezvous import RendezvousServer

        srv = RendezvousServer(
            port=0, ttl=60, db_path=tmp_path / "ip.db",
            require_registration_to_list=False,
        )
        app = srv.get_app()
        app.config["TESTING"] = True
        client = app.test_client()

        resp = client.post("/api/v1/announce", json={
            "node_id": "nohost", "node_name": "NoHost", "port": 1,
        })
        assert resp.status_code == 200

        peers = client.get("/api/v1/peers").get_json()["peers"]
        assert len(peers) == 1
        # Flask test client uses 127.0.0.1
        assert peers[0]["host"] == "127.0.0.1"


# ============================================================
# RENDEZVOUS AUTH
# ============================================================


class TestRendezvousAuth:
    """Test Bearer token auth enforcement."""

    @pytest.fixture
    def auth_client(self, tmp_path):
        from familiar.core.mesh.rendezvous import RendezvousServer

        srv = RendezvousServer(
            port=0, ttl=60, auth_token="secret123",
            db_path=tmp_path / "auth.db",
        )
        app = srv.get_app()
        app.config["TESTING"] = True
        return app.test_client()

    def test_health_no_token_rejected(self, auth_client):
        resp = auth_client.get("/api/v1/health")
        assert resp.status_code == 401

    def test_health_wrong_token_rejected(self, auth_client):
        resp = auth_client.get("/api/v1/health", headers={
            "Authorization": "Bearer wrongtoken"
        })
        assert resp.status_code == 401

    def test_health_correct_token(self, auth_client):
        resp = auth_client.get("/api/v1/health", headers={
            "Authorization": "Bearer secret123"
        })
        assert resp.status_code == 200

    def test_announce_requires_token(self, auth_client):
        resp = auth_client.post("/api/v1/announce", json={
            "node_id": "n1", "node_name": "N", "host": "1.1.1.1", "port": 1,
        })
        assert resp.status_code == 401

    def test_announce_with_token(self, auth_client):
        resp = auth_client.post("/api/v1/announce", json={
            "node_id": "n1", "node_name": "N", "host": "1.1.1.1", "port": 1,
        }, headers={"Authorization": "Bearer secret123"})
        assert resp.status_code == 200


# ============================================================
# DISCOVERY CLIENT
# ============================================================


class TestDiscoveryClient:
    """Test rendezvous client methods on MeshDiscovery."""

    @pytest.fixture
    def discovery(self, tmp_path):
        from familiar.core.mesh.discovery import MeshDiscovery

        return MeshDiscovery(
            node_id="local123",
            node_name="Local Node",
            node_type="gateway",
            port=18789,
            mesh_dir=tmp_path,
            rendezvous_servers=["http://rdv.example.com:18790"],
            rendezvous_token="tok",
            rendezvous_announce_interval=300,
            rendezvous_poll_interval=60,
        )

    def test_rendezvous_params_stored(self, discovery):
        assert discovery.rendezvous_servers == ["http://rdv.example.com:18790"]
        assert discovery.rendezvous_token == "tok"
        assert discovery.rendezvous_announce_interval == 300
        assert discovery.rendezvous_poll_interval == 60

    def test_start_stop_rendezvous(self, discovery):
        discovery.start_rendezvous()
        assert discovery._rendezvous_running is True
        assert discovery._rendezvous_thread is not None
        assert discovery._rendezvous_thread.is_alive()

        discovery.stop_rendezvous()
        assert discovery._rendezvous_running is False

    def test_start_rendezvous_no_servers(self, tmp_path):
        from familiar.core.mesh.discovery import MeshDiscovery

        d = MeshDiscovery(
            node_id="x", node_name="X", mesh_dir=tmp_path,
            rendezvous_servers=[],
        )
        d.start_rendezvous()
        assert d._rendezvous_running is False

    @patch("familiar.core.mesh.discovery.MeshDiscovery._rendezvous_discover")
    @patch("familiar.core.mesh.discovery.MeshDiscovery._rendezvous_announce")
    def test_announce_called_in_loop(self, mock_announce, mock_discover, discovery):
        """Verify announce is called for each server during loop iteration."""
        for url in discovery.rendezvous_servers:
            discovery._rendezvous_announce(url)
        mock_announce.assert_called_once_with("http://rdv.example.com:18790")

    def test_rendezvous_announce_with_httpx(self, discovery):
        """Test _rendezvous_announce makes correct HTTP call."""
        mock_response = MagicMock()
        mock_response.status_code = 200

        with patch("httpx.post", return_value=mock_response) as mock_post:
            discovery._rendezvous_announce("http://rdv.example.com:18790")
            mock_post.assert_called_once()
            args, kwargs = mock_post.call_args
            assert args[0] == "http://rdv.example.com:18790/api/v1/announce"
            assert kwargs["json"]["node_id"] == "local123"
            assert kwargs["headers"]["Authorization"] == "Bearer tok"

    def test_rendezvous_discover_creates_peers(self, discovery):
        """Test _rendezvous_discover creates DiscoveredPeer entries."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "peers": [
                {
                    "node_id": "remote456",
                    "node_name": "Remote Node",
                    "node_type": "gateway",
                    "host": "5.6.7.8",
                    "port": 18789,
                    "fingerprint": "fp_remote",
                    "version": "1.0",
                    "capabilities": ["chat"],
                },
            ],
            "count": 1,
        }

        found_peers = []
        discovery._on_peer_found = lambda p: found_peers.append(p)

        with patch("httpx.get", return_value=mock_response):
            discovery._rendezvous_discover("http://rdv.example.com:18790")

        assert len(found_peers) == 1
        assert found_peers[0].node_id == "remote456"
        assert found_peers[0].discovery_source == "rendezvous"

    def test_rendezvous_discover_skips_self(self, discovery):
        """Self node_id should be skipped."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "peers": [
                {
                    "node_id": "local123",  # Same as our node_id
                    "node_name": "Self",
                    "host": "1.1.1.1",
                    "port": 18789,
                },
            ],
            "count": 1,
        }

        found_peers = []
        discovery._on_peer_found = lambda p: found_peers.append(p)

        with patch("httpx.get", return_value=mock_response):
            discovery._rendezvous_discover("http://rdv.example.com:18790")

        assert len(found_peers) == 0

    def test_rendezvous_withdraw(self, discovery):
        """Test withdraw sends DELETE."""
        mock_response = MagicMock()
        mock_response.status_code = 200

        with patch("httpx.delete", return_value=mock_response) as mock_del:
            discovery._rendezvous_withdraw("http://rdv.example.com:18790")
            mock_del.assert_called_once()
            url = mock_del.call_args[0][0]
            assert "local123" in url

    def test_rendezvous_discover_passes_node_id(self, discovery):
        """Test that discover passes node_id query param for give-to-get."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"peers": [], "count": 0}

        with patch("httpx.get", return_value=mock_response) as mock_get:
            discovery._rendezvous_discover("http://rdv.example.com:18790")
            mock_get.assert_called_once()
            url = mock_get.call_args[0][0]
            assert "node_id=local123" in url


# ============================================================
# SERVICE SPEC
# ============================================================


class TestServiceSpec:
    """Test rendezvous service spec registration."""

    def test_spec_registered(self):
        from familiar.services.specs import SERVICE_SPECS

        assert "rendezvous" in SERVICE_SPECS

    def test_spec_type_native(self):
        from familiar.services.specs import SERVICE_SPECS, ServiceType

        spec = SERVICE_SPECS["rendezvous"]
        assert spec.service_type == ServiceType.NATIVE

    def test_spec_health_port(self):
        from familiar.services.specs import SERVICE_SPECS

        spec = SERVICE_SPECS["rendezvous"]
        assert spec.health_check_port == 18790
        assert spec.health_check_path == "/api/v1/health"


# ============================================================
# HEALTH CHECK
# ============================================================


class TestHealthCheck:
    """Test rendezvous health check factory."""

    def test_healthy(self):
        from familiar.core.health import create_rendezvous_health_check

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"status": "ok", "peer_count": 3}

        check = create_rendezvous_health_check("http://rdv.example.com:18790")
        with patch("httpx.get", return_value=mock_response):
            result = check()
        assert result.healthy is True
        assert "3" in result.message

    def test_unhealthy(self):
        from familiar.core.health import create_rendezvous_health_check

        check = create_rendezvous_health_check("http://unreachable:18790")
        with patch("httpx.get", side_effect=Exception("Connection refused")):
            result = check()
        assert result.healthy is False
        assert "Connection refused" in result.message


# ============================================================
# START LOCAL SERVER
# ============================================================


class TestStartLocalServer:
    """Test the start_local_server helper."""

    def test_returns_thread(self, tmp_path):
        """start_local_server should return a daemon thread."""
        with patch("familiar.core.mesh.rendezvous.RendezvousServer.start"):
            from familiar.core.mesh.rendezvous import start_local_server

            thread = start_local_server(port=19999, db_path=tmp_path / "local.db")
            assert thread.is_alive() or True  # Thread started (start is mocked)
            assert thread.daemon is True
            assert thread.name == "rdv-local-server"

    def test_custom_port_and_token(self, tmp_path):
        """Should pass port and token to RendezvousServer."""
        with patch("familiar.core.mesh.rendezvous.RendezvousServer.__init__",
                    return_value=None) as mock_init, \
             patch("familiar.core.mesh.rendezvous.RendezvousServer.start"):
            from familiar.core.mesh.rendezvous import start_local_server

            start_local_server(port=12345, auth_token="tok", db_path=tmp_path / "x.db")
            mock_init.assert_called_once_with(
                port=12345, auth_token="tok", db_path=tmp_path / "x.db"
            )


# ============================================================
# COMMUNITY URL CONSTANT
# ============================================================


class TestConstants:
    """Test module-level constants."""

    def test_community_rdv_url(self):
        from familiar.core.mesh.rendezvous import COMMUNITY_RDV_URL

        assert COMMUNITY_RDV_URL == "https://rdv.familiar.community"

    def test_default_port(self):
        from familiar.core.mesh.rendezvous import DEFAULT_RDV_PORT

        assert DEFAULT_RDV_PORT == 18790

    def test_default_caps(self):
        from familiar.core.mesh.rendezvous import (
            DEFAULT_GLOBAL_PEER_CAP,
            DEFAULT_MAX_PER_IP,
        )

        assert DEFAULT_GLOBAL_PEER_CAP == 500
        assert DEFAULT_MAX_PER_IP == 5
