# 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 sqlite3
import os
from datetime import datetime
from typing import Optional

from meshy.models import Contact, Message, Channel, ChannelMessage, 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'):
            try:
                os.remove(path + suffix)
            except FileNotFoundError:
                pass

    def __init__(self, db_path: Optional[str] = None,
                 device_address: Optional[str] = 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._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')

        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 ''")

        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.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 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,
             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,
                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,
            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._db.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._db.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) -> Optional[int]:
        """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) -> Optional[Contact]:
        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._db.commit()

    def find_contact_by_prefix(self, prefix: bytes) -> Optional[Contact]:
        """Find a contact by the first 6 bytes of its public key."""
        prefix_hex = prefix.hex()
        row = self._db.execute(
            'SELECT * FROM contacts WHERE public_key_hex LIKE ?',
            (prefix_hex + '%',)
        ).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'] if 'path_hash_size' in row.keys() else 1,
            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) -> Optional[float]:
        """Return the timestamp of the oldest unread incoming message for a contact."""
        last_read = self.get_contact_last_read_at(contact_key_hex)
        if last_read is None:
            row = self._db.execute('''
                SELECT MIN(timestamp) as ts FROM messages
                WHERE contact_key_hex = ? AND is_outgoing = 0
            ''', (contact_key_hex,)).fetchone()
        else:
            row = self._db.execute('''
                SELECT MIN(timestamp) as ts FROM messages
                WHERE contact_key_hex = ? AND timestamp > ? AND is_outgoing = 0
            ''', (contact_key_hex, last_read)).fetchone()
        return row['ts'] if row and row['ts'] else None

    def get_contact_last_read_at(self, public_key_hex: str) -> Optional[float]:
        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._db.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)
            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
        ''', (
            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,
        ))
        self._db.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'] if 'path_hash_size' in row.keys() else 1,
            latitude=row['latitude'],
            longitude=row['longitude'],
            last_seen=datetime.fromtimestamp(row['last_seen']) if row['last_seen'] 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."""
        last_read = self.get_contact_last_read_at(contact_key_hex)
        if last_read is None:
            row = self._db.execute('''
                SELECT COUNT(*) as cnt FROM messages
                WHERE contact_key_hex = ? AND is_outgoing = 0
            ''', (contact_key_hex,)).fetchone()
        else:
            row = self._db.execute('''
                SELECT COUNT(*) as cnt FROM messages
                WHERE contact_key_hex = ? AND timestamp > ? AND is_outgoing = 0
            ''', (contact_key_hex, last_read)).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."""
        last_read = self.get_channel_last_read_at(channel_index)
        if last_read is None:
            row = self._db.execute('''
                SELECT COUNT(*) as cnt FROM channel_messages
                WHERE channel_index = ? AND is_outgoing = 0
            ''', (channel_index,)).fetchone()
        else:
            row = self._db.execute('''
                SELECT COUNT(*) as cnt FROM channel_messages
                WHERE channel_index = ? AND timestamp > ? AND is_outgoing = 0
            ''', (channel_index, last_read)).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._db.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.

        Matches same sender + text within a 5-second window around the
        timestamp to catch retries where the timestamp may differ slightly.
        """
        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._db.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._db.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._db.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'] if 'room_sender_name' in row.keys() else '',
        )

    # ─── 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._db.commit()

    def get_room_password(self, room_key_hex: str) -> Optional[str]:
        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._db.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._db.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) -> Optional[Channel]:
        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'] if 'region' in row.keys() else '').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._db.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._db.commit()

    def set_channel_region(self, index: int, region: str):
        self._db.execute(
            'UPDATE channels SET region = ? WHERE idx = ?',
            (region, index)
        )
        self._db.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._db.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._db.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._db.commit()

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

    def get_channel_last_read_at(self, index: int) -> Optional[float]:
        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) -> Optional[float]:
        """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._db.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._db.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._db.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:
        import hashlib
        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._db.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]
