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

"""
Tests for Mesh Phase 8: Encrypted Peer-to-Peer Messaging.

Covers MessageStore, MessageBridge, and PermissionMatrix.send_messages.
"""

import json
import sqlite3
import threading
import time
from unittest.mock import MagicMock

import pytest

# ============================================================
# Fixtures
# ============================================================


@pytest.fixture
def mesh_dir(tmp_path):
    d = tmp_path / "mesh"
    d.mkdir()
    return d


@pytest.fixture
def store(mesh_dir):
    from familiar.core.mesh.message_bridge import MessageStore

    return MessageStore(mesh_dir)


@pytest.fixture
def mock_trust_manager():
    tm = MagicMock()
    tm.has_crypto = True
    tm.check_permission = MagicMock(return_value=True)
    tm.encrypt_for_peer = MagicMock(return_value=b"\x00" * 32)
    tm.decrypt_from_peer = MagicMock(
        return_value=json.dumps({
            "msg_id": "test-msg-1",
            "sender_name": "Peer A",
            "body": "Hello!",
            "timestamp": "2026-03-07T12:00:00+00:00",
        }).encode()
    )
    tm.get_trust_record = MagicMock(return_value=MagicMock(node_name="Peer A"))
    return tm


def _make_async_ws():
    """Create a mock websocket whose send() is an async coroutine."""
    ws = MagicMock()

    async def _send(data):
        pass

    ws.send = _send
    return ws


@pytest.fixture
def mock_gateway():
    gw = MagicMock()
    gw.peer_gateways = {}
    gw.peer_gateway_info = {}
    return gw


@pytest.fixture
def bridge(mesh_dir, mock_trust_manager, mock_gateway):
    from familiar.core.mesh.message_bridge import MessageBridge

    return MessageBridge(
        gateway=mock_gateway,
        trust_manager=mock_trust_manager,
        node_id="node-local",
        node_name="Local Node",
        mesh_dir=mesh_dir,
    )


# ============================================================
# MessageStore tests (~12)
# ============================================================


class TestMessageStore:
    def test_init_creates_tables(self, mesh_dir):
        from familiar.core.mesh.message_bridge import MessageStore

        MessageStore(mesh_dir)
        db_path = mesh_dir / "messages.db"
        assert db_path.exists()

        with sqlite3.connect(str(db_path)) as conn:
            tables = {
                r[0]
                for r in conn.execute(
                    "SELECT name FROM sqlite_master WHERE type='table'"
                ).fetchall()
            }
        assert "conversations" in tables
        assert "messages" in tables
        assert "outbox" in tables

    def test_upsert_and_get_conversations(self, store):
        store.upsert_conversation("peer-1", "Alice", "Hi!", "2026-03-07T12:00:00")
        store.upsert_conversation("peer-2", "Bob", "Hey", "2026-03-07T12:01:00")

        convs = store.get_conversations()
        assert len(convs) == 2
        # Ordered by last_ts DESC
        assert convs[0]["peer_name"] == "Bob"
        assert convs[1]["peer_name"] == "Alice"

    def test_upsert_updates_existing(self, store):
        store.upsert_conversation("peer-1", "Alice", "Hi!", "2026-03-07T12:00:00")
        store.upsert_conversation("peer-1", "Alice", "Updated!", "2026-03-07T12:05:00")

        convs = store.get_conversations()
        assert len(convs) == 1
        assert convs[0]["last_message"] == "Updated!"

    def test_unread_count_increment(self, store):
        store.upsert_conversation("peer-1", "Alice", "Hi!", "2026-03-07T12:00:00")
        assert store.total_unread() == 0

        store.upsert_conversation(
            "peer-1", "Alice", "Msg 1", "2026-03-07T12:01:00", increment_unread=True
        )
        assert store.total_unread() == 1

        store.upsert_conversation(
            "peer-1", "Alice", "Msg 2", "2026-03-07T12:02:00", increment_unread=True
        )
        assert store.total_unread() == 2

    def test_mark_read(self, store):
        store.upsert_conversation(
            "peer-1", "Alice", "Hi!", "2026-03-07T12:00:00", increment_unread=True
        )
        assert store.total_unread() == 1

        store.mark_read("peer-1")
        assert store.total_unread() == 0

    def test_insert_and_get_messages(self, store):
        store.insert_message("m1", "peer-1", "outgoing", "Hello", "2026-03-07T12:00:00")
        store.insert_message("m2", "peer-1", "incoming", "Hi!", "2026-03-07T12:00:01")
        store.insert_message("m3", "peer-1", "outgoing", "How are you?", "2026-03-07T12:00:02")

        msgs = store.get_messages("peer-1")
        assert len(msgs) == 3
        # Chronological order
        assert msgs[0]["msg_id"] == "m1"
        assert msgs[2]["msg_id"] == "m3"

    def test_message_pagination(self, store):
        for i in range(10):
            store.insert_message(
                f"m{i}", "peer-1", "outgoing", f"Msg {i}", f"2026-03-07T12:00:{i:02d}"
            )

        msgs = store.get_messages("peer-1", limit=3)
        assert len(msgs) == 3
        # Should be last 3 messages in chronological order
        assert msgs[0]["msg_id"] == "m7"

        # Pagination with before
        msgs2 = store.get_messages("peer-1", limit=3, before="2026-03-07T12:00:07")
        assert len(msgs2) == 3
        assert msgs2[2]["msg_id"] == "m6"

    def test_update_status(self, store):
        store.insert_message("m1", "peer-1", "outgoing", "Hello", "2026-03-07T12:00:00", "sent")
        store.update_status("m1", "delivered")

        msgs = store.get_messages("peer-1")
        assert msgs[0]["status"] == "delivered"

    def test_update_statuses_batch(self, store):
        store.insert_message("m1", "peer-1", "incoming", "A", "2026-03-07T12:00:00", "delivered")
        store.insert_message("m2", "peer-1", "incoming", "B", "2026-03-07T12:00:01", "delivered")

        store.update_statuses(["m1", "m2"], "read")

        msgs = store.get_messages("peer-1")
        assert all(m["status"] == "read" for m in msgs)

    def test_outbox_round_trip(self, store):
        store.enqueue("m1", "peer-1", '{"test": true}')

        pending = store.get_pending("peer-1")
        assert len(pending) == 1
        assert pending[0]["msg_id"] == "m1"
        assert json.loads(pending[0]["payload_json"]) == {"test": True}

        store.dequeue("m1")
        assert len(store.get_pending("peer-1")) == 0

    def test_outbox_max_attempts(self, store):
        from familiar.core.mesh.message_bridge import MAX_OUTBOX_ATTEMPTS

        store.enqueue("m1", "peer-1", '{}')

        for _ in range(MAX_OUTBOX_ATTEMPTS):
            store.increment_attempts("m1")

        # Should no longer appear in pending
        assert len(store.get_pending("peer-1")) == 0
        assert "m1" in store.get_failed_msg_ids()

    def test_thread_safety(self, store):
        errors = []

        def writer(n):
            try:
                for i in range(20):
                    store.insert_message(
                        f"t{n}-m{i}", "peer-1", "outgoing", f"Msg {i}", f"2026-03-07T{n}:{i:02d}:00"
                    )
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=writer, args=(i,)) for i in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        msgs = store.get_messages("peer-1", limit=100)
        assert len(msgs) == 80  # 4 threads * 20 messages


# ============================================================
# MessageBridge tests (~14)
# ============================================================


class TestMessageBridge:
    def test_send_trusted_peer_online(self, bridge, mock_gateway):
        """Sending to an online trusted peer should return status 'sent'."""
        # Make peer online
        mock_ws = _make_async_ws()
        mock_gateway.peer_gateways["peer-1"] = mock_ws

        result = bridge.send_message("peer-1", "Hello!")
        assert result["status"] == "sent"
        assert "msg_id" in result

        # Should store locally
        msgs = bridge.get_messages("peer-1")
        assert len(msgs) == 1
        assert msgs[0]["body"] == "Hello!"
        assert msgs[0]["direction"] == "outgoing"

    def test_send_untrusted_peer(self, bridge, mock_trust_manager):
        """Sending to a peer without permission should fail."""
        mock_trust_manager.check_permission.return_value = False

        result = bridge.send_message("peer-bad", "Hello!")
        assert result["status"] == "error"
        assert "permission" in result["error"].lower()

    def test_send_no_trust_manager(self, mesh_dir, mock_gateway):
        """Sending without a trust manager should fail."""
        from familiar.core.mesh.message_bridge import MessageBridge

        bridge = MessageBridge(
            gateway=mock_gateway,
            trust_manager=None,
            node_id="node-local",
            node_name="Local",
            mesh_dir=mesh_dir,
        )
        result = bridge.send_message("peer-1", "Hello!")
        assert result["status"] == "error"

    def test_send_offline_queued(self, bridge, mock_gateway):
        """Sending to an offline peer should queue in outbox."""
        # peer_gateways is empty — peer offline
        result = bridge.send_message("peer-1", "Hello!")
        assert result["status"] == "queued"

        # Should be in outbox
        pending = bridge.store.get_pending("peer-1")
        assert len(pending) == 1

    def test_incoming_message_decrypt_and_ack(self, bridge, mock_gateway):
        """Incoming message should be decrypted, stored, and ACK'd."""
        mock_ws = _make_async_ws()
        mock_gateway.peer_gateways["peer-A"] = mock_ws

        data = {
            "msg_id": "test-msg-1",
            "sender_name": "Peer A",
            "ciphertext": b"\x00" * 32,
            "timestamp": "2026-03-07T12:00:00+00:00",
        }
        data["ciphertext"] = data["ciphertext"].hex()

        bridge.handle_mesh_event("mesh.chat.message", data, "peer-A")

        # Should store incoming message
        msgs = bridge.get_messages("peer-A")
        assert len(msgs) == 1
        assert msgs[0]["body"] == "Hello!"
        assert msgs[0]["direction"] == "incoming"

        # Should increment unread
        assert bridge.get_unread_count() == 1

    def test_incoming_no_permission_rejected(self, bridge, mock_trust_manager):
        """Incoming from untrusted peer should be rejected."""
        mock_trust_manager.check_permission.return_value = False

        data = {
            "msg_id": "bad-msg",
            "sender_name": "Bad Peer",
            "ciphertext": (b"\x00" * 32).hex(),
            "timestamp": "2026-03-07T12:00:00+00:00",
        }
        bridge.handle_mesh_event("mesh.chat.message", data, "bad-peer")

        assert bridge.get_unread_count() == 0

    def test_handle_ack(self, bridge):
        """ACK should update message status to delivered."""
        bridge.store.insert_message("m1", "peer-1", "outgoing", "Hello", "2026-03-07T12:00:00", "sent")

        bridge.handle_mesh_event("mesh.chat.ack", {"msg_id": "m1", "status": "delivered"}, "peer-1")

        msgs = bridge.get_messages("peer-1")
        assert msgs[0]["status"] == "delivered"

    def test_handle_read_receipt(self, bridge):
        """Read receipt should update all listed messages to read."""
        bridge.store.insert_message("m1", "peer-1", "outgoing", "A", "2026-03-07T12:00:00", "delivered")
        bridge.store.insert_message("m2", "peer-1", "outgoing", "B", "2026-03-07T12:00:01", "delivered")

        bridge.handle_mesh_event(
            "mesh.chat.read",
            {"msg_ids": ["m1", "m2"], "reader_name": "Peer"},
            "peer-1",
        )

        msgs = bridge.get_messages("peer-1")
        assert all(m["status"] == "read" for m in msgs)

    def test_handle_typing(self, bridge):
        """Typing indicator should be tracked and pruned after timeout."""
        bridge.handle_mesh_event(
            "mesh.chat.typing",
            {"sender_name": "Peer A", "active": True},
            "peer-A",
        )

        typing = bridge.get_typing_peers()
        assert "peer-A" in typing

    def test_typing_expires(self, bridge):
        """Typing state should be pruned after TYPING_TIMEOUT."""
        from familiar.core.mesh.message_bridge import TYPING_TIMEOUT

        # Set typing state in the past
        bridge._typing_states["peer-A"] = time.monotonic() - TYPING_TIMEOUT - 1

        typing = bridge.get_typing_peers()
        assert "peer-A" not in typing

    def test_drain_outbox(self, bridge, mock_gateway):
        """Draining outbox should send queued messages and dequeue on success."""
        bridge.store.enqueue("m1", "peer-1", json.dumps({"msg_id": "m1", "ciphertext": "aa"}))
        bridge.store.insert_message("m1", "peer-1", "outgoing", "Hello", "2026-03-07T12:00:00", "queued")

        # Make peer come online
        mock_ws = _make_async_ws()
        mock_gateway.peer_gateways["peer-1"] = mock_ws

        bridge.drain_outbox("peer-1")

        # Outbox should be empty
        assert len(bridge.store.get_pending("peer-1")) == 0
        # Message status should be updated
        msgs = bridge.get_messages("peer-1")
        assert msgs[0]["status"] == "sent"

    def test_drain_outbox_max_attempts(self, bridge, mock_gateway):
        """Messages exceeding max attempts should be marked as failed."""
        from familiar.core.mesh.message_bridge import MAX_OUTBOX_ATTEMPTS

        bridge.store.enqueue("m1", "peer-1", '{}')
        bridge.store.insert_message("m1", "peer-1", "outgoing", "Hello", "2026-03-07T12:00:00", "queued")

        # Exhaust attempts
        for _ in range(MAX_OUTBOX_ATTEMPTS):
            bridge.store.increment_attempts("m1")

        # Make peer online
        mock_ws = _make_async_ws()
        mock_gateway.peer_gateways["peer-1"] = mock_ws

        bridge.drain_outbox("peer-1")

        # Message should be marked failed
        msgs = bridge.get_messages("peer-1")
        assert msgs[0]["status"] == "failed"

    def test_get_contacts_with_online_status(self, bridge, mock_gateway):
        """Contacts should include online status from gateway."""
        bridge.store.upsert_conversation("peer-1", "Alice", "Hi", "2026-03-07T12:00:00")
        bridge.store.upsert_conversation("peer-2", "Bob", "Hey", "2026-03-07T12:01:00")

        mock_gateway.peer_gateways["peer-1"] = _make_async_ws()
        # peer-2 is offline

        contacts = bridge.get_contacts()
        alice = next(c for c in contacts if c["peer_id"] == "peer-1")
        bob = next(c for c in contacts if c["peer_id"] == "peer-2")

        assert alice["online"] is True
        assert bob["online"] is False

    def test_encryption_failure_returns_error(self, bridge, mock_trust_manager):
        """If encryption fails with crypto available, should return error."""
        mock_trust_manager.encrypt_for_peer.return_value = None

        result = bridge.send_message("peer-1", "Secret message")
        assert result["status"] == "error"
        assert "encryption" in result["error"].lower()


# ============================================================
# Permission tests (~4)
# ============================================================


class TestMessagingPermission:
    def test_send_messages_field_exists(self):
        from familiar.core.mesh.trust import PermissionMatrix

        pm = PermissionMatrix()
        assert hasattr(pm, "send_messages")

    def test_send_messages_default_false(self):
        from familiar.core.mesh.trust import PermissionMatrix

        pm = PermissionMatrix()
        assert pm.send_messages is False

    def test_grant_and_revoke_send_messages(self):
        from familiar.core.mesh.trust import PermissionMatrix

        pm = PermissionMatrix()
        assert pm.grant("send_messages") is True
        assert pm.send_messages is True
        assert pm.check("send_messages") is True

        assert pm.revoke("send_messages") is True
        assert pm.send_messages is False

    def test_all_deny_includes_send_messages(self):
        from familiar.core.mesh.trust import PermissionMatrix

        pm = PermissionMatrix.all_deny()
        assert pm.send_messages is False

    def test_all_grant_includes_send_messages(self):
        from familiar.core.mesh.trust import PermissionMatrix

        pm = PermissionMatrix.all_grant()
        assert pm.send_messages is True


# ============================================================
# Mark as read + receipts integration
# ============================================================


class TestMessageablePeers:
    def test_get_messageable_peers_basic(self, mesh_dir, mock_gateway):
        """Returns trusted peer with send_messages=True."""
        from familiar.core.mesh.message_bridge import MessageBridge
        from familiar.core.mesh.trust import MeshTrustManager, PermissionMatrix, TrustRecord

        tm = MeshTrustManager(mesh_dir=mesh_dir)
        rec = TrustRecord(
            node_id="peer-A", node_name="Alice",
            identity_fingerprint="aaa", trust_level="trusted",
            permissions=PermissionMatrix(send_messages=True),
        )
        tm._trust_store.upsert(rec)

        bridge = MessageBridge(
            gateway=mock_gateway, trust_manager=tm,
            node_id="node-local", node_name="Local", mesh_dir=mesh_dir,
        )
        peers = bridge.get_messageable_peers()
        assert len(peers) == 1
        assert peers[0]["peer_id"] == "peer-A"
        assert peers[0]["peer_name"] == "Alice"

    def test_get_messageable_peers_excludes_existing_conversations(self, mesh_dir, mock_gateway):
        """Peer with an existing conversation should not appear."""
        from familiar.core.mesh.message_bridge import MessageBridge
        from familiar.core.mesh.trust import MeshTrustManager, PermissionMatrix, TrustRecord

        tm = MeshTrustManager(mesh_dir=mesh_dir)
        rec = TrustRecord(
            node_id="peer-A", node_name="Alice",
            identity_fingerprint="aaa", trust_level="trusted",
            permissions=PermissionMatrix(send_messages=True),
        )
        tm._trust_store.upsert(rec)

        bridge = MessageBridge(
            gateway=mock_gateway, trust_manager=tm,
            node_id="node-local", node_name="Local", mesh_dir=mesh_dir,
        )
        bridge.store.upsert_conversation("peer-A", "Alice", "Hi", "2026-03-07T12:00:00")
        peers = bridge.get_messageable_peers()
        assert len(peers) == 0

    def test_get_messageable_peers_excludes_untrusted(self, mesh_dir, mock_gateway):
        """Untrusted or pending peers should not appear."""
        from familiar.core.mesh.message_bridge import MessageBridge
        from familiar.core.mesh.trust import MeshTrustManager, PermissionMatrix, TrustRecord

        tm = MeshTrustManager(mesh_dir=mesh_dir)
        for level in ("pending", "unknown", "blocked"):
            rec = TrustRecord(
                node_id=f"peer-{level}", node_name=level.title(),
                identity_fingerprint=level, trust_level=level,
                permissions=PermissionMatrix(send_messages=True),
            )
            tm._trust_store.upsert(rec)

        bridge = MessageBridge(
            gateway=mock_gateway, trust_manager=tm,
            node_id="node-local", node_name="Local", mesh_dir=mesh_dir,
        )
        peers = bridge.get_messageable_peers()
        assert len(peers) == 0

    def test_get_messageable_peers_online_status(self, mesh_dir, mock_gateway):
        """Online flag should reflect gateway peer_gateways."""
        from familiar.core.mesh.message_bridge import MessageBridge
        from familiar.core.mesh.trust import MeshTrustManager, PermissionMatrix, TrustRecord

        tm = MeshTrustManager(mesh_dir=mesh_dir)
        for pid in ("peer-on", "peer-off"):
            rec = TrustRecord(
                node_id=pid, node_name=pid,
                identity_fingerprint=pid, trust_level="trusted",
                permissions=PermissionMatrix(send_messages=True),
            )
            tm._trust_store.upsert(rec)

        mock_gateway.peer_gateways["peer-on"] = _make_async_ws()

        bridge = MessageBridge(
            gateway=mock_gateway, trust_manager=tm,
            node_id="node-local", node_name="Local", mesh_dir=mesh_dir,
        )
        peers = bridge.get_messageable_peers()
        by_id = {p["peer_id"]: p for p in peers}
        assert by_id["peer-on"]["online"] is True
        assert by_id["peer-off"]["online"] is False


class TestAutoGrantSendMessages:
    def test_auto_grant_send_messages_on_approve(self, mesh_dir):
        """Approving a peer should auto-grant send_messages permission."""
        from familiar.core.mesh.trust import MeshTrustManager, TrustRecord

        tm = MeshTrustManager(mesh_dir=mesh_dir)
        rec = TrustRecord(
            node_id="peer-X", node_name="Xavier",
            identity_fingerprint="xxx", trust_level="pending",
        )
        tm._trust_store.upsert(rec)

        assert rec.permissions.send_messages is False
        result = tm.approve("peer-X")
        assert result is True

        updated = tm.get_trust_record("peer-X")
        assert updated.trust_level == "trusted"
        assert updated.permissions.send_messages is True


class TestMarkAsRead:
    def test_mark_as_read_sends_receipt(self, bridge, mock_gateway):
        """mark_as_read should update DB and send read receipt to peer."""
        mock_ws = _make_async_ws()
        mock_gateway.peer_gateways["peer-1"] = mock_ws

        # Insert some incoming messages
        bridge.store.insert_message("m1", "peer-1", "incoming", "A", "2026-03-07T12:00:00", "delivered")
        bridge.store.insert_message("m2", "peer-1", "incoming", "B", "2026-03-07T12:00:01", "delivered")
        bridge.store.upsert_conversation("peer-1", "Alice", "B", "2026-03-07T12:00:01", increment_unread=True)
        bridge.store.upsert_conversation("peer-1", "Alice", "B", "2026-03-07T12:00:01", increment_unread=True)

        bridge.mark_as_read("peer-1")

        # Unread should be 0
        assert bridge.get_unread_count() == 0

        # Messages should be marked read
        msgs = bridge.get_messages("peer-1")
        assert all(m["status"] == "read" for m in msgs)
