# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""SQLite storage for contacts, messages, and channels."""

import contextlib
import hashlib
import os
import sqlite3
import time
from datetime import datetime

from meshy.models import Channel, ChannelMessage, Contact, Message, MessageStatus, NotificationLevel


class Storage:
    @staticmethod
    def db_path_for_device(device_address: str) -> str:
        data_dir = os.path.join(
            os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")), "meshy"
        )
        safe_addr = device_address.replace(":", "_").replace("/", "_")
        return os.path.join(data_dir, f"device_{safe_addr}.db")

    @staticmethod
    def delete_device_db(device_address: str):
        path = Storage.db_path_for_device(device_address)
        for suffix in ("", "-wal", "-shm"):
            with contextlib.suppress(FileNotFoundError):
                os.remove(path + suffix)

    def __init__(self, db_path: str | None = None, device_address: str | None = None):
        if db_path is None:
            if device_address:
                db_path = self.db_path_for_device(device_address)
                os.makedirs(os.path.dirname(db_path), exist_ok=True)
            else:
                data_dir = os.path.join(
                    os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")), "meshy"
                )
                os.makedirs(data_dir, exist_ok=True)
                db_path = os.path.join(data_dir, "meshy.db")
        self._db = sqlite3.connect(db_path)
        self._db.row_factory = sqlite3.Row
        self._db.execute("PRAGMA journal_mode=WAL")
        self._batch_depth = 0
        self._create_tables()

    def _create_tables(self):
        self._db.executescript("""
            CREATE TABLE IF NOT EXISTS contacts (
                public_key_hex TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                type INTEGER NOT NULL DEFAULT 1,
                flags INTEGER NOT NULL DEFAULT 0,
                path_length INTEGER NOT NULL DEFAULT -1,
                path BLOB NOT NULL DEFAULT X'',
                latitude REAL,
                longitude REAL,
                last_seen REAL,
                last_message_at REAL,
                is_active INTEGER NOT NULL DEFAULT 1
            );

            CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                contact_key_hex TEXT NOT NULL,
                sender_key_hex TEXT NOT NULL,
                text TEXT NOT NULL,
                timestamp REAL NOT NULL,
                is_outgoing INTEGER NOT NULL,
                is_cli INTEGER NOT NULL DEFAULT 0,
                status INTEGER NOT NULL DEFAULT 0,
                message_id TEXT,
                FOREIGN KEY (contact_key_hex) REFERENCES contacts(public_key_hex)
            );

            CREATE TABLE IF NOT EXISTS channels (
                idx INTEGER PRIMARY KEY,
                name TEXT NOT NULL DEFAULT '',
                psk BLOB NOT NULL DEFAULT X'00000000000000000000000000000000',
                unread_count INTEGER NOT NULL DEFAULT 0,
                notification_level INTEGER NOT NULL DEFAULT 0
            );

            CREATE TABLE IF NOT EXISTS channel_messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                channel_index INTEGER NOT NULL,
                sender_name TEXT NOT NULL DEFAULT '',
                text TEXT NOT NULL,
                timestamp REAL NOT NULL,
                is_outgoing INTEGER NOT NULL DEFAULT 0,
                FOREIGN KEY (channel_index) REFERENCES channels(idx)
            );

            CREATE TABLE IF NOT EXISTS channel_message_paths (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                channel_index INTEGER NOT NULL,
                sender_timestamp INTEGER NOT NULL,
                msg_text_hash TEXT NOT NULL,
                snr REAL,
                path_len INTEGER NOT NULL DEFAULT -1,
                path_bytes TEXT NOT NULL DEFAULT ''
            );

            CREATE TABLE IF NOT EXISTS regions (
                name TEXT PRIMARY KEY
            );

            CREATE TABLE IF NOT EXISTS discovered_contacts (
                public_key_hex TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                type INTEGER NOT NULL DEFAULT 1,
                flags INTEGER NOT NULL DEFAULT 0,
                path_length INTEGER NOT NULL DEFAULT -1,
                path BLOB NOT NULL DEFAULT X'',
                path_hash_size INTEGER NOT NULL DEFAULT 1,
                latitude REAL,
                longitude REAL,
                last_seen REAL
            );

            CREATE INDEX IF NOT EXISTS idx_messages_contact
                ON messages(contact_key_hex, timestamp);
            CREATE INDEX IF NOT EXISTS idx_channel_messages_channel
                ON channel_messages(channel_index, timestamp);
            CREATE INDEX IF NOT EXISTS idx_channel_message_paths
                ON channel_message_paths(channel_index, sender_timestamp, msg_text_hash);
        """)
        self._db.commit()
        self._migrate()

    def _migrate(self):
        """Add columns that may not exist in older databases."""
        cursor = self._db.execute("PRAGMA table_info(channels)")
        ch_columns = {row[1] for row in cursor.fetchall()}
        if "notification_level" not in ch_columns:
            self._db.execute(
                "ALTER TABLE channels ADD COLUMN notification_level INTEGER NOT NULL DEFAULT 0"
            )
        if "last_read_at" not in ch_columns:
            self._db.execute("ALTER TABLE channels ADD COLUMN last_read_at REAL")
        if "region" not in ch_columns:
            self._db.execute("ALTER TABLE channels ADD COLUMN region TEXT NOT NULL DEFAULT ''")

        cursor = self._db.execute("PRAGMA table_info(contacts)")
        ct_columns = {row[1] for row in cursor.fetchall()}
        if "last_read_at" not in ct_columns:
            self._db.execute("ALTER TABLE contacts ADD COLUMN last_read_at REAL")
        if "path_hash_size" not in ct_columns:
            self._db.execute(
                "ALTER TABLE contacts ADD COLUMN path_hash_size INTEGER NOT NULL DEFAULT 1"
            )
        if "device_lastmod" not in ct_columns:
            self._db.execute("ALTER TABLE contacts ADD COLUMN device_lastmod REAL")
        if "force_flood" not in ct_columns:
            self._db.execute(
                "ALTER TABLE contacts ADD COLUMN force_flood INTEGER NOT NULL DEFAULT 0"
            )
        if "auto_reset_path" not in ct_columns:
            self._db.execute(
                "ALTER TABLE contacts ADD COLUMN auto_reset_path INTEGER NOT NULL DEFAULT 1"
            )
        if "smart_route_selection" not in ct_columns:
            self._db.execute(
                "ALTER TABLE contacts ADD COLUMN smart_route_selection INTEGER NOT NULL DEFAULT 0"
            )

        cursor = self._db.execute("PRAGMA table_info(messages)")
        msg_columns = {row[1] for row in cursor.fetchall()}
        if "room_sender_name" not in msg_columns:
            self._db.execute(
                "ALTER TABLE messages ADD COLUMN room_sender_name TEXT NOT NULL DEFAULT ''"
            )

        cursor = self._db.execute("PRAGMA table_info(discovered_contacts)")
        dc_columns = {row[1] for row in cursor.fetchall()}
        if "received_at" not in dc_columns:
            self._db.execute("ALTER TABLE discovered_contacts ADD COLUMN received_at REAL")

        self._db.execute("""
            CREATE TABLE IF NOT EXISTS room_passwords (
                room_key_hex TEXT PRIMARY KEY,
                password TEXT NOT NULL
            )
        """)
        self._db.execute("""
            CREATE TABLE IF NOT EXISTS settings (
                key TEXT PRIMARY KEY,
                value TEXT NOT NULL DEFAULT ''
            )
        """)

        self._db.execute("""
            CREATE TABLE IF NOT EXISTS discovered_contacts (
                public_key_hex TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                type INTEGER NOT NULL DEFAULT 1,
                flags INTEGER NOT NULL DEFAULT 0,
                path_length INTEGER NOT NULL DEFAULT -1,
                path BLOB NOT NULL DEFAULT X'',
                path_hash_size INTEGER NOT NULL DEFAULT 1,
                latitude REAL,
                longitude REAL,
                last_seen REAL
            )
        """)

        self._db.execute("""
            CREATE TABLE IF NOT EXISTS path_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                contact_key_hex TEXT NOT NULL,
                path_bytes TEXT NOT NULL,
                path_hash_size INTEGER NOT NULL DEFAULT 1,
                hop_count INTEGER NOT NULL DEFAULT 0,
                success_count INTEGER NOT NULL DEFAULT 0,
                failure_count INTEGER NOT NULL DEFAULT 0,
                last_trip_ms INTEGER,
                route_weight REAL NOT NULL DEFAULT 2.5,
                last_success_at REAL,
                UNIQUE(contact_key_hex, path_bytes)
            )
        """)

        self._db.execute("""
            CREATE INDEX IF NOT EXISTS idx_messages_contact_outgoing
                ON messages(contact_key_hex, is_outgoing, timestamp)
        """)
        self._db.execute("""
            CREATE INDEX IF NOT EXISTS idx_channel_messages_unread
                ON channel_messages(channel_index, is_outgoing, timestamp)
        """)

        self._db.commit()
        self._cleanup_old_data()

    def _cleanup_old_data(self):
        """Remove stale data that is no longer relevant."""
        import time

        week_ago = time.time() - 7 * 86400
        self._db.execute(
            "DELETE FROM channel_message_paths WHERE sender_timestamp < ?", (int(week_ago),)
        )
        ninety_days_ago = time.time() - 90 * 86400
        self._db.execute("DELETE FROM discovered_contacts WHERE last_seen < ?", (ninety_days_ago,))
        self._db.commit()

    def _commit(self):
        if self._batch_depth == 0:
            self._db.commit()

    @contextlib.contextmanager
    def batch(self):
        self._batch_depth += 1
        try:
            yield
        finally:
            self._batch_depth -= 1
            if self._batch_depth == 0:
                self._commit()

    def close(self):
        self._db.close()

    # ─── Contacts ──────────────────────────────────────────────

    def save_contact(self, contact: Contact):
        self._db.execute(
            """
            INSERT INTO contacts
            (public_key_hex, name, type, flags, path_length, path, path_hash_size,
             force_flood, auto_reset_path, smart_route_selection,
             latitude, longitude, last_seen, device_lastmod, last_message_at, is_active)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(public_key_hex) DO UPDATE SET
                name = excluded.name,
                type = excluded.type,
                flags = excluded.flags,
                path_length = excluded.path_length,
                path = excluded.path,
                path_hash_size = excluded.path_hash_size,
                force_flood = excluded.force_flood,
                auto_reset_path = excluded.auto_reset_path,
                smart_route_selection = excluded.smart_route_selection,
                latitude = excluded.latitude,
                longitude = excluded.longitude,
                last_seen = excluded.last_seen,
                device_lastmod = excluded.device_lastmod,
                last_message_at = excluded.last_message_at,
                is_active = excluded.is_active
        """,
            (
                contact.public_key_hex,
                contact.name,
                contact.type,
                contact.flags,
                contact.path_length,
                contact.path,
                contact.path_hash_size,
                int(contact.force_flood),
                int(contact.auto_reset_path),
                int(contact.smart_route_selection),
                contact.latitude,
                contact.longitude,
                contact.last_seen.timestamp() if contact.last_seen else None,
                contact.device_lastmod.timestamp() if contact.device_lastmod else None,
                contact.last_message_at.timestamp() if contact.last_message_at else None,
                int(contact.is_active),
            ),
        )
        self._commit()

    def deactivate_contact(self, public_key_hex: str):
        """Mark a contact as inactive (no longer on the device)."""
        self._db.execute(
            "UPDATE contacts SET is_active = 0 WHERE public_key_hex = ?", (public_key_hex,)
        )
        self._commit()

    def get_contacts(self) -> list[Contact]:
        rows = self._db.execute(
            "SELECT * FROM contacts WHERE is_active = 1 ORDER BY last_seen DESC"
        ).fetchall()
        return [self._row_to_contact(r) for r in rows]

    def get_max_device_lastmod(self) -> int | None:
        """Return the highest device_lastmod timestamp among active contacts."""
        row = self._db.execute(
            "SELECT MAX(device_lastmod) FROM contacts WHERE is_active = 1"
        ).fetchone()
        val = row[0] if row else None
        return int(val) if val else None

    def get_contact(self, public_key_hex: str) -> Contact | None:
        row = self._db.execute(
            "SELECT * FROM contacts WHERE public_key_hex = ?", (public_key_hex,)
        ).fetchone()
        return self._row_to_contact(row) if row else None

    def remove_contact(self, public_key_hex: str):
        self._db.execute("DELETE FROM contacts WHERE public_key_hex = ?", (public_key_hex,))
        self._db.execute("DELETE FROM messages WHERE contact_key_hex = ?", (public_key_hex,))
        self._commit()

    def find_contact_by_prefix(self, prefix: bytes) -> Contact | None:
        """Find a contact by the first 6 bytes of its public key."""
        prefix_hex = prefix.hex()
        upper = prefix_hex[:-1] + chr(ord(prefix_hex[-1]) + 1)
        row = self._db.execute(
            "SELECT * FROM contacts WHERE public_key_hex >= ? AND public_key_hex < ?",
            (prefix_hex, upper),
        ).fetchone()
        return self._row_to_contact(row) if row else None

    def _row_to_contact(self, row) -> Contact:
        return Contact(
            public_key=bytes.fromhex(row["public_key_hex"]),
            name=row["name"],
            type=row["type"],
            flags=row["flags"],
            path_length=row["path_length"],
            path=bytes(row["path"]),
            path_hash_size=row["path_hash_size"],
            force_flood=bool(row["force_flood"]),
            auto_reset_path=bool(row["auto_reset_path"]),
            smart_route_selection=bool(row["smart_route_selection"]),
            latitude=row["latitude"],
            longitude=row["longitude"],
            last_seen=datetime.fromtimestamp(row["last_seen"]) if row["last_seen"] else None,
            device_lastmod=datetime.fromtimestamp(row["device_lastmod"])
            if row["device_lastmod"]
            else None,
            last_message_at=datetime.fromtimestamp(row["last_message_at"])
            if row["last_message_at"]
            else None,
            is_active=bool(row["is_active"]),
        )

    def get_oldest_unread_contact_timestamp(self, contact_key_hex: str) -> float | None:
        """Return the timestamp of the oldest unread incoming message for a contact."""
        row = self._db.execute(
            """
            SELECT MIN(timestamp) as ts FROM messages
            WHERE contact_key_hex = ? AND is_outgoing = 0
              AND timestamp > COALESCE(
                (SELECT last_read_at FROM contacts WHERE public_key_hex = ?), 0)
        """,
            (contact_key_hex, contact_key_hex),
        ).fetchone()
        return row["ts"] if row and row["ts"] else None

    def get_contact_last_read_at(self, public_key_hex: str) -> float | None:
        row = self._db.execute(
            "SELECT last_read_at FROM contacts WHERE public_key_hex = ?", (public_key_hex,)
        ).fetchone()
        return row["last_read_at"] if row and row["last_read_at"] else None

    def set_contact_last_read_at(self, public_key_hex: str, timestamp: float):
        self._db.execute(
            "UPDATE contacts SET last_read_at = ? WHERE public_key_hex = ?",
            (timestamp, public_key_hex),
        )
        self._commit()

    # ─── Discovered Contacts ─────────────────────────────────

    def save_discovered_contact(self, contact: Contact):
        self._db.execute(
            """
            INSERT INTO discovered_contacts
            (public_key_hex, name, type, flags, path_length, path,
             path_hash_size, latitude, longitude, last_seen, received_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(public_key_hex) DO UPDATE SET
                name = excluded.name,
                type = excluded.type,
                flags = excluded.flags,
                path_length = excluded.path_length,
                path = excluded.path,
                path_hash_size = excluded.path_hash_size,
                latitude = excluded.latitude,
                longitude = excluded.longitude,
                last_seen = excluded.last_seen,
                received_at = excluded.received_at
        """,
            (
                contact.public_key_hex,
                contact.name,
                contact.type,
                contact.flags,
                contact.path_length,
                contact.path,
                contact.path_hash_size,
                contact.latitude,
                contact.longitude,
                contact.last_seen.timestamp() if contact.last_seen else None,
                contact.received_at.timestamp() if contact.received_at else None,
            ),
        )
        self._commit()

    def get_discovered_contacts(self) -> list[Contact]:
        rows = self._db.execute(
            "SELECT * FROM discovered_contacts ORDER BY last_seen DESC"
        ).fetchall()
        return [self._row_to_discovered_contact(r) for r in rows]

    def _row_to_discovered_contact(self, row) -> Contact:
        return Contact(
            public_key=bytes.fromhex(row["public_key_hex"]),
            name=row["name"],
            type=row["type"],
            flags=row["flags"],
            path_length=row["path_length"],
            path=bytes(row["path"]),
            path_hash_size=row["path_hash_size"],
            latitude=row["latitude"],
            longitude=row["longitude"],
            last_seen=datetime.fromtimestamp(row["last_seen"]) if row["last_seen"] else None,
            received_at=datetime.fromtimestamp(row["received_at"]) if row["received_at"] else None,
            is_active=True,
        )

    def count_unread_messages(self, contact_key_hex: str) -> int:
        """Count incoming messages newer than last_read_at for a contact."""
        row = self._db.execute(
            """
            SELECT COUNT(*) as cnt FROM messages
            WHERE contact_key_hex = ? AND is_outgoing = 0
              AND timestamp > COALESCE(
                (SELECT last_read_at FROM contacts WHERE public_key_hex = ?), 0)
        """,
            (contact_key_hex, contact_key_hex),
        ).fetchone()
        return row["cnt"] if row else 0

    def count_unread_channel_messages(self, channel_index: int) -> int:
        """Count channel messages newer than last_read_at."""
        row = self._db.execute(
            """
            SELECT COUNT(*) as cnt FROM channel_messages
            WHERE channel_index = ? AND is_outgoing = 0
              AND timestamp > COALESCE(
                (SELECT last_read_at FROM channels WHERE idx = ?), 0)
        """,
            (channel_index, channel_index),
        ).fetchone()
        return row["cnt"] if row else 0

    # ─── Messages ──────────────────────────────────────────────

    def save_message(self, contact_key_hex: str, message: Message):
        self._db.execute(
            """
            INSERT INTO messages
            (contact_key_hex, sender_key_hex, text, timestamp,
             is_outgoing, is_cli, status, message_id, room_sender_name)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
            (
                contact_key_hex,
                message.sender_key_hex,
                message.text,
                message.timestamp.timestamp(),
                int(message.is_outgoing),
                int(message.is_cli),
                int(message.status),
                message.message_id,
                message.room_sender_name,
            ),
        )
        # Update contact's last_message_at
        self._db.execute(
            """
            UPDATE contacts SET last_message_at = ?
            WHERE public_key_hex = ?
        """,
            (message.timestamp.timestamp(), contact_key_hex),
        )
        self._commit()

    def get_messages(self, contact_key_hex: str, limit: int = 30, offset: int = 0) -> list[Message]:
        rows = self._db.execute(
            """
            SELECT * FROM messages
            WHERE contact_key_hex = ?
            ORDER BY timestamp DESC LIMIT ? OFFSET ?
        """,
            (contact_key_hex, limit, offset),
        ).fetchall()
        return [self._row_to_message(r) for r in reversed(rows)]

    def has_duplicate_message(self, contact_key_hex: str, timestamp: float, text: str) -> bool:
        """Check if an incoming message is a duplicate.

        Two layers: first, an exact timestamp+text match catches firmware
        retransmissions that slipped through.  Second, a check against
        the last incoming message from the contact within 30 minutes
        catches manual resends (new timestamp, same text, no reply
        from us in between).
        """
        # Layer 1: exact sender_timestamp match (firmware duplicate)
        exact = self._db.execute(
            """
            SELECT 1 FROM messages
            WHERE contact_key_hex = ? AND text = ?
              AND is_outgoing = 0 AND timestamp = ?
            LIMIT 1
        """,
            (contact_key_hex, text, timestamp),
        ).fetchone()
        if exact is not None:
            return True
        # Layer 2: manual resend (same text, last incoming, within 30 min)
        row = self._db.execute(
            """
            SELECT text, timestamp FROM messages
            WHERE contact_key_hex = ? AND is_outgoing = 0
            ORDER BY timestamp DESC
            LIMIT 1
        """,
            (contact_key_hex,),
        ).fetchone()
        if row is None:
            return False
        last_text, last_ts = row
        if last_text != text or (time.time() - last_ts) > 1800:
            return False
        # If we sent a reply after the last incoming message, the
        # conversation has moved on and this is a new response.
        outgoing = self._db.execute(
            """
            SELECT 1 FROM messages
            WHERE contact_key_hex = ? AND is_outgoing = 1
              AND timestamp > ?
            LIMIT 1
        """,
            (contact_key_hex, last_ts),
        ).fetchone()
        return outgoing is None

    def message_exists(self, contact_key_hex: str, timestamp: float, text: str) -> bool:
        """Check if a specific message already exists (for backup restore)."""
        row = self._db.execute(
            """
            SELECT 1 FROM messages
            WHERE contact_key_hex = ? AND text = ?
              AND is_outgoing = 0
              AND abs(timestamp - ?) <= 5
            LIMIT 1
        """,
            (contact_key_hex, text, timestamp),
        ).fetchone()
        return row is not None

    def delete_message(self, message_id: str):
        self._db.execute("DELETE FROM messages WHERE message_id = ?", (message_id,))
        self._commit()

    def update_message_status(self, message_id: str, status: MessageStatus):
        self._db.execute(
            "UPDATE messages SET status = ? WHERE message_id = ?", (int(status), message_id)
        )
        self._commit()

    def fail_orphaned_messages(self):
        """Mark any PENDING or SENT outgoing messages as FAILED.

        Called on disconnect to clean up messages that were in flight
        when the connection dropped.
        """
        self._db.execute(
            "UPDATE messages SET status = ? WHERE is_outgoing = 1 AND status IN (?, ?)",
            (int(MessageStatus.FAILED), int(MessageStatus.PENDING), int(MessageStatus.SENT)),
        )
        self._commit()

    def _row_to_message(self, row) -> Message:
        return Message(
            sender_key=bytes.fromhex(row["sender_key_hex"]),
            text=row["text"],
            timestamp=datetime.fromtimestamp(row["timestamp"]),
            is_outgoing=bool(row["is_outgoing"]),
            is_cli=bool(row["is_cli"]),
            status=MessageStatus(row["status"]),
            message_id=row["message_id"],
            room_sender_name=row["room_sender_name"],
        )

    # ─── Room Passwords ───────────────────────────────────────

    def save_room_password(self, room_key_hex: str, password: str):
        self._db.execute(
            "INSERT OR REPLACE INTO room_passwords (room_key_hex, password) VALUES (?, ?)",
            (room_key_hex, password),
        )
        self._commit()

    def get_room_password(self, room_key_hex: str) -> str | None:
        row = self._db.execute(
            "SELECT password FROM room_passwords WHERE room_key_hex = ?", (room_key_hex,)
        ).fetchone()
        return row["password"] if row else None

    def remove_room_password(self, room_key_hex: str):
        self._db.execute("DELETE FROM room_passwords WHERE room_key_hex = ?", (room_key_hex,))
        self._commit()

    # ─── Channels ──────────────────────────────────────────────

    def save_channel(self, channel: Channel):
        self._db.execute(
            """
            INSERT INTO channels (idx, name, psk, unread_count, notification_level, region)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(idx) DO UPDATE SET
                name = excluded.name,
                psk = excluded.psk,
                unread_count = excluded.unread_count,
                notification_level = excluded.notification_level,
                region = excluded.region
        """,
            (
                channel.index,
                channel.name,
                channel.psk,
                channel.unread_count,
                int(channel.notification_level),
                channel.region,
            ),
        )
        self._commit()

    def get_channels(self) -> list[Channel]:
        rows = self._db.execute("SELECT * FROM channels ORDER BY idx").fetchall()
        return [self._row_to_channel(r) for r in rows]

    def get_channel(self, index: int) -> Channel | None:
        row = self._db.execute("SELECT * FROM channels WHERE idx = ?", (index,)).fetchone()
        return self._row_to_channel(row) if row else None

    def _row_to_channel(self, row) -> Channel:
        return Channel(
            index=row["idx"],
            name=row["name"],
            psk=bytes(row["psk"]),
            unread_count=row["unread_count"],
            notification_level=NotificationLevel(row["notification_level"]),
            region=row["region"].split(",")[0].strip(),
        )

    def remove_channel(self, index: int):
        self._db.execute("DELETE FROM channels WHERE idx = ?", (index,))
        self._db.execute("DELETE FROM channel_messages WHERE channel_index = ?", (index,))
        self._commit()

    def set_channel_notification_level(self, index: int, level: NotificationLevel):
        self._db.execute(
            "UPDATE channels SET notification_level = ? WHERE idx = ?", (int(level), index)
        )
        self._commit()

    def set_channel_region(self, index: int, region: str):
        self._db.execute("UPDATE channels SET region = ? WHERE idx = ?", (region, index))
        self._commit()

    # ─── Regions ──────────────────────────────────────────────

    def get_regions(self) -> list[str]:
        rows = self._db.execute("SELECT name FROM regions ORDER BY name").fetchall()
        return [r["name"] for r in rows]

    def add_region(self, name: str):
        self._db.execute("INSERT OR IGNORE INTO regions (name) VALUES (?)", (name,))
        self._commit()

    def remove_region(self, name: str):
        self._db.execute("DELETE FROM regions WHERE name = ?", (name,))
        # Clear region from channels that used it
        self._db.execute("UPDATE channels SET region = '' WHERE region = ?", (name,))
        # Clear default scope if it was this region
        row = self._db.execute("SELECT value FROM settings WHERE key = 'default_scope'").fetchone()
        if row and row["value"] == name:
            self._db.execute(
                "INSERT OR REPLACE INTO settings (key, value) VALUES ('default_scope', '')"
            )
        self._commit()

    def get_default_scope(self) -> str:
        row = self._db.execute("SELECT value FROM settings WHERE key = 'default_scope'").fetchone()
        return row["value"] if row else ""

    def set_default_scope(self, scope: str):
        self._db.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES ('default_scope', ?)", (scope,)
        )
        self._commit()

    def get_oldest_unread_channel_timestamp(self, channel_index: int) -> float | None:
        """Return the timestamp of the oldest unread incoming channel message."""
        row = self._db.execute(
            """
            SELECT MIN(timestamp) as ts FROM channel_messages
            WHERE channel_index = ? AND is_outgoing = 0
              AND timestamp > COALESCE(
                (SELECT last_read_at FROM channels WHERE idx = ?), 0)
        """,
            (channel_index, channel_index),
        ).fetchone()
        return row["ts"] if row and row["ts"] else None

    def get_channel_last_read_at(self, index: int) -> float | None:
        row = self._db.execute(
            "SELECT last_read_at FROM channels WHERE idx = ?", (index,)
        ).fetchone()
        return row["last_read_at"] if row and row["last_read_at"] else None

    def get_max_channel_message_timestamp(self, channel_index: int) -> float | None:
        """Return the newest message timestamp for a channel."""
        row = self._db.execute(
            "SELECT MAX(timestamp) as ts FROM channel_messages WHERE channel_index = ?",
            (channel_index,),
        ).fetchone()
        return row["ts"] if row and row["ts"] else None

    def set_channel_last_read_at(self, index: int, timestamp: float):
        self._db.execute("UPDATE channels SET last_read_at = ? WHERE idx = ?", (timestamp, index))
        self._commit()

    # ─── Channel Messages ──────────────────────────────────────

    def save_channel_message(self, msg: ChannelMessage):
        self._db.execute(
            """
            INSERT INTO channel_messages
            (channel_index, sender_name, text, timestamp, is_outgoing)
            VALUES (?, ?, ?, ?, ?)
        """,
            (
                msg.channel_index,
                msg.sender_name,
                msg.text,
                msg.timestamp.timestamp(),
                int(msg.is_outgoing),
            ),
        )
        self._commit()

    def delete_channel_message(self, channel_index: int, timestamp: float, text: str):
        self._db.execute(
            """
            DELETE FROM channel_messages
            WHERE channel_index = ? AND abs(timestamp - ?) < 1 AND text = ?
        """,
            (channel_index, timestamp, text),
        )
        self._commit()

    def get_channel_messages(
        self, channel_index: int, limit: int = 30, offset: int = 0
    ) -> list[ChannelMessage]:
        rows = self._db.execute(
            """
            SELECT * FROM channel_messages
            WHERE channel_index = ?
            ORDER BY timestamp DESC LIMIT ? OFFSET ?
        """,
            (channel_index, limit, offset),
        ).fetchall()
        return [
            ChannelMessage(
                channel_index=r["channel_index"],
                sender_name=r["sender_name"],
                text=r["text"],
                timestamp=datetime.fromtimestamp(r["timestamp"]),
                is_outgoing=bool(r["is_outgoing"]),
            )
            for r in reversed(rows)
        ]

    # ─── Channel Message Paths ────────────────────────────────

    @staticmethod
    def _msg_text_hash(text: str) -> str:
        return hashlib.md5(text.encode("utf-8")).hexdigest()[:16]

    def save_channel_message_path(
        self, channel_index: int, sender_timestamp: int, text: str, path_info: dict
    ):
        text_hash = self._msg_text_hash(text)
        self._db.execute(
            """
            INSERT INTO channel_message_paths
            (channel_index, sender_timestamp, msg_text_hash, snr, path_len, path_bytes)
            VALUES (?, ?, ?, ?, ?, ?)
        """,
            (
                channel_index,
                sender_timestamp,
                text_hash,
                path_info.get("snr"),
                path_info.get("path_len", -1),
                path_info.get("path_bytes", ""),
            ),
        )
        self._commit()

    def get_channel_message_paths(
        self, channel_index: int, sender_timestamp: int, text: str
    ) -> list[dict]:
        text_hash = self._msg_text_hash(text)
        rows = self._db.execute(
            """
            SELECT snr, path_len, path_bytes FROM channel_message_paths
            WHERE channel_index = ? AND sender_timestamp = ? AND msg_text_hash = ?
        """,
            (channel_index, sender_timestamp, text_hash),
        ).fetchall()
        return [
            {"snr": r["snr"], "path_len": r["path_len"], "path_bytes": r["path_bytes"]}
            for r in rows
        ]

    # ─── Path History ─────────────────────────────────────────

    def upsert_path_record(
        self,
        contact_key_hex: str,
        path_bytes: str,
        path_hash_size: int,
        hop_count: int,
        success: bool,
        trip_ms: int | None = None,
        initial_weight: float = 2.5,
        weight_increment: float = 0.5,
        max_weight: float = 5.0,
    ):
        import time

        row = self._db.execute(
            "SELECT id, success_count, failure_count, route_weight "
            "FROM path_history WHERE contact_key_hex = ? AND path_bytes = ?",
            (contact_key_hex, path_bytes),
        ).fetchone()

        if row:
            new_success = row["success_count"] + (1 if success else 0)
            new_failure = row["failure_count"] + (0 if success else 1)
            new_weight = row["route_weight"] + weight_increment
            if new_weight <= 0:
                self._db.execute("DELETE FROM path_history WHERE id = ?", (row["id"],))
            else:
                self._db.execute(
                    """UPDATE path_history SET
                        success_count = ?, failure_count = ?, route_weight = ?,
                        last_trip_ms = COALESCE(?, last_trip_ms),
                        last_success_at = CASE WHEN ? THEN ? ELSE last_success_at END
                    WHERE id = ?""",
                    (
                        new_success,
                        new_failure,
                        min(new_weight, max_weight),
                        trip_ms,
                        success,
                        time.time() if success else None,
                        row["id"],
                    ),
                )
        else:
            self._db.execute(
                """INSERT INTO path_history
                (contact_key_hex, path_bytes, path_hash_size, hop_count,
                 success_count, failure_count, last_trip_ms, route_weight, last_success_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (
                    contact_key_hex,
                    path_bytes,
                    path_hash_size,
                    hop_count,
                    1 if success else 0,
                    0 if success else 1,
                    trip_ms,
                    initial_weight,
                    time.time() if success else None,
                ),
            )
        self._commit()

    def get_path_history(self, contact_key_hex: str) -> list:
        rows = self._db.execute(
            "SELECT * FROM path_history WHERE contact_key_hex = ? ORDER BY last_success_at DESC LIMIT 100",
            (contact_key_hex,),
        ).fetchall()
        return [dict(r) for r in rows]

    def clear_path_history(self, contact_key_hex: str):
        self._db.execute("DELETE FROM path_history WHERE contact_key_hex = ?", (contact_key_hex,))
        self._commit()
