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

"""Backup and restore for MeshCore device configuration and messages."""

import glob
import json
import logging
import os
import uuid
from datetime import datetime

from gi.repository import GLib
from meshy.models import (
    ChannelMessage,
    Contact,
    Message,
    MessageStatus,
    NotificationLevel,
)
from meshy.protocol import (
    build_add_update_contact,
    build_device_query,
    build_set_auto_add_config,
    build_set_channel,
)

log = logging.getLogger(__name__)


class BackupManager:
    """Stateless helper for building and applying backup dicts."""

    ALL_SECTIONS = {
        "device_settings",
        "device_identity",
        "contacts",
        "channels",
        "messages",
        "regions",
        "discovered_contacts",
        "path_history",
        "local_settings",
    }

    def export_backup(self, win, sections: set[str] | None = None) -> dict:
        if sections is None:
            sections = self.ALL_SECTIONS

        si = win.self_info
        di = win.device_info

        backup = {
            "name": di.name or si.get("name", ""),
            "public_key": di.public_key or si.get("public_key", ""),
        }

        if "device_settings" in sections:
            backup["radio_settings"] = {
                "frequency": int(di.radio_freq * 1000)
                if di.radio_freq
                else si.get("radio_freq", 0) * 1000,
                "bandwidth": int(di.radio_bw * 1000)
                if di.radio_bw
                else si.get("radio_bw", 0) * 1000,
                "spreading_factor": di.radio_sf or si.get("radio_sf", 0),
                "coding_rate": di.radio_cr or si.get("radio_cr", 0),
                "tx_power": di.tx_power or si.get("tx_power", 0),
            }
            backup["position_settings"] = {
                "latitude": str(si.get("adv_lat", 0.0)),
                "longitude": str(si.get("adv_lon", 0.0)),
            }
            backup["other_settings"] = {
                "manual_add_contacts": 1 if si.get("manual_add_contacts") else 0,
                "advert_location_policy": si.get("adv_loc_policy", 0),
            }
            backup["auto_add_settings"] = win.get_auto_add_config()

        if "channels" in sections:
            channels_list = []
            for ch in win.channels:
                if not ch.is_empty:
                    channels_list.append(
                        {
                            "name": ch.name,
                            "secret": ch.psk_hex,
                        }
                    )
            if channels_list:
                backup["channels"] = channels_list

            retention = {}
            for ch in win.channels:
                if not ch.is_empty and ch.retention_days != 0:
                    retention[str(ch.index)] = ch.retention_days
            if retention:
                backup["retention_levels"] = retention

        if "contacts" in sections:
            contacts_list = []
            for c in win.contacts:
                contacts_list.append(
                    {
                        "type": c.type,
                        "name": c.name,
                        "public_key": c.public_key_hex,
                        "flags": c.flags,
                        "latitude": str(c.latitude) if c.latitude else "0.0",
                        "longitude": str(c.longitude) if c.longitude else "0.0",
                        "last_advert": int(c.last_seen.timestamp()) if c.last_seen else 0,
                        "last_modified": int(c.device_lastmod.timestamp())
                        if c.device_lastmod
                        else 0,
                        "out_path": ",".join(c.path_hops) if c.path_hops else None,
                    }
                )
            if contacts_list:
                backup["contacts"] = contacts_list

        if win.storage:
            if "messages" in sections:
                dm_messages = {}
                for c in win.contacts:
                    msgs = win.storage.get_messages(c.public_key_hex, limit=10000)
                    if msgs:
                        dm_messages[c.public_key_hex] = [
                            {
                                "text": m.text,
                                "timestamp": m.timestamp.timestamp(),
                                "is_outgoing": m.is_outgoing,
                                "sender_key_hex": m.sender_key_hex,
                                "room_sender_name": m.room_sender_name,
                            }
                            for m in msgs
                        ]
                if dm_messages:
                    backup["messages"] = dm_messages

                ch_messages = {}
                for ch in win.channels:
                    if ch.is_empty:
                        continue
                    msgs = win.storage.get_channel_messages(ch.index, limit=10000)
                    if msgs:
                        ch_messages[str(ch.index)] = [
                            {
                                "sender_name": m.sender_name,
                                "text": m.text,
                                "timestamp": m.timestamp.timestamp(),
                                "is_outgoing": m.is_outgoing,
                            }
                            for m in msgs
                        ]
                if ch_messages:
                    backup["channel_messages"] = ch_messages

                passwords = {}
                for c in win.contacts:
                    pw = win.storage.get_room_password(c.public_key_hex)
                    if pw:
                        passwords[c.public_key_hex] = pw
                if passwords:
                    backup["room_passwords"] = passwords

                notif = {}
                for ch in win.channels:
                    if not ch.is_empty and ch.notification_level != NotificationLevel.DEFAULT:
                        notif[str(ch.index)] = ch.notification_level.value
                if notif:
                    backup["notification_levels"] = notif

                blocked = list(win.storage.get_blocked_channel_senders())
                if blocked:
                    backup["blocked_channel_senders"] = sorted(blocked)

            if "regions" in sections:
                regions = win.storage.get_regions()
                if regions:
                    backup["regions"] = regions

            if "discovered_contacts" in sections:
                discovered = [
                    {
                        "public_key": c.public_key_hex,
                        "name": c.name,
                        "type": c.type,
                        "flags": c.flags,
                        "latitude": c.latitude,
                        "longitude": c.longitude,
                        "last_seen": c.last_seen.timestamp() if c.last_seen else None,
                        "received_at": c.received_at.timestamp() if c.received_at else None,
                    }
                    for c in win.storage.get_discovered_contacts()
                ]
                if discovered:
                    backup["discovered_contacts"] = discovered

            if "path_history" in sections:
                all_history = {}
                for c in win.contacts:
                    entries = win.storage.get_path_history(c.public_key_hex)
                    if entries:
                        all_history[c.public_key_hex] = [
                            {
                                "path_bytes": e["path_bytes"],
                                "path_hash_size": e["path_hash_size"],
                                "hop_count": e["hop_count"],
                                "success_count": e["success_count"],
                                "failure_count": e["failure_count"],
                                "last_trip_ms": e["last_trip_ms"],
                                "route_weight": e["route_weight"],
                                "last_success_at": e["last_success_at"],
                            }
                            for e in entries
                        ]
                if all_history:
                    backup["path_history"] = all_history

            if "local_settings" in sections:
                local = {}
                scope = win.storage.get_default_scope()
                if scope:
                    local["default_scope"] = scope
                if local:
                    backup["local_settings"] = local

        return backup

    _BACKUP_KEYS_FOR_SECTION = {
        "device_settings": [
            "name",
            "radio_settings",
            "position_settings",
            "other_settings",
            "auto_add_settings",
        ],
        "device_identity": ["private_key"],
        "contacts": ["contacts"],
        "channels": ["channels", "retention_levels"],
        "messages": [
            "messages",
            "channel_messages",
            "room_passwords",
            "notification_levels",
            "blocked_channel_senders",
        ],
        "regions": ["regions"],
        "discovered_contacts": ["discovered_contacts"],
        "path_history": ["path_history"],
        "local_settings": ["local_settings"],
    }

    @classmethod
    def available_sections(cls, backup: dict) -> set[str]:
        result = set()
        for section, keys in cls._BACKUP_KEYS_FOR_SECTION.items():
            for key in keys:
                val = backup.get(key)
                if val:
                    result.add(section)
                    break
        return result

    def import_backup(self, win, backup: dict, sections: set[str] | None = None, on_complete=None):
        if sections is None:
            sections = self.ALL_SECTIONS

        key_hex = backup.get("private_key", "")
        if "device_identity" in sections and key_hex and len(key_hex) == 128:
            try:
                key_bytes = bytes.fromhex(key_hex)
            except ValueError:
                key_bytes = None

            if key_bytes:

                def _after_key():
                    result = self._do_import_data(win, backup, sections)
                    if on_complete:
                        on_complete(result)

                def _on_key_error(err):
                    log.warning(
                        "Private key import failed: %s",
                        err.get("message", "") if err else "unknown",
                    )
                    _after_key()

                win._frame_handler.expect_ok(
                    on_ok=_after_key,
                    on_error=_on_key_error,
                )
                win.import_private_key(key_bytes)
                return 0, 0

        result = self._do_import_data(win, backup, sections)
        if on_complete:
            on_complete(result)
        return result

    def _do_import_data(self, win, backup: dict, sections: set[str]):
        imported_contacts = 0
        imported_channels = 0

        settings_cmds = []
        if "device_settings" in sections:
            name = backup.get("name", "")
            if name:
                settings_cmds.append(lambda: win.set_advert_name(name))

            radio = backup.get("radio_settings", {})
            if radio:
                freq = radio.get("frequency", 0)
                bw = radio.get("bandwidth", 0)
                sf = radio.get("spreading_factor", 0)
                cr = radio.get("coding_rate", 0)
                tx = radio.get("tx_power", 0)
                if freq and bw and sf and cr:
                    settings_cmds.append(lambda: win.set_radio_params(freq, bw, sf, cr))
                if tx:
                    settings_cmds.append(lambda: win.set_tx_power(tx))

            pos = backup.get("position_settings", {})
            if pos:
                try:
                    lat = float(pos.get("latitude", 0))
                    lon = float(pos.get("longitude", 0))
                    if abs(lat) > 1e-6 or abs(lon) > 1e-6:
                        settings_cmds.append(lambda: win.set_advert_latlon(lat, lon))
                except (ValueError, TypeError):
                    pass

            other = backup.get("other_settings", {})
            if other:
                loc_policy = other.get("advert_location_policy", None)
                if loc_policy is not None:
                    settings_cmds.append(lambda: win.set_advert_loc_policy(loc_policy))

            auto = backup.get("auto_add_settings", {})
            if auto:
                frame = build_set_auto_add_config(
                    chat=auto.get("auto_add_chat", False),
                    repeater=auto.get("auto_add_repeater", False),
                    room=auto.get("auto_add_room_server", False),
                    sensor=auto.get("auto_add_sensor", False),
                    overwrite_oldest=auto.get("overwrite_oldest", False),
                    max_hops=auto.get("max_hops", 0),
                )
                settings_cmds.append(lambda: win.send_frame(frame))

        for i, cmd in enumerate(settings_cmds):
            GLib.timeout_add(i * 300, lambda c=cmd: c() or False)
        settings_delay = len(settings_cmds) * 300

        if "device_settings" in sections:
            di = win._device_info
            name = backup.get("name", "")
            if name:
                di.name = name
            radio = backup.get("radio_settings", {})
            if radio:
                freq = radio.get("frequency", 0)
                bw = radio.get("bandwidth", 0)
                sf = radio.get("spreading_factor", 0)
                cr = radio.get("coding_rate", 0)
                tx = radio.get("tx_power", 0)
                if freq and bw and sf and cr:
                    di.radio_freq = freq
                    di.radio_bw = bw
                    di.radio_sf = sf
                    di.radio_cr = cr
                if tx:
                    di.tx_power = tx
            pos = backup.get("position_settings", {})
            if pos:
                try:
                    lat_v = float(pos.get("latitude", 0))
                    lon_v = float(pos.get("longitude", 0))
                    if abs(lat_v) > 1e-6 or abs(lon_v) > 1e-6:
                        di.latitude = lat_v
                        di.longitude = lon_v
                except (ValueError, TypeError):
                    pass
            win._settings_view.update_device_info(di)

        device_cmds = []
        if "channels" in sections:
            channels = backup.get("channels", [])
            used = {c.index for c in win.channels if not c.is_empty}
            for ch_data in channels:
                ch_name = ch_data.get("name", "")
                secret_hex = ch_data.get("secret", "")
                if ch_name and secret_hex and len(secret_hex) == 32:
                    try:
                        psk = bytes.fromhex(secret_hex)
                        already = any(
                            c.psk_hex == secret_hex for c in win.channels if not c.is_empty
                        )
                        if already:
                            continue
                        slot = None
                        for idx in range(win.device_info.max_channels or 8):
                            if idx not in used:
                                slot = idx
                                break
                        if slot is not None:
                            device_cmds.append(
                                lambda s=slot, n=ch_name, p=psk: win.send_frame(
                                    build_set_channel(s, n, p)
                                )
                            )
                            imported_channels += 1
                            used.add(slot)
                    except ValueError:
                        pass

            if win.storage:
                for ch_idx_str, days in backup.get("retention_levels", {}).items():
                    win.storage.set_channel_retention_days(int(ch_idx_str), days)

        if "contacts" in sections:
            contacts = backup.get("contacts", [])
            contacts_by_key = {c.public_key_hex: c for c in win.contacts}
            for c_data in contacts:
                pub_key_hex = c_data.get("public_key", "")
                if not pub_key_hex or len(pub_key_hex) != 64:
                    continue
                if pub_key_hex in contacts_by_key:
                    continue
                try:
                    pub_key = bytes.fromhex(pub_key_hex)
                    c_name = c_data.get("name", "Unknown")
                    contact_type = c_data.get("type", 1)
                    flags = c_data.get("flags", 0)
                    lat = float(c_data.get("latitude", 0))
                    lon = float(c_data.get("longitude", 0))
                    last_mod = c_data.get("last_modified")
                    device_cmds.append(
                        lambda pk=pub_key,
                        ct=contact_type,
                        fl=flags,
                        n=c_name,
                        la=lat,
                        lo=lon,
                        lm=last_mod: win.send_frame(
                            build_add_update_contact(
                                pub_key=pk,
                                contact_type=ct,
                                flags=fl,
                                name=n,
                                lat=la if abs(la) > 1e-6 else None,
                                lon=lo if abs(lo) > 1e-6 else None,
                                last_modified=lm,
                            )
                        )
                    )
                    imported_contacts += 1
                except (ValueError, TypeError):
                    pass

        for i, cmd in enumerate(device_cmds):
            GLib.timeout_add(settings_delay + i * 300, lambda c=cmd: c() or False)

        if "messages" in sections and win.storage:
            with win.storage.batch():
                for pub_key_hex, msgs in backup.get("messages", {}).items():
                    for m in msgs:
                        ts = m.get("timestamp", 0)
                        text = m.get("text", "")
                        if win.storage.message_exists(pub_key_hex, ts, text):
                            continue
                        message = Message(
                            sender_key=bytes.fromhex(m.get("sender_key_hex", pub_key_hex)),
                            text=text,
                            timestamp=datetime.fromtimestamp(ts),
                            is_outgoing=m.get("is_outgoing", False),
                            status=MessageStatus.DELIVERED,
                            message_id=str(uuid.uuid4()),
                            room_sender_name=m.get("room_sender_name", ""),
                        )
                        win.storage.save_message(pub_key_hex, message)

                for ch_idx_str, msgs in backup.get("channel_messages", {}).items():
                    ch_idx = int(ch_idx_str)
                    for m in msgs:
                        msg = ChannelMessage(
                            channel_index=ch_idx,
                            sender_name=m.get("sender_name", ""),
                            text=m.get("text", ""),
                            timestamp=datetime.fromtimestamp(m.get("timestamp", 0)),
                            is_outgoing=m.get("is_outgoing", False),
                        )
                        win.storage.save_channel_message(msg)

                for pub_key_hex, pw in backup.get("room_passwords", {}).items():
                    win.storage.save_room_password(pub_key_hex, pw)

                for name in backup.get("blocked_channel_senders", []):
                    win.storage.block_channel_sender(name)

        if win.storage:
            if "regions" in sections:
                for region_name in backup.get("regions", []):
                    win.storage.add_region(region_name)

            if "discovered_contacts" in sections:
                for dc in backup.get("discovered_contacts", []):
                    pub_key_hex = dc.get("public_key", "")
                    if not pub_key_hex or len(pub_key_hex) != 64:
                        continue
                    contact = Contact(
                        public_key=bytes.fromhex(pub_key_hex),
                        name=dc.get("name", ""),
                        type=dc.get("type", 1),
                        flags=dc.get("flags", 0),
                        latitude=dc.get("latitude"),
                        longitude=dc.get("longitude"),
                        last_seen=datetime.fromtimestamp(dc["last_seen"])
                        if dc.get("last_seen")
                        else None,
                        received_at=datetime.fromtimestamp(dc["received_at"])
                        if dc.get("received_at")
                        else None,
                    )
                    win.storage.save_discovered_contact(contact)

            if "path_history" in sections:
                for contact_key, entries in backup.get("path_history", {}).items():
                    for e in entries:
                        win.storage.import_path_history_entry(
                            contact_key_hex=contact_key,
                            path_bytes=e["path_bytes"],
                            path_hash_size=e.get("path_hash_size", 1),
                            hop_count=e.get("hop_count", 0),
                            success_count=e.get("success_count", 0),
                            failure_count=e.get("failure_count", 0),
                            last_trip_ms=e.get("last_trip_ms"),
                            route_weight=e.get("route_weight", 2.5),
                            last_success_at=e.get("last_success_at"),
                        )

            if "local_settings" in sections:
                local = backup.get("local_settings", {})
                if "default_scope" in local:
                    win.storage.set_default_scope(local["default_scope"])

        if "messages" in sections and backup.get("blocked_channel_senders"):
            win._frame_handler.update_blocked_senders()

        total_delay = settings_delay + len(device_cmds) * 300 + 1000
        GLib.timeout_add(total_delay, lambda: win._frame_handler.send_get_contacts() or False)
        GLib.timeout_add(total_delay + 500, lambda: win._fetch_channels_sequential(0) or False)
        GLib.timeout_add(total_delay + 1000, lambda: win.send_frame(build_device_query()) or False)

        return imported_contacts, imported_channels


_DAY_MS = 24 * 60 * 60 * 1000
_MAX_AUTO_BACKUPS = 7


class AutoBackupManager:
    def __init__(self):
        self._timer_id = None
        self._backup = BackupManager()

    @staticmethod
    def _backups_dir() -> str:
        data_dir = os.path.join(
            os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")),
            "meshy",
            "backups",
        )
        os.makedirs(data_dir, exist_ok=True)
        return data_dir

    @staticmethod
    def _safe_address(device_address: str) -> str:
        return device_address.replace(":", "_").replace("/", "_")

    def run(self, win) -> bool:
        if not win.is_connected or not win.storage:
            return False
        try:
            from gi.repository import Gio

            settings = Gio.Settings.new("page.codeberg.sesivany.Meshy")
            sections = set(settings.get_strv("auto-backup-sections"))
            sections.discard("device_identity")
            backup = self._backup.export_backup(win, sections=sections or None)
            addr = self._safe_address(win._transport.connected_address or "unknown")
            date_str = datetime.now().strftime("%Y-%m-%d")
            filename = f"auto_{addr}_{date_str}.json"
            path = os.path.join(self._backups_dir(), filename)
            with open(path, "w", encoding="utf-8") as f:
                json.dump(backup, f, indent=2, ensure_ascii=False)
            log.info("Auto-backup saved: %s", path)
            self._prune(addr)
            return True
        except Exception as e:
            log.error("Auto-backup failed: %s", e)
            return False

    def schedule(self, win):
        self.cancel()
        self.run(win)

        def _daily_backup():
            self.run(win)
            return True

        self._timer_id = GLib.timeout_add(_DAY_MS, _daily_backup)

    def cancel(self):
        if self._timer_id is not None:
            GLib.source_remove(self._timer_id)
            self._timer_id = None

    def list_backups(self, device_address: str | None = None) -> list[dict]:
        backups_dir = self._backups_dir()
        if device_address:
            pattern = f"auto_{self._safe_address(device_address)}_*.json"
        else:
            pattern = "auto_*.json"
        files = sorted(glob.glob(os.path.join(backups_dir, pattern)), reverse=True)
        result = []
        for path in files:
            try:
                with open(path, encoding="utf-8") as f:
                    data = json.load(f)
                mtime = os.path.getmtime(path)
                mdt = datetime.fromtimestamp(mtime)
                result.append(
                    {
                        "path": path,
                        "filename": os.path.basename(path),
                        "name": data.get("name", ""),
                        "contacts": len(data.get("contacts", [])),
                        "channels": len(data.get("channels", [])),
                        "date": os.path.basename(path).rsplit("_", 1)[-1].replace(".json", ""),
                        "time": mdt.strftime("%H:%M"),
                        "mtime_dt": mdt,
                    }
                )
            except Exception:
                continue
        return result

    def _prune(self, safe_address: str):
        pattern = os.path.join(self._backups_dir(), f"auto_{safe_address}_*.json")
        files = sorted(glob.glob(pattern))
        while len(files) > _MAX_AUTO_BACKUPS:
            oldest = files.pop(0)
            try:
                os.remove(oldest)
                log.info("Pruned old auto-backup: %s", oldest)
            except OSError as e:
                log.warning("Failed to prune %s: %s", oldest, e)
