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

"""
Mesh Message Bridge — Phase 8 (v2.7.7)

ICQ-style encrypted peer-to-peer messaging between trusted mesh peers.

Features:
  - 1:1 encrypted conversations via existing MeshTrustManager crypto
  - SQLite-backed message history with conversation metadata
  - Offline queuing with automatic drain on peer reconnect
  - Delivery receipts (sent → delivered → read)
  - Typing indicators (ephemeral, pruned after 5s)
  - Unread badge count for dashboard nav

Event topics (only mesh.* topics forwarded across gateways):
  mesh.chat.message  — encrypted message payload
  mesh.chat.ack      — delivery acknowledgement
  mesh.chat.read     — read receipt (batch)
  mesh.chat.typing   — typing indicator

Storage:
  ~/.familiar/data/mesh/messages.db (SQLite)
  Messages stored as plaintext locally after decryption.
  Outbox holds pre-encrypted ciphertext for offline delivery.

Dependencies: None (pure stdlib). Encryption via existing trust.py.
"""

from __future__ import annotations

import json
import logging
import sqlite3
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

# Mesh event topics
MESH_CHAT_MESSAGE = "mesh.chat.message"
MESH_CHAT_ACK = "mesh.chat.ack"
MESH_CHAT_READ = "mesh.chat.read"
MESH_CHAT_TYPING = "mesh.chat.typing"

# Max outbox retry attempts before marking failed
MAX_OUTBOX_ATTEMPTS = 10

# Typing indicator timeout (seconds)
TYPING_TIMEOUT = 5.0


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


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


# ============================================================
# MESSAGE STORE — SQLite persistence
# ============================================================


class MessageStore:
    """
    SQLite-backed store for conversations, messages, and offline outbox.

    Thread-safe. Follows the UsageTracker pattern for DB init and locking.
    """

    def __init__(self, mesh_dir: Path):
        mesh_dir.mkdir(parents=True, exist_ok=True)
        self._db_path = mesh_dir / "messages.db"
        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 conversations (
                    peer_id TEXT PRIMARY KEY,
                    peer_name TEXT NOT NULL DEFAULT '',
                    last_message TEXT DEFAULT '',
                    last_ts TEXT DEFAULT '',
                    unread_count INTEGER DEFAULT 0,
                    is_archived INTEGER DEFAULT 0
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS messages (
                    msg_id TEXT PRIMARY KEY,
                    conversation TEXT NOT NULL,
                    direction TEXT NOT NULL,
                    body TEXT NOT NULL,
                    timestamp TEXT NOT NULL,
                    status TEXT NOT NULL DEFAULT 'sent',
                    reply_to TEXT DEFAULT NULL,
                    FOREIGN KEY (conversation) REFERENCES conversations(peer_id)
                )
            """)
            conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_messages_conversation
                ON messages(conversation, timestamp)
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS outbox (
                    msg_id TEXT PRIMARY KEY,
                    peer_id TEXT NOT NULL,
                    payload_json TEXT NOT NULL,
                    created TEXT NOT NULL,
                    attempts INTEGER DEFAULT 0,
                    last_try TEXT DEFAULT ''
                )
            """)
            conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_outbox_peer
                ON outbox(peer_id)
            """)

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

    # ── Conversations ──

    def upsert_conversation(
        self,
        peer_id: str,
        peer_name: str,
        last_message: str = "",
        last_ts: str = "",
        increment_unread: bool = False,
    ):
        with self._lock:
            with self._conn() as conn:
                existing = conn.execute(
                    "SELECT peer_id, unread_count FROM conversations WHERE peer_id = ?",
                    (peer_id,),
                ).fetchone()

                if existing:
                    unread = existing["unread_count"]
                    if increment_unread:
                        unread += 1
                    conn.execute(
                        """UPDATE conversations
                           SET peer_name = ?, last_message = ?, last_ts = ?, unread_count = ?
                           WHERE peer_id = ?""",
                        (peer_name, last_message, last_ts, unread, peer_id),
                    )
                else:
                    unread = 1 if increment_unread else 0
                    conn.execute(
                        """INSERT INTO conversations
                           (peer_id, peer_name, last_message, last_ts, unread_count)
                           VALUES (?, ?, ?, ?, ?)""",
                        (peer_id, peer_name, last_message, last_ts, unread),
                    )

    def get_conversations(self, include_archived: bool = False) -> List[dict]:
        with self._lock:
            with self._conn() as conn:
                if include_archived:
                    rows = conn.execute(
                        "SELECT * FROM conversations ORDER BY last_ts DESC"
                    ).fetchall()
                else:
                    rows = conn.execute(
                        "SELECT * FROM conversations WHERE is_archived = 0 ORDER BY last_ts DESC"
                    ).fetchall()
                return [dict(r) for r in rows]

    def mark_read(self, peer_id: str):
        with self._lock:
            with self._conn() as conn:
                conn.execute(
                    "UPDATE conversations SET unread_count = 0 WHERE peer_id = ?",
                    (peer_id,),
                )

    def total_unread(self) -> int:
        with self._lock:
            with self._conn() as conn:
                row = conn.execute(
                    "SELECT COALESCE(SUM(unread_count), 0) as total FROM conversations"
                ).fetchone()
                return row["total"] if row else 0

    # ── Messages ──

    def insert_message(
        self,
        msg_id: str,
        conversation: str,
        direction: str,
        body: str,
        timestamp: str,
        status: str = "sent",
        reply_to: Optional[str] = None,
    ):
        with self._lock:
            with self._conn() as conn:
                conn.execute(
                    """INSERT OR IGNORE INTO messages
                       (msg_id, conversation, direction, body, timestamp, status, reply_to)
                       VALUES (?, ?, ?, ?, ?, ?, ?)""",
                    (msg_id, conversation, direction, body, timestamp, status, reply_to),
                )

    def get_messages(
        self,
        peer_id: str,
        limit: int = 50,
        before: Optional[str] = None,
    ) -> List[dict]:
        with self._lock:
            with self._conn() as conn:
                if before:
                    rows = conn.execute(
                        """SELECT * FROM messages
                           WHERE conversation = ? AND timestamp < ?
                           ORDER BY timestamp DESC LIMIT ?""",
                        (peer_id, before, limit),
                    ).fetchall()
                else:
                    rows = conn.execute(
                        """SELECT * FROM messages
                           WHERE conversation = ?
                           ORDER BY timestamp DESC LIMIT ?""",
                        (peer_id, limit),
                    ).fetchall()
                # Return in chronological order
                return [dict(r) for r in reversed(rows)]

    def update_status(self, msg_id: str, status: str):
        with self._lock:
            with self._conn() as conn:
                conn.execute(
                    "UPDATE messages SET status = ? WHERE msg_id = ?",
                    (status, msg_id),
                )

    def update_statuses(self, msg_ids: List[str], status: str):
        if not msg_ids:
            return
        with self._lock:
            with self._conn() as conn:
                placeholders = ",".join("?" for _ in msg_ids)
                conn.execute(
                    f"UPDATE messages SET status = ? WHERE msg_id IN ({placeholders})",
                    [status] + msg_ids,
                )

    def get_unread_msg_ids(self, peer_id: str) -> List[str]:
        """Get msg_ids of incoming messages that haven't been read yet."""
        with self._lock:
            with self._conn() as conn:
                rows = conn.execute(
                    """SELECT msg_id FROM messages
                       WHERE conversation = ? AND direction = 'incoming'
                       AND status != 'read'
                       ORDER BY timestamp""",
                    (peer_id,),
                ).fetchall()
                return [r["msg_id"] for r in rows]

    # ── Outbox ──

    def enqueue(self, msg_id: str, peer_id: str, payload_json: str):
        with self._lock:
            with self._conn() as conn:
                conn.execute(
                    """INSERT OR IGNORE INTO outbox
                       (msg_id, peer_id, payload_json, created, attempts)
                       VALUES (?, ?, ?, ?, 0)""",
                    (msg_id, peer_id, payload_json, _utcnow_iso()),
                )

    def get_pending(self, peer_id: str) -> List[dict]:
        with self._lock:
            with self._conn() as conn:
                rows = conn.execute(
                    """SELECT * FROM outbox
                       WHERE peer_id = ? AND attempts < ?
                       ORDER BY created""",
                    (peer_id, MAX_OUTBOX_ATTEMPTS),
                ).fetchall()
                return [dict(r) for r in rows]

    def dequeue(self, msg_id: str):
        with self._lock:
            with self._conn() as conn:
                conn.execute("DELETE FROM outbox WHERE msg_id = ?", (msg_id,))

    def increment_attempts(self, msg_id: str):
        with self._lock:
            with self._conn() as conn:
                conn.execute(
                    "UPDATE outbox SET attempts = attempts + 1, last_try = ? WHERE msg_id = ?",
                    (_utcnow_iso(), msg_id),
                )

    def get_failed_msg_ids(self) -> List[str]:
        """Get msg_ids that exceeded max attempts."""
        with self._lock:
            with self._conn() as conn:
                rows = conn.execute(
                    "SELECT msg_id FROM outbox WHERE attempts >= ?",
                    (MAX_OUTBOX_ATTEMPTS,),
                ).fetchall()
                return [r["msg_id"] for r in rows]


# ============================================================
# MESSAGE BRIDGE — main class
# ============================================================


class MessageBridge:
    """
    Bridges local chat UI with mesh peers for encrypted messaging.

    Handles:
      - Sending messages (encrypt → send or queue)
      - Receiving messages (decrypt → store → ACK)
      - Delivery & read receipts
      - Typing indicators
      - Offline queue drain on reconnect

    Usage:
        bridge = MessageBridge(
            gateway=mesh_gateway,
            trust_manager=trust_manager,
            node_id="local_123",
            node_name="Kitchen Pi",
            mesh_dir=MESH_DIR,
        )

        # Send a message
        result = bridge.send_message(peer_id, "Hello!")

        # Handle incoming mesh event (called by gateway)
        bridge.handle_mesh_event(topic, data, peer_id)
    """

    def __init__(
        self,
        gateway: Any,
        trust_manager: Any,
        node_id: str,
        node_name: str,
        mesh_dir: Optional[Path] = None,
    ):
        self._gateway = gateway
        self._trust_manager = trust_manager
        self.node_id = node_id
        self.node_name = node_name

        if mesh_dir is None:
            mesh_dir = Path.home() / ".familiar" / "data" / "mesh"

        self.store = MessageStore(mesh_dir)

        # Typing states: {peer_id: timestamp}
        self._typing_states: Dict[str, float] = {}
        self._typing_lock = threading.Lock()

    # ── Sending ──

    def send_message(
        self,
        peer_id: str,
        body: str,
        reply_to: Optional[str] = None,
    ) -> dict:
        """
        Send an encrypted message to a peer.

        Returns dict with msg_id and status ('sent', 'queued', or 'error').
        """
        # Check trust + permission
        if not self._trust_manager:
            return {"error": "No trust manager available", "status": "error"}

        if not self._trust_manager.check_permission(peer_id, "send_messages"):
            return {"error": "No send_messages permission for this peer", "status": "error"}

        msg_id = str(uuid.uuid4())
        timestamp = _utcnow_iso()

        # Get peer name for conversation
        peer_name = self._get_peer_name(peer_id)

        # Build wire payload
        wire_payload = {
            "msg_id": msg_id,
            "sender_name": self.node_name,
            "body": body,
            "timestamp": timestamp,
        }
        if reply_to:
            wire_payload["reply_to"] = reply_to

        # Encrypt
        plaintext = json.dumps(wire_payload).encode("utf-8")
        ciphertext = self._trust_manager.encrypt_for_peer(peer_id, plaintext)

        if ciphertext is None:
            # No encryption available — send unencrypted if no crypto
            if not self._trust_manager.has_crypto:
                encrypted_payload = {
                    "msg_id": msg_id,
                    "sender_name": self.node_name,
                    "ciphertext": None,
                    "plaintext_fallback": wire_payload,
                    "timestamp": timestamp,
                }
            else:
                return {"error": "Encryption failed — no session with peer", "status": "error"}
        else:
            encrypted_payload = {
                "msg_id": msg_id,
                "sender_name": self.node_name,
                "ciphertext": ciphertext.hex(),
                "timestamp": timestamp,
            }

        # Store locally as plaintext
        preview = body[:100] if len(body) > 100 else body
        self.store.insert_message(
            msg_id=msg_id,
            conversation=peer_id,
            direction="outgoing",
            body=body,
            timestamp=timestamp,
            status="sent",
            reply_to=reply_to,
        )
        self.store.upsert_conversation(
            peer_id=peer_id,
            peer_name=peer_name,
            last_message=preview,
            last_ts=timestamp,
        )

        # Try to send
        sent = self._send_to_peer(peer_id, MESH_CHAT_MESSAGE, encrypted_payload)
        if not sent:
            # Peer offline — queue in outbox
            self.store.enqueue(
                msg_id=msg_id,
                peer_id=peer_id,
                payload_json=json.dumps(encrypted_payload),
            )
            self.store.update_status(msg_id, "queued")
            return {"msg_id": msg_id, "status": "queued", "timestamp": timestamp}

        return {"msg_id": msg_id, "status": "sent", "timestamp": timestamp}

    def send_typing(self, peer_id: str):
        """Send a typing indicator to a peer."""
        if not self._trust_manager or not self._trust_manager.check_permission(
            peer_id, "send_messages"
        ):
            return

        self._send_to_peer(
            peer_id,
            MESH_CHAT_TYPING,
            {"sender_name": self.node_name, "active": True},
        )

    def mark_as_read(self, peer_id: str):
        """Mark all messages in a conversation as read and send receipts."""
        msg_ids = self.store.get_unread_msg_ids(peer_id)
        if not msg_ids:
            self.store.mark_read(peer_id)
            return

        # Update local status
        self.store.update_statuses(msg_ids, "read")
        self.store.mark_read(peer_id)

        # Send read receipt to peer
        self._send_to_peer(
            peer_id,
            MESH_CHAT_READ,
            {"msg_ids": msg_ids, "reader_name": self.node_name},
        )

    # ── Receiving (called by gateway) ──

    def handle_mesh_event(self, topic: str, data: dict, peer_id: str):
        """Dispatch incoming mesh.chat.* events."""
        if topic == MESH_CHAT_MESSAGE:
            self._handle_incoming_message(data, peer_id)
        elif topic == MESH_CHAT_ACK:
            self._handle_ack(data)
        elif topic == MESH_CHAT_READ:
            self._handle_read_receipt(data)
        elif topic == MESH_CHAT_TYPING:
            self._handle_typing(data, peer_id)

    def _handle_incoming_message(self, data: dict, peer_id: str):
        """Process an incoming encrypted message."""
        # Check trust
        if self._trust_manager and not self._trust_manager.check_permission(
            peer_id, "send_messages"
        ):
            logger.debug(f"Message rejected from {peer_id}: no send_messages permission")
            return

        msg_id = data.get("msg_id", "")
        sender_name = data.get("sender_name", peer_id[:8])
        timestamp = data.get("timestamp", _utcnow_iso())

        # Decrypt
        ciphertext_hex = data.get("ciphertext")
        if ciphertext_hex is not None:
            if not self._trust_manager:
                logger.warning("Cannot decrypt: no trust manager")
                return
            ciphertext = bytes.fromhex(ciphertext_hex)
            plaintext_bytes = self._trust_manager.decrypt_from_peer(peer_id, ciphertext)
            if plaintext_bytes is None:
                logger.warning(f"Decryption failed for message {msg_id} from {peer_id[:8]}")
                return
            try:
                wire = json.loads(plaintext_bytes.decode("utf-8"))
            except (json.JSONDecodeError, UnicodeDecodeError):
                logger.warning(f"Invalid message payload from {peer_id[:8]}")
                return
        else:
            # Plaintext fallback (no crypto available)
            wire = data.get("plaintext_fallback", {})

        body = wire.get("body", "")
        reply_to = wire.get("reply_to")

        if not body:
            return

        # Store message
        preview = body[:100] if len(body) > 100 else body
        self.store.insert_message(
            msg_id=msg_id,
            conversation=peer_id,
            direction="incoming",
            body=body,
            timestamp=timestamp,
            status="delivered",
            reply_to=reply_to,
        )
        self.store.upsert_conversation(
            peer_id=peer_id,
            peer_name=sender_name,
            last_message=preview,
            last_ts=timestamp,
            increment_unread=True,
        )

        # Send ACK
        self._send_to_peer(
            peer_id,
            MESH_CHAT_ACK,
            {"msg_id": msg_id, "status": "delivered"},
        )

    def _handle_ack(self, data: dict):
        """Handle delivery acknowledgement."""
        msg_id = data.get("msg_id", "")
        if msg_id:
            self.store.update_status(msg_id, "delivered")

    def _handle_read_receipt(self, data: dict):
        """Handle read receipt for one or more messages."""
        msg_ids = data.get("msg_ids", [])
        if msg_ids:
            self.store.update_statuses(msg_ids, "read")

    def _handle_typing(self, data: dict, peer_id: str):
        """Record a peer's typing state."""
        active = data.get("active", True)
        with self._typing_lock:
            if active:
                self._typing_states[peer_id] = time.monotonic()
            else:
                self._typing_states.pop(peer_id, None)

    # ── Offline queue ──

    def drain_outbox(self, peer_id: str):
        """
        Send queued messages to a peer that just connected.
        Called by gateway when a peer WebSocket is established.
        """
        pending = self.store.get_pending(peer_id)
        for item in pending:
            msg_id = item["msg_id"]
            try:
                payload = json.loads(item["payload_json"])
                sent = self._send_to_peer(peer_id, MESH_CHAT_MESSAGE, payload)
                if sent:
                    self.store.dequeue(msg_id)
                    self.store.update_status(msg_id, "sent")
                else:
                    self.store.increment_attempts(msg_id)
            except Exception as e:
                logger.debug(f"Failed to drain outbox msg {msg_id}: {e}")
                self.store.increment_attempts(msg_id)

        # Mark messages that exceeded max attempts as failed
        for failed_id in self.store.get_failed_msg_ids():
            self.store.update_status(failed_id, "failed")
            self.store.dequeue(failed_id)

    # ── Query (for API) ──

    def get_contacts(self) -> List[dict]:
        """Get conversations with online status from gateway."""
        conversations = self.store.get_conversations()
        online_peers = set()
        if self._gateway and hasattr(self._gateway, "peer_gateways"):
            online_peers = set(self._gateway.peer_gateways.keys())

        for conv in conversations:
            conv["online"] = conv["peer_id"] in online_peers

        return conversations

    def get_messages(
        self, peer_id: str, limit: int = 50, before: Optional[str] = None
    ) -> List[dict]:
        return self.store.get_messages(peer_id, limit=limit, before=before)

    def get_unread_count(self) -> int:
        return self.store.total_unread()

    def get_typing_peers(self) -> List[str]:
        """Get list of peers currently typing (prune stale >5s)."""
        now = time.monotonic()
        with self._typing_lock:
            active = []
            stale = []
            for peer_id, ts in self._typing_states.items():
                if now - ts <= TYPING_TIMEOUT:
                    active.append(peer_id)
                else:
                    stale.append(peer_id)
            for pid in stale:
                del self._typing_states[pid]
            return active

    # ── Peer discovery ──

    def get_messageable_peers(self) -> List[dict]:
        """Get trusted peers with send_messages permission who aren't already contacts."""
        if not self._trust_manager:
            return []
        existing = {c["peer_id"] for c in self.store.get_conversations()}
        online_peers: set = set()
        if self._gateway and hasattr(self._gateway, "peer_gateways"):
            online_peers = set(self._gateway.peer_gateways.keys())
        peers = []
        for record in self._trust_manager._trust_store.get_all():
            if (
                record.trust_level == "trusted"
                and record.permissions.send_messages
                and record.node_id not in existing
            ):
                peers.append({
                    "peer_id": record.node_id,
                    "peer_name": record.node_name,
                    "online": record.node_id in online_peers,
                })
        return peers

    # ── Internal helpers ──

    def _get_peer_name(self, peer_id: str) -> str:
        """Resolve peer display name from trust records or gateway info."""
        if self._trust_manager:
            record = self._trust_manager.get_trust_record(peer_id)
            if record:
                return record.node_name

        if self._gateway and hasattr(self._gateway, "peer_gateway_info"):
            info = self._gateway.peer_gateway_info.get(peer_id, {})
            if info.get("name"):
                return info["name"]

        return peer_id[:8]

    def _send_to_peer(self, peer_id: str, topic: str, payload: dict) -> bool:
        """
        Send a mesh event to a specific peer via the gateway.
        Returns True if the peer is online and the message was dispatched.
        """
        if not self._gateway:
            return False

        ws = None
        if hasattr(self._gateway, "peer_gateways"):
            ws = self._gateway.peer_gateways.get(peer_id)
        if not ws:
            return False

        try:
            import asyncio

            from .gateway import Message, MsgType

            msg = Message(
                type=MsgType.TOOL_REQUEST,
                payload={
                    "mesh_event": True,
                    "topic": topic,
                    "data": payload,
                },
            )

            try:
                loop = asyncio.get_event_loop()
                if loop.is_running():
                    asyncio.ensure_future(ws.send(msg.to_json()))
                else:
                    loop.run_until_complete(ws.send(msg.to_json()))
            except RuntimeError:
                asyncio.run(ws.send(msg.to_json()))

            return True
        except Exception as e:
            logger.debug(f"Failed to send {topic} to {peer_id[:8]}: {e}")
            return False

    # ── Status ──

    def get_status(self) -> dict:
        """Get message bridge status for CLI/dashboard."""
        return {
            "total_conversations": len(self.store.get_conversations(include_archived=True)),
            "total_unread": self.store.total_unread(),
            "typing_peers": len(self.get_typing_peers()),
            "gateway_connected": self._gateway is not None,
            "has_crypto": self._trust_manager.has_crypto if self._trust_manager else False,
        }
