import asyncio
import json
import os
import uuid
import hashlib
import pathlib
import struct
import time
import sqlite3
import websockets
from urllib.parse import urlparse
from nacl.signing import VerifyKey
from nacl.public import PrivateKey
from nacl import bindings
from nacl.exceptions import BadSignatureError
from crypto_utils import (
    b64e, b64d, now_ts, canonical_json, sha256_hex, hkdf, kdf_chain,
    aead_encrypt, aead_decrypt, Identity, Bundle, Session
)
from database import DB
from config import APP_DIR

class NetClient:
    def __init__(self, ident: Identity, db: DB, server_url: str, ui, proxy_config: dict = None, file_config: dict = None, cloud_config: dict = None):
        self.ident = ident
        self.db = db
        self.server_url = server_url
        self.ui = ui
        self.proxy_config = proxy_config or {}
        self.file_config = file_config or {}
        self.cloud_config = cloud_config or {}

        self.seeding_enabled = True
        try:
            self.seeding_enabled = bool(self.file_config.get("seeding_enabled", True))
        except Exception:
            self.seeding_enabled = True

        legacy_upload = 0.0
        try:
            legacy_upload = float(self.file_config.get("upload_limit_mbit", 0) or 0)
        except Exception:
            legacy_upload = 0.0

        try:
            self.seed_upload_limit_mbit = float(self.file_config.get("seed_upload_limit_mbit", legacy_upload) or 0)
        except Exception:
            self.seed_upload_limit_mbit = float(legacy_upload)
        try:
            self.cloud_upload_limit_mbit = float(self.file_config.get("cloud_upload_limit_mbit", legacy_upload) or 0)
        except Exception:
            self.cloud_upload_limit_mbit = float(legacy_upload)

        self._seed_upload_next_ts = 0.0
        self._cloud_upload_next_ts = 0.0

        self.ws = None
        self.online_aliases = set()
        self.all_users = []
        self.blocked_users = set()
        self.my_profile = {}

        self.peers = {}
        self.sessions = {}
        self.pending_eph_sk = {}

        self._file_find_waiters: dict[str, asyncio.Future] = {}
        self._file_chunk_waiters: dict[str, dict[int, asyncio.Future]] = {}
        self._file_seeds_total: dict[str, int] = {}
        seed_dir = self.file_config.get("seed_dir")
        downloads_dir = self.file_config.get("downloads_dir")
        self.files_dir = pathlib.Path(seed_dir) if seed_dir else (APP_DIR / "files")
        self.downloads_dir = pathlib.Path(downloads_dir) if downloads_dir else (APP_DIR / "downloads")
        try:
            self.downloads_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass

        max_seed_mb = 0
        try:
            max_seed_mb = int(self.file_config.get("max_seed_mb", 0) or 0)
        except Exception:
            max_seed_mb = 0
        self.max_seed_bytes = max(0, max_seed_mb) * 1024 * 1024
        try:
            self.files_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass

        self._orig_socket_cls = None
        self._socks_forced = False

        self._authed = asyncio.Event()
        self._preauth_out = []

        self._cloud_waiters: dict[str, asyncio.Future] = {}
        self._cloud_chunk_inflight: dict[str, dict] = {}
        self._cloud_waiter_progress: dict[str, object] = {}
        self._cloud_put_waiters: dict[str, asyncio.Future] = {}
        self._cloud_published_sha: set[str] = set()
        self._cloud_pending_sha: set[str] = set()

        self._cloud_ops_lock = asyncio.Lock()

        self._cloud_publish_debounce_task: asyncio.Task | None = None
        self._cloud_publish_requested_monotonic: float = 0.0
        self._cloud_last_cloud_put_monotonic: float = 0.0

        self._voice_key_cache: dict[tuple[str, str], bytes] = {}
        self._voice_seq: dict[tuple[str, str], int] = {}

        for p in self.db.list_peers():
            self.peers[p["alias"]] = Bundle(
                alias=p["alias"],
                fp=p["fp"],
                sign_pub=b64d(p["sign_pub_b64"]),
                dh_pub=b64d(p["dh_pub_b64"]),
                pinned_fp=p["pinned_fp"],
            )
        for chat in self.db.list_chats():
            sess = self.db.load_session(chat["peer_alias"])
            if sess:
                self.sessions[sess["peer_alias"]] = Session(
                    peer_alias=sess["peer_alias"],
                    peer_fp=sess["peer_fp"],
                    send_ck=sess["send_ck"],
                    recv_ck=sess["recv_ck"],
                    send_n=sess["send_n"],
                    recv_n=sess["recv_n"],
                )

    def _force_global_socks(self, proxy_host: str, proxy_port: int) -> None:
        if self._socks_forced:
            return
        import socket
        import socks

        if self._orig_socket_cls is None:
            self._orig_socket_cls = socket.socket

        socks.set_default_proxy(socks.SOCKS5, proxy_host, proxy_port, rdns=True)
        socket.socket = socks.socksocket
        self._socks_forced = True

    def _restore_global_socks(self) -> None:
        if not self._socks_forced:
            return
        try:
            import socket
            if self._orig_socket_cls is not None:
                socket.socket = self._orig_socket_cls
        finally:
            self._socks_forced = False

    async def send(self, obj: dict):
        if not self.ws:
            print(f"DEBUG: Cannot send {obj.get('type')}, WS is None", flush=True)
            return
        t = obj.get("type")
        if not self._authed.is_set() and t not in ("register", "auth"):
            self._preauth_out.append(obj)
            return

        if t in ("file_chunk",):
            await self._throttle_upload(obj, kind="seed")
        elif t in (
            "cloud_put",
            "cloud_put_begin",
            "cloud_put_chunk",
            "cloud_put_end",
            "cloud_del",
        ):
            await self._throttle_upload(obj, kind="cloud")

        await self.ws.send(canonical_json(obj))

    def _cloud_ws_max_bytes(self) -> int:

        try:
            mb = int(self.cloud_config.get("server_ws_max_size_mb") or 16)
        except Exception:
            mb = 16
        mb = max(1, min(mb, 256))
        return int(mb) * 1024 * 1024

    def _cloud_ws_safe_raw_bytes(self) -> int:

        max_ws = self._cloud_ws_max_bytes()

        safe = min(int(max_ws * 0.70), 8 * 1024 * 1024)
        return max(1, safe)

    async def _throttle_upload(self, obj: dict, *, kind: str) -> None:
        if kind == "cloud":
            limit_mbit = float(getattr(self, "cloud_upload_limit_mbit", 0.0) or 0.0)
            next_ts_attr = "_cloud_upload_next_ts"
        else:
            limit_mbit = float(getattr(self, "seed_upload_limit_mbit", 0.0) or 0.0)
            next_ts_attr = "_seed_upload_next_ts"

        if limit_mbit <= 0:
            return

        try:
            payload_bytes = len(canonical_json(obj).encode("utf-8"))
        except Exception:
            payload_bytes = 0
        if payload_bytes <= 0:
            return

        bps = float(limit_mbit) * 1_000_000.0
        if bps <= 1:
            return

        now = time.monotonic()
        need_sec = (payload_bytes * 8.0) / bps
        if need_sec <= 0:
            return

        next_ts = float(getattr(self, next_ts_attr, 0.0) or 0.0)
        if next_ts < now:
            next_ts = now
        delay = next_ts - now
        next_ts += need_sec
        setattr(self, next_ts_attr, next_ts)
        if delay > 0:
            await asyncio.sleep(delay)

    async def send_alive(self):
        await self.send({"type": "alive"})

    async def refresh_users(self):
        await self.send({"type": "list"})

    async def _register_and_auth(self):
        await self.send(
            {
                "type": "register",
                "alias": self.ident.alias,
                "fp": self.ident.fp,
                "sign_pub": b64e(self.ident.sign_pub),
                "dh_pub": b64e(self.ident.dh_pub),
            }
        )

        while True:
            msg = json.loads(await self.ws.recv())
            if msg.get("type") == "challenge":
                chall = b64d(msg["challenge"])
                break
            if msg.get("type") == "error" and msg.get("error") == "NOT_AUTHED":
                continue
            raise RuntimeError(f"Register failed: {msg}")

        sig = self.ident.sign_sk.sign(chall).signature
        await self.send({"type": "auth", "sig": b64e(sig)})

        ok = json.loads(await self.ws.recv())
        if ok.get("type") != "ok":
            raise RuntimeError(f"Auth failed: {ok}")

        acc = ok.get("account")
        if isinstance(acc, dict):
            try:
                self.ui.set_my_account(acc)
            except Exception:
                pass

        prof = ok.get("profile")
        if isinstance(prof, dict):
            self.my_profile = prof

        bl = ok.get("blocked")
        if isinstance(bl, list):
            try:
                self.blocked_users = set(str(x) for x in bl if isinstance(x, str) and x)
            except Exception:
                self.blocked_users = set()

        self._authed.set()

        queued = list(self._preauth_out)
        self._preauth_out.clear()
        try:
            pace = float(self.proxy_config.get("server_msg_pace_sec") or 0.07)
        except Exception:
            pace = 0.07
        pace = max(0.0, min(pace, 0.5))
        for item in queued:
            await self.send(item)
            if pace > 0:
                await asyncio.sleep(pace)

        self.ui.system_log(f"Authenticated as {self.ident.alias}  fp={self.ident.fp}")
        await self.send({"type": "list"})

        if self.seeding_enabled:
            try:
                await self._announce_all_files()
            except Exception:
                pass

    def _cloud_enabled(self) -> bool:

        return self._user_bool_setting("cloud_user_enabled", default=False)

    def _user_bool_setting(self, key: str, *, default: bool) -> bool:
        try:
            raw = self.db.get_setting(key)
        except Exception:
            raw = None
        if raw is None:
            return bool(default)
        s = str(raw).strip().lower()
        if s in ("1", "true", "yes", "on"):
            return True
        if s in ("0", "false", "no", "off", ""):
            return False
        return bool(default)

    def _user_json_setting(self, key: str, *, default):
        try:
            raw = self.db.get_setting(key)
        except Exception:
            raw = None
        if raw is None:
            return default
        try:
            v = json.loads(str(raw))
            return v
        except Exception:
            return default

    def _cloud_publish_snapshots_enabled(self) -> bool:

        default = False
        try:
            default = bool(self.cloud_config.get("publish_snapshots"))
        except Exception:
            default = False
        return self._user_bool_setting("cloud_user_publish_snapshots", default=default)

    def _cloud_publish_files_enabled(self) -> bool:
        default = False
        try:
            default = bool(self.cloud_config.get("publish_files"))
        except Exception:
            default = False
        return self._user_bool_setting("cloud_user_publish_files", default=default)

    def _cloud_chat_filter_mode(self) -> str:

        try:
            v = (self.db.get_setting("cloud_chat_mode") or "all").strip().lower()
            return v if v in ("all", "selected") else "all"
        except Exception:
            return "all"

    def _cloud_selected_chats(self) -> list[str]:
        v = self._user_json_setting("cloud_selected_chats_json", default=[])
        if not isinstance(v, list):
            return []
        out: list[str] = []
        for x in v:
            if isinstance(x, str) and x:
                out.append(x)
        return out

    def _cloud_snapshot_key(self) -> bytes:

        ikm = bytes(self.ident.dh_sk)
        return hkdf(b"antch-cloud", ikm, ("snapshot|" + self.ident.alias).encode(), 32)

    def request_cloud_publish(self) -> None:
        try:
            if not self._cloud_enabled() or not self._cloud_publish_snapshots_enabled() or not self._authed.is_set():
                return
        except Exception:
            return

        self._cloud_publish_requested_monotonic = time.monotonic()
        if self._cloud_publish_debounce_task is not None and not self._cloud_publish_debounce_task.done():
            return

        try:
            self._cloud_publish_debounce_task = asyncio.create_task(self._cloud_publish_debounced())
        except Exception:
            self._cloud_publish_debounce_task = None

    async def _cloud_publish_debounced(self) -> None:
        debounce_sec = 2.0
        min_interval_sec = 10.0
        try:
            try:
                debounce_sec = float(self.cloud_config.get("publish_debounce_sec") or 2.0)
            except Exception:
                debounce_sec = 2.0
            debounce_sec = max(0.25, min(debounce_sec, 30.0))

            try:
                min_interval_sec = float(self.cloud_config.get("publish_min_interval_sec") or 10.0)
            except Exception:
                min_interval_sec = 10.0
            min_interval_sec = max(0.0, min(min_interval_sec, 300.0))

            while True:
                await asyncio.sleep(debounce_sec)
                if (time.monotonic() - float(self._cloud_publish_requested_monotonic)) >= (debounce_sec - 1e-3):
                    break

            if min_interval_sec > 0:
                now = time.monotonic()
                wait = (self._cloud_last_cloud_put_monotonic + min_interval_sec) - now
                if wait > 0:
                    await asyncio.sleep(wait)

            if not self._cloud_enabled() or not self._cloud_publish_snapshots_enabled() or not self._authed.is_set():
                return

            await self.publish_db_snapshot_if_needed(force=False)
        except asyncio.CancelledError:
            return
        except Exception:
            return
        finally:

            self._cloud_publish_debounce_task = None

    async def publish_db_snapshot_if_needed(self, *, force: bool = False, wait_for_ack: bool = False, timeout: float = 30.0) -> tuple[bool, str]:
        async with self._cloud_ops_lock:
            return await self._publish_db_snapshot_if_needed_locked(force=force, wait_for_ack=wait_for_ack, timeout=timeout)

    async def _publish_db_snapshot_if_needed_locked(self, *, force: bool = False, wait_for_ack: bool = False, timeout: float = 30.0) -> tuple[bool, str]:

        db_path = getattr(self.db, "path", None)
        if not db_path:
            return (False, "no_db_path")

        loop = asyncio.get_running_loop()
        snapshot_error = [None]

        def _make_snapshot_bytes() -> bytes | None:
            tmp_dir = APP_DIR / "tmp"
            tmp = tmp_dir / f"tmp_snapshot_{uuid.uuid4().hex}.db"
            try:
                tmp_dir.mkdir(parents=True, exist_ok=True)
            except Exception as e:
                snapshot_error[0] = f"mkdir_failed:{e}"
                return None

            try:
                self.db.export_backup(tmp)
            except Exception as e:
                snapshot_error[0] = f"export_backup_failed:{e}"
                try:
                    tmp.unlink(missing_ok=True)
                except Exception:
                    pass
                return None

            try:
                if self._cloud_chat_filter_mode() == "selected":
                    allowed = set(self._cloud_selected_chats())
                    if allowed:
                        c = sqlite3.connect(str(tmp))
                        try:
                            cur = c.cursor()

                            allowed_list = list(allowed)
                            q = ",".join(["?"] * len(allowed_list))

                            cur.execute(f"DELETE FROM messages WHERE peer_alias NOT IN ({q})", allowed_list)
                            cur.execute(f"DELETE FROM chats WHERE peer_alias NOT IN ({q})", allowed_list)
                            cur.execute(f"DELETE FROM outbox WHERE peer_alias NOT IN ({q})", allowed_list)
                            cur.execute(f"DELETE FROM requests_in WHERE peer_alias NOT IN ({q})", allowed_list)
                            cur.execute(f"DELETE FROM requests_out WHERE peer_alias NOT IN ({q})", allowed_list)
                            cur.execute(f"DELETE FROM file_offers WHERE peer_alias NOT IN ({q})", allowed_list)

                            allowed_peers = [x for x in allowed_list if not str(x).startswith("g:")]
                            if allowed_peers:
                                qp = ",".join(["?"] * len(allowed_peers))
                                cur.execute(f"DELETE FROM sessions WHERE peer_alias NOT IN ({qp})", allowed_peers)
                            else:
                                cur.execute("DELETE FROM sessions")

                            allowed_gids = [str(x)[2:] for x in allowed_list if isinstance(x, str) and x.startswith("g:")]
                            if allowed_gids:
                                qg = ",".join(["?"] * len(allowed_gids))
                                cur.execute(f"DELETE FROM groups WHERE group_id NOT IN ({qg})", allowed_gids)
                                cur.execute(f"DELETE FROM group_members WHERE group_id NOT IN ({qg})", allowed_gids)
                            else:
                                cur.execute("DELETE FROM groups")
                                cur.execute("DELETE FROM group_members")

                            keep_peer_aliases = set(allowed_peers)
                            if allowed_gids:
                                try:
                                    cur.execute("SELECT DISTINCT member_alias FROM group_members")
                                    keep_peer_aliases |= set(str(r[0]) for r in cur.fetchall() if r and r[0])
                                except Exception:
                                    pass
                            if keep_peer_aliases:
                                kpl = list(keep_peer_aliases)
                                qk = ",".join(["?"] * len(kpl))
                                cur.execute(f"DELETE FROM peers WHERE alias NOT IN ({qk})", kpl)
                            else:
                                cur.execute("DELETE FROM peers")

                            c.commit()
                        finally:
                            c.close()

                data = tmp.read_bytes()
                return data
            except Exception as e:
                snapshot_error[0] = f"filter_or_read_failed:{e}"
                return None
            finally:
                try:
                    tmp.unlink(missing_ok=True)
                except Exception:
                    pass

                try:
                    tmp.with_suffix(tmp.suffix + ".enc").unlink(missing_ok=True)
                except Exception:
                    pass

        plain = await loop.run_in_executor(None, _make_snapshot_bytes)
        if not plain:
            err_detail = snapshot_error[0] or "unknown"
            return (False, f"snapshot_create_failed:{err_detail}")

        created_at = now_ts()
        aad = (f"cloud-snapshot|{self.ident.alias}|{created_at}").encode()
        key = self._cloud_snapshot_key()
        nonce, ct = aead_encrypt(key, plain, aad)
        envelope = nonce + ct
        total_bytes = len(envelope)

        try:
            ws_safe = int(self._cloud_ws_safe_raw_bytes())
        except Exception:
            ws_safe = 8 * 1024 * 1024

        blob_sha = sha256_hex(envelope)
        if (not force) and (blob_sha in self._cloud_published_sha or blob_sha in self._cloud_pending_sha):
            return (True, "already_published")

        fut = None
        if wait_for_ack:
            try:
                fut = asyncio.get_running_loop().create_future()
                self._cloud_put_waiters[blob_sha] = fut
            except Exception:
                fut = None

        meta = {
            "owner": self.ident.alias,
            "kind": "db",
            "created_at": int(created_at),
            "blob_sha256": blob_sha,
            "bytes_total": int(total_bytes),
        }
        sig = self.ident.sign_sk.sign(("cloud_put|" + canonical_json(meta)).encode()).signature

        self._cloud_pending_sha.add(blob_sha)

        if total_bytes <= ws_safe:
            await self.send(
                {
                    "type": "cloud_put",
                    "owner": self.ident.alias,
                    "kind": "db",
                    "created_at": int(created_at),
                    "nonce": "",
                    "ct": b64e(envelope),
                    "sig": b64e(sig),
                }
            )
        else:

            upload_id = uuid.uuid4().hex
            await self.send(
                {
                    "type": "cloud_put_begin",
                    "owner": self.ident.alias,
                    "kind": "db",
                    "created_at": int(created_at),
                    "blob_sha256": blob_sha,
                    "bytes_total": int(total_bytes),
                    "nonce": "",
                    "sig": b64e(sig),
                    "upload_id": upload_id,
                }
            )

            chunk_bytes = max(256 * 1024, min(int(ws_safe * 0.9), 8 * 1024 * 1024))

            try:
                target_mps = float(self.cloud_config.get("cloud_chunk_msgs_per_sec") or 18)
            except Exception:
                target_mps = 18.0
            target_mps = max(1.0, min(target_mps, 19.0))
            next_send_ts = time.monotonic()

            done = 0
            idx = 0
            while done < total_bytes:
                chunk = envelope[done:done + chunk_bytes]
                if not chunk:
                    break

                try:
                    to_thread = getattr(asyncio, "to_thread", None)
                    if callable(to_thread) and len(chunk) >= 1024 * 1024:
                        data_b64 = await to_thread(b64e, chunk)
                    else:
                        data_b64 = b64e(chunk)
                except Exception:
                    data_b64 = b64e(chunk)

                await self.send(
                    {
                        "type": "cloud_put_chunk",
                        "upload_id": upload_id,
                        "idx": int(idx),
                        "data": data_b64,
                    }
                )

                idx += 1
                done += len(chunk)

                now_m = time.monotonic()
                if next_send_ts < now_m:
                    next_send_ts = now_m
                next_send_ts += 1.0 / float(target_mps)
                delay = next_send_ts - now_m
                if delay > 0:
                    await asyncio.sleep(delay)

            await self.send({"type": "cloud_put_end", "upload_id": upload_id})

        try:
            self._cloud_last_cloud_put_monotonic = time.monotonic()
        except Exception:
            pass

        if fut is not None:
            try:
                result = await asyncio.wait_for(fut, timeout=float(timeout or 60.0))
                ok = result.get("ok", False) if isinstance(result, dict) else False
                if ok:
                    return (True, "ok")
                else:
                    err = result.get("error", "server_rejected") if isinstance(result, dict) else "unknown"
                    return (False, f"server_error:{err}")
            except asyncio.TimeoutError:
                try:
                    self._cloud_put_waiters.pop(blob_sha, None)
                except Exception:
                    pass
                return (False, "timeout")
            except Exception as e:
                return (False, f"exception:{e}")

        return (True, "sent_no_wait")

    async def publish_file_container_if_needed(
        self,
        *,
        file_id: str,
        force: bool = False,
        progress_cb=None,
        wait_for_ack: bool = False,
        timeout: float = 30.0,
    ) -> tuple[bool, str]:
        if not isinstance(file_id, str) or not file_id:
            return (False, "invalid_file_id")
        if not self._cloud_enabled():
            return (False, "cloud_disabled")
        if not self._cloud_publish_files_enabled():
            return (False, "file_publish_disabled")
        if not self._authed.is_set():
            return (False, "not_authenticated")

        async with self._cloud_ops_lock:
            path = self._file_container_path(file_id)
            if not path.exists():
                return (False, f"file_not_found:{path}")

            try:
                st = path.stat()
                total_bytes = int(st.st_size)
            except Exception as e:
                return (False, f"stat_failed:{e}")

            if total_bytes <= 0:
                return (False, "file_empty")

            try:
                ws_safe = int(self._cloud_ws_safe_raw_bytes())
            except Exception:
                ws_safe = 1

            created_at = now_ts()

            blob_sha = None
            try:
                h = hashlib.sha256()
                with path.open("rb") as fp:
                    while True:
                        b = fp.read(4 * 1024 * 1024)
                        if not b:
                            break
                        h.update(b)
                blob_sha = h.hexdigest()
            except Exception as e:
                return (False, f"hash_failed:{e}")
            if not isinstance(blob_sha, str) or not blob_sha:
                return (False, "hash_empty")
            if (not force) and (blob_sha in self._cloud_published_sha or blob_sha in self._cloud_pending_sha):
                return (True, "already_published")

            meta = {
                "owner": self.ident.alias,
                "kind": "file",
                "created_at": int(created_at),
                "blob_sha256": blob_sha,
                "bytes_total": int(total_bytes),
                "file_id": str(file_id),
            }
            sig = self.ident.sign_sk.sign(("cloud_put|" + canonical_json(meta)).encode()).signature

            fut = None
            if wait_for_ack:
                try:
                    fut = asyncio.get_running_loop().create_future()
                    self._cloud_put_waiters[blob_sha] = fut
                except Exception:
                    fut = None

            self._cloud_pending_sha.add(blob_sha)

            try:
                if callable(progress_cb):
                    progress_cb({
                        "phase": "upload",
                        "name": str(file_id),
                        "bytes_done": 0,
                        "bytes_total": int(total_bytes),
                    })
            except Exception:
                pass

            if int(total_bytes) <= int(ws_safe):
                try:
                    data = path.read_bytes()
                except Exception as e:
                    return (False, f"read_failed:{e}")
                await self.send(
                    {
                        "type": "cloud_put",
                        "owner": self.ident.alias,
                        "kind": "file",
                        "created_at": int(created_at),
                        "nonce": "",
                        "ct": b64e(data),
                        "sig": b64e(sig),
                        "file_id": str(file_id),
                    }
                )
            else:

                upload_id = uuid.uuid4().hex
                await self.send(
                    {
                        "type": "cloud_put_begin",
                        "owner": self.ident.alias,
                        "kind": "file",
                        "created_at": int(created_at),
                        "blob_sha256": blob_sha,
                        "bytes_total": int(total_bytes),
                        "nonce": "",
                        "sig": b64e(sig),
                        "file_id": str(file_id),
                        "upload_id": upload_id,
                    }
                )

                try:

                    default_chunk = int(min(int(ws_safe), 8 * 1024 * 1024))
                    chunk_bytes = int(self.cloud_config.get("cloud_chunk_bytes") or default_chunk)
                except Exception:
                    chunk_bytes = int(min(int(ws_safe), 8 * 1024 * 1024))
                chunk_bytes = max(256 * 1024, min(int(chunk_bytes), max(256 * 1024, int(ws_safe))))

                try:

                    target_mps = float(self.cloud_config.get("cloud_chunk_msgs_per_sec") or 18)
                except Exception:
                    target_mps = 18.0
                target_mps = max(1.0, min(target_mps, 19.0))

                next_send_ts = time.monotonic()

                done = 0
                idx = 0
                try:
                    fp = path.open("rb")
                except Exception as e:
                    return (False, f"open_failed:{e}")
                if fp is None:
                    return (False, "open_returned_none")
                try:
                    while True:
                        chunk = fp.read(int(chunk_bytes))
                        if not chunk:
                            break

                        try:
                            to_thread = getattr(asyncio, "to_thread", None)
                            if callable(to_thread) and len(chunk) >= 1024 * 1024:
                                data_b64 = await to_thread(b64e, chunk)
                            else:
                                data_b64 = b64e(chunk)
                        except Exception:
                            data_b64 = b64e(chunk)

                        await self.send(
                            {
                                "type": "cloud_put_chunk",
                                "upload_id": upload_id,
                                "idx": int(idx),
                                "data": data_b64,
                            }
                        )

                        idx += 1
                        done += len(chunk)
                        try:
                            if callable(progress_cb):
                                progress_cb({
                                    "phase": "upload",
                                    "name": str(file_id),
                                    "bytes_done": int(done),
                                    "bytes_total": int(total_bytes),
                                })
                        except Exception:
                            pass

                        now_m = time.monotonic()
                        if next_send_ts < now_m:
                            next_send_ts = now_m
                        next_send_ts += 1.0 / float(target_mps)
                        delay = next_send_ts - now_m
                        if delay > 0:
                            await asyncio.sleep(delay)
                finally:
                    try:
                        fp.close()
                    except Exception:
                        pass

                await self.send({"type": "cloud_put_end", "upload_id": upload_id})

            try:
                self._cloud_last_cloud_put_monotonic = time.monotonic()
            except Exception:
                pass

            try:
                self.db.set_cloud_file_blob(file_id, blob_sha, int(created_at))
            except Exception:
                pass

            if fut is not None:
                try:
                    result = await asyncio.wait_for(fut, timeout=float(timeout or 60.0))
                    ok = result.get("ok", False) if isinstance(result, dict) else False
                    if ok:
                        return (True, "ok")
                    else:
                        err = result.get("error", "server_rejected") if isinstance(result, dict) else "unknown"
                        return (False, f"server_error:{err}")
                except asyncio.TimeoutError:
                    try:
                        self._cloud_put_waiters.pop(blob_sha, None)
                    except Exception:
                        pass
                    return (False, "timeout")
                except asyncio.CancelledError:
                    raise
                except Exception as e:
                    return (False, f"exception:{e}")

            return (True, "sent_no_wait")

    async def cloud_delete_file_if_known(self, *, file_id: str) -> None:
        if not isinstance(file_id, str) or not file_id:
            return
        if not self._cloud_enabled() or not self._authed.is_set():
            return

        blob_sha = None
        try:
            m = self.db.get_cloud_file_blob(file_id)
            if isinstance(m, dict):
                blob_sha = m.get("blob_sha256")
        except Exception:
            blob_sha = None

        if not (isinstance(blob_sha, str) and blob_sha):
            try:
                p = self._file_container_path(file_id)
                if p.exists():
                    blob_sha = sha256_hex(p.read_bytes())
            except Exception:
                blob_sha = None

        if not (isinstance(blob_sha, str) and blob_sha):
            return

        await self.cloud_delete_blob(kind="file", blob_sha256=blob_sha)
        try:
            self.db.delete_cloud_file_blob(file_id)
        except Exception:
            pass

    async def cloud_delete_blob(self, *, kind: str, blob_sha256: str) -> None:
        if not self._cloud_enabled() or not self._authed.is_set():
            return
        if kind not in ("db", "file"):
            return
        if not isinstance(blob_sha256, str) or not blob_sha256:
            return

        async with self._cloud_ops_lock:
            meta = {
                "owner": self.ident.alias,
                "kind": kind,
                "blob_sha256": blob_sha256,
            }
            sig = self.ident.sign_sk.sign(("cloud_del|" + canonical_json(meta)).encode()).signature
            await self.send(
                {
                    "type": "cloud_del",
                    "owner": self.ident.alias,
                    "kind": kind,
                    "blob_sha256": blob_sha256,
                    "sig": b64e(sig),
                }
            )

    async def fetch_latest_db_snapshot(self, timeout: float = 20.0) -> bytes | None:
        if not self._cloud_enabled():
            return None
        req_id = uuid.uuid4().hex
        fut = asyncio.get_running_loop().create_future()
        self._cloud_waiters[req_id] = fut
        await self.send({"type": "cloud_get", "owner": self.ident.alias, "kind": "db", "req_id": req_id})
        try:
            res = await asyncio.wait_for(fut, timeout=timeout)
        except asyncio.TimeoutError:
            raise RuntimeError("TIMEOUT")
        finally:
            self._cloud_waiters.pop(req_id, None)

        if not isinstance(res, dict):
            raise RuntimeError("BAD_RESPONSE")
        if not res.get("ok"):
            err = res.get("error")
            if isinstance(err, str) and err:

                try:
                    sent = res.get("nodes_sent")
                    resp = res.get("nodes_responded")
                    if isinstance(sent, int) or isinstance(resp, int):
                        raise RuntimeError(f"{err} (nodes {int(resp or 0)}/{int(sent or 0)})")
                except Exception:
                    pass
                raise RuntimeError(err)
            raise RuntimeError("FETCH_FAILED")
        ct_b64 = res.get("ct")
        envelope = None
        if isinstance(res.get("blob_bytes"), (bytes, bytearray)):
            envelope = bytes(res.get("blob_bytes"))
        else:
            ct_b64 = res.get("ct")
            if not isinstance(ct_b64, str):
                raise RuntimeError("BAD_RESPONSE")
            try:
                envelope = b64d(ct_b64)
            except Exception:
                raise RuntimeError("BAD_BLOB")

        if len(envelope) < 24:
            return None
        nonce = envelope[:24]
        ciphertext = envelope[24:]
        created_at = int(res.get("created_at") or 0)
        aad = (f"cloud-snapshot|{self.ident.alias}|{created_at}").encode()
        try:
            out = aead_decrypt(self._cloud_snapshot_key(), nonce, ciphertext, aad)
            try:
                self.db.set_setting("cloud_last_fetch_ts", str(now_ts()))
                sha = res.get("blob_sha256")
                if isinstance(sha, str) and sha:
                    self.db.set_setting("cloud_last_fetch_sha", sha)
            except Exception:
                pass
            return out
        except Exception:
            raise RuntimeError("DECRYPT_FAILED")

    async def _announce_all_files(self):

        try:
            pace = float(self.proxy_config.get("server_msg_pace_sec") or 0.07)
        except Exception:
            pace = 0.07
        pace = max(0.0, min(pace, 0.5))

        for f in self.db.list_files(limit=500):
            await self.send({"type": "file_announce", "file_id": f["file_id"]})
            if pace > 0:
                await asyncio.sleep(pace)

        try:
            for p in self.files_dir.glob("*.e2ef"):
                fid = p.stem
                if isinstance(fid, str) and len(fid) >= 16:
                    await self.send({"type": "file_announce", "file_id": fid})
                    if pace > 0:
                        await asyncio.sleep(pace)
        except Exception:
            pass

    def _file_container_path(self, file_id: str) -> pathlib.Path:
        return self.files_dir / f"{file_id}.e2ef"

    def _sha256_hex_file(self, path: pathlib.Path, *, chunk_bytes: int = 1024 * 1024) -> str | None:
        try:
            h = hashlib.sha256()
            with path.open("rb") as fp:
                while True:
                    b = fp.read(int(chunk_bytes))
                    if not b:
                        break
                    h.update(b)
            return h.hexdigest()
        except Exception:
            return None

    def _safe_filename(self, name: str) -> str:
        name = (name or "file").strip()
        name = name.replace("/", "_").replace("\\", "_").replace("\x00", "_")
        if name in (".", "..", ""):
            name = "file"
        return name[:255]

    def _guess_mime(self, name: str) -> str:
        try:
            import mimetypes

            mt, _ = mimetypes.guess_type(name)
            return mt or "application/octet-stream"
        except Exception:
            return "application/octet-stream"

    def _make_image_preview_b64(self, path: str | os.PathLike[str], *, max_px: int = 900, max_bytes: int = 250_000) -> str | None:
        try:
            from PIL import Image as PILImage
            from PIL import ImageOps
            import io

            def downscale_to_longest(im: "PILImage.Image", longest_px: int) -> "PILImage.Image":
                w, h = im.size
                longest = max(w, h) if w and h else 0
                target = int(max(64, longest_px))
                if not longest or longest <= target:
                    return im
                scale = target / float(longest)
                nw = max(1, int(w * scale))
                nh = max(1, int(h * scale))
                return im.resize((nw, nh), PILImage.Resampling.LANCZOS)

            src = pathlib.Path(path)
            with PILImage.open(src) as im:

                im = ImageOps.exif_transpose(im)

                has_alpha = (
                    im.mode in ("RGBA", "LA")
                    or (im.mode == "P" and "transparency" in (im.info or {}))
                )
                im = im.convert("RGBA" if has_alpha else "RGB")

                cur_px = int(max(64, max_px))
                im = downscale_to_longest(im, cur_px)

                data = b""
                max_bytes_i = int(max(1, max_bytes))

                for _ in range(8):
                    buf = io.BytesIO()
                    im.save(buf, format="PNG", optimize=True, compress_level=9)
                    data = buf.getvalue()
                    if len(data) <= max_bytes_i:
                        return b64e(data)

                    cur_px = max(64, int(cur_px * 0.85))
                    im = downscale_to_longest(im, cur_px)

                if data and len(data) <= max_bytes_i * 2:
                    return b64e(data)
                return None
        except Exception:
            return None

    def _seed_dir_usage_bytes(self) -> int:
        total = 0
        try:
            for p in self.files_dir.glob("*.e2ef"):
                try:
                    total += int(p.stat().st_size)
                except Exception:
                    pass
        except Exception:
            pass
        return total

    def _read_record_by_index(self, path: pathlib.Path, idx: int) -> bytes:

        with path.open("rb") as fp:
            cur_idx = 0
            while True:
                hdr = fp.read(4)
                if len(hdr) != 4:
                    raise EOFError("record out of range")
                (ln,) = struct.unpack(">I", hdr)
                if ln <= 0 or ln > 64 * 1024 * 1024:
                    raise ValueError("invalid record length")
                data = fp.read(ln)
                if len(data) != ln:
                    raise EOFError("truncated record")
                if cur_idx == idx:
                    return data
                cur_idx += 1

    async def announce_file(self, file_id: str) -> None:
        await self.send({"type": "file_announce", "file_id": file_id})

    async def find_seeds(self, file_id: str, timeout: float = 10.0) -> list[str]:
        fut = asyncio.get_running_loop().create_future()
        self._file_find_waiters[file_id] = fut
        await self.send({"type": "file_find", "file_id": file_id})
        try:
            res = await asyncio.wait_for(fut, timeout=timeout)
            return list(res)
        finally:
            self._file_find_waiters.pop(file_id, None)

    async def request_record(self, file_id: str, seed: str, idx: int, timeout: float = 15.0) -> bytes:

        transfer_id = uuid.uuid4().hex
        fut = asyncio.get_running_loop().create_future()
        self._file_chunk_waiters.setdefault(transfer_id, {})[idx] = fut
        await self.send({"type": "file_get", "file_id": file_id, "seed": seed, "transfer_id": transfer_id, "idx": int(idx)})
        try:
            return await asyncio.wait_for(fut, timeout=timeout)
        finally:
            waiter_map = self._file_chunk_waiters.get(transfer_id)
            if waiter_map:
                waiter_map.pop(idx, None)
                if not waiter_map:
                    self._file_chunk_waiters.pop(transfer_id, None)

    async def seed_file_from_path(
        self,
        path: str | os.PathLike[str],
        name: str | None = None,
        plain_chunk: int = 190 * 1024,
        progress_cb=None,
    ) -> dict:
        src = pathlib.Path(path)
        if not src.exists() or not src.is_file():
            raise FileNotFoundError(str(src))

        plain_total = 0
        try:
            plain_total = int(src.stat().st_size)
        except Exception:
            plain_total = 0

        file_key = bindings.crypto_secretstream_xchacha20poly1305_keygen()
        state = bindings.crypto_secretstream_xchacha20poly1305_state()
        header = bindings.crypto_secretstream_xchacha20poly1305_init_push(state, file_key)

        hasher = hashlib.sha256()

        tmp_path = self.files_dir / f"tmp_{uuid.uuid4().hex}.e2ef"
        bytes_total = 0
        record_count = 0
        plain_done = 0

        def write_record(fp, data: bytes):
            nonlocal bytes_total, record_count
            fp.write(struct.pack(">I", len(data)))
            fp.write(data)
            bytes_total += len(data)
            record_count += 1
            hasher.update(data)

        plain_chunk = int(min(max(1, plain_chunk), 190 * 1024))

        try:
            with src.open("rb") as inp, tmp_path.open("wb") as out:
                write_record(out, header)

                try:
                    if callable(progress_cb):
                        progress_cb({"phase": "encrypt", "name": (name or src.name), "bytes_done": 0, "bytes_total": plain_total})
                except Exception:
                    pass

                tick = 0
                while True:
                    chunk = inp.read(int(plain_chunk))
                    if not chunk:
                        break
                    tag = bindings.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE
                    ct = bindings.crypto_secretstream_xchacha20poly1305_push(state, chunk, b"", tag)
                    write_record(out, ct)
                    plain_done += len(chunk)

                    tick += 1
                    if tick % 8 == 0:
                        try:
                            if callable(progress_cb):
                                progress_cb(
                                    {
                                        "phase": "encrypt",
                                        "name": (name or src.name),
                                        "bytes_done": int(plain_done),
                                        "bytes_total": int(plain_total),
                                    }
                                )
                        except Exception:
                            pass
                        await asyncio.sleep(0)

                ct_final = bindings.crypto_secretstream_xchacha20poly1305_push(
                    state,
                    b"",
                    b"",
                    bindings.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
                )
                write_record(out, ct_final)
        except asyncio.CancelledError:
            try:
                tmp_path.unlink(missing_ok=True)
            except Exception:
                pass
            raise

        file_id = hasher.hexdigest()
        final_path = self._file_container_path(file_id)

        if self.max_seed_bytes > 0:
            try:
                projected = self._seed_dir_usage_bytes() + int(tmp_path.stat().st_size)
                if projected > self.max_seed_bytes:
                    try:
                        tmp_path.unlink()
                    except Exception:
                        pass
                    raise RuntimeError("Seed storage limit exceeded (adjust in Launcher → Settings)")
            except Exception as e:

                if "limit" in str(e).lower():
                    raise
        try:
            tmp_path.replace(final_path)
        except Exception:

            data = tmp_path.read_bytes()
            final_path.write_bytes(data)
            try:
                tmp_path.unlink()
            except Exception:
                pass

        file_name = name or src.name
        mime = self._guess_mime(file_name)

        record_hashes: list[str] = []
        self.db.upsert_file(file_id, file_name, bytes_total, record_count, record_hashes, str(final_path))
        await self.announce_file(file_id)

        try:
            if callable(progress_cb):
                progress_cb({"phase": "done", "name": file_name, "bytes_done": int(plain_total or plain_done), "bytes_total": int(plain_total)})
        except Exception:
            pass

        return {
            "file_id": file_id,
            "name": file_name,
            "bytes_total": bytes_total,
            "record_count": record_count,
            "record_hashes": [],
            "file_key": b64e(file_key),
            "mime": mime,
        }

    async def download_ciphertext(
        self,
        file_id: str,
        record_count: int,
        record_hashes: list[str],
        seed: str | None = None,
        cloud_blob_sha256: str | None = None,
        *,
        name: str | None = None,
        bytes_total_hint: int | None = None,
        progress_cb=None,
    ) -> pathlib.Path:

        try:
            info0 = self.db.get_file(file_id)
        except Exception:
            info0 = None
        if info0 and info0.get("local_path"):
            p0 = pathlib.Path(str(info0["local_path"]))
            if p0.exists():
                return p0

            try:
                await self.send({"type": "file_unannounce", "file_id": str(file_id)})
            except Exception:
                pass
            try:
                self.db.delete_file(file_id)
            except Exception:
                pass

        async def _verify_and_store_container(blob: bytes, expected_sha: str | None) -> pathlib.Path:
            if isinstance(expected_sha, str) and expected_sha:
                if hashlib.sha256(blob).hexdigest() != expected_sha:
                    raise RuntimeError("BAD_BLOB_HASH")

            try:
                h = hashlib.sha256()
                off = 0

                while off + 4 <= len(blob):
                    (ln,) = struct.unpack(">I", blob[off : off + 4])
                    off += 4
                    if ln <= 0 or ln > 64 * 1024 * 1024:
                        raise RuntimeError("BAD_CONTAINER")
                    if off + int(ln) > len(blob):
                        raise RuntimeError("BAD_CONTAINER")
                    rec = blob[off : off + int(ln)]
                    off += int(ln)
                    h.update(rec)
                if off != len(blob):
                    raise RuntimeError("BAD_CONTAINER")
                if h.hexdigest() != file_id:
                    raise RuntimeError("FILE_ID_MISMATCH")
            except RuntimeError:
                raise
            except Exception:
                raise RuntimeError("BAD_CONTAINER")

            final_path = self._file_container_path(file_id)
            tmp_path = self.files_dir / f"dl_{file_id}_{uuid.uuid4().hex}.part"
            tmp_path.write_bytes(blob)
            tmp_path.replace(final_path)
            try:
                self.db.upsert_file(file_id, None, int(len(blob)), int(record_count or 0), list(record_hashes or []), str(final_path))
            except Exception:
                pass
            await self.announce_file(file_id)
            return final_path

        def _progress(info: dict) -> None:
            if not callable(progress_cb):
                return
            try:
                payload = dict(info or {})
            except Exception:
                payload = {}
            if name and not payload.get("name"):
                payload["name"] = str(name)
            try:
                progress_cb(payload)
            except Exception:
                pass

        async def _download_from_cloud_blob(blob_sha256: str, timeout: float = 25.0) -> pathlib.Path:

            if not isinstance(blob_sha256, str) or not blob_sha256:
                raise RuntimeError("NO_BLOB_SHA")

            req_id = uuid.uuid4().hex
            fut = asyncio.get_running_loop().create_future()
            self._cloud_waiters[req_id] = fut
            if callable(progress_cb):
                self._cloud_waiter_progress[req_id] = _progress
            await self.send({"type": "cloud_get_by_sha", "kind": "file", "blob_sha256": blob_sha256, "req_id": req_id, "file_id": str(file_id)})
            try:
                res = await asyncio.wait_for(fut, timeout=float(timeout or 25.0))
            except asyncio.TimeoutError:
                raise RuntimeError("TIMEOUT")
            finally:
                self._cloud_waiters.pop(req_id, None)
                self._cloud_waiter_progress.pop(req_id, None)

            if not isinstance(res, dict) or not res.get("ok"):
                err = res.get("error") if isinstance(res, dict) else None
                raise RuntimeError(str(err or "FETCH_FAILED"))
            blob = None
            if isinstance(res.get("blob_bytes"), (bytes, bytearray)):
                blob = bytes(res.get("blob_bytes"))
            else:
                ct_b64 = res.get("ct")
                if not isinstance(ct_b64, str):
                    raise RuntimeError("BAD_BLOB")
                try:
                    blob = b64d(ct_b64)
                except Exception:
                    raise RuntimeError("BAD_BLOB")
            try:
                _progress({"phase": "download", "bytes_done": int(len(blob or b"")), "bytes_total": int(len(blob or b""))})
            except Exception:
                pass
            return await _verify_and_store_container(blob, expected_sha=str(blob_sha256))

        async def _download_from_cloud_file_id(timeout: float = 25.0) -> pathlib.Path:
            req_id = uuid.uuid4().hex
            fut = asyncio.get_running_loop().create_future()
            self._cloud_waiters[req_id] = fut
            if callable(progress_cb):
                self._cloud_waiter_progress[req_id] = _progress
            await self.send({"type": "cloud_get_file", "file_id": str(file_id), "req_id": req_id})
            try:
                res = await asyncio.wait_for(fut, timeout=float(timeout or 25.0))
            except asyncio.TimeoutError:
                raise RuntimeError("TIMEOUT")
            finally:
                self._cloud_waiters.pop(req_id, None)
                self._cloud_waiter_progress.pop(req_id, None)

            if not isinstance(res, dict) or not res.get("ok"):
                err = res.get("error") if isinstance(res, dict) else None
                raise RuntimeError(str(err or "FETCH_FAILED"))

            blob = None
            if isinstance(res.get("blob_bytes"), (bytes, bytearray)):
                blob = bytes(res.get("blob_bytes"))
            else:
                ct_b64 = res.get("ct")
                if isinstance(ct_b64, str) and ct_b64:
                    try:
                        blob = b64d(ct_b64)
                    except Exception:
                        blob = None

            blob_sha = res.get("blob_sha256")
            if isinstance(blob, (bytes, bytearray)) and blob:
                try:
                    _progress({"phase": "download", "bytes_done": int(len(blob)), "bytes_total": int(len(blob))})
                except Exception:
                    pass

                expected_sha = str(blob_sha) if isinstance(blob_sha, str) and blob_sha else None
                return await _verify_and_store_container(bytes(blob), expected_sha=expected_sha)

            if not isinstance(blob_sha, str) or not blob_sha:
                raise RuntimeError("NO_BLOB_SHA")
            return await _download_from_cloud_blob(blob_sha, timeout=float(timeout or 25.0))

        def _is_retryable_seed_exception(e: BaseException) -> bool:

            if isinstance(e, asyncio.TimeoutError):
                return True
            if isinstance(e, RuntimeError):
                msg = str(e)
                if msg in {"SEED_OFFLINE", "SEED_NOT_ANNOUNCED", "NO_SUCH_FILE", "SEEDING_DISABLED"}:
                    return True
                if msg.startswith("READ_ERR:"):
                    return True
            return False

        if isinstance(seed, str) and seed:
            try:
                await self.request_record(file_id, seed, 0, timeout=6.0)
            except Exception:
                seed = None

        seeds: list[str] = []
        if seed is None:
            try:
                seeds = await self.find_seeds(file_id)
            except Exception:
                seeds = []
        if seed is None and not seeds:

            try:
                return await _download_from_cloud_file_id()
            except Exception:

                if isinstance(cloud_blob_sha256, str) and cloud_blob_sha256:
                    return await _download_from_cloud_blob(cloud_blob_sha256)
            raise RuntimeError("No seeds available")

        if seed is None:
            seed = seeds[0]

        tmp_path = self.files_dir / f"dl_{file_id}_{uuid.uuid4().hex}.part"
        hasher = hashlib.sha256()
        bytes_total = 0

        try:
            _progress({"phase": "download", "bytes_done": 0, "bytes_total": int(bytes_total_hint or 0)})
        except Exception:
            pass

        tried_seeds: set[str] = set()
        try:
            with tmp_path.open("wb") as out:
                tick = 0
                for idx in range(int(record_count)):
                    attempts = 0
                    while True:
                        attempts += 1
                        tried_seeds.add(str(seed))
                        try:
                            rec = await self.request_record(file_id, str(seed), idx, timeout=15.0)
                            break
                        except Exception as e:
                            if not _is_retryable_seed_exception(e) or attempts >= 4:
                                raise

                            try:
                                seeds = await self.find_seeds(file_id)
                            except Exception:
                                seeds = []
                            next_seed = None
                            for s in seeds:
                                if s not in tried_seeds:
                                    next_seed = s
                                    break
                            if not next_seed:

                                raise RuntimeError("NO_SEEDS_LEFT")
                            seed = next_seed

                    if idx < len(record_hashes):
                        got = hashlib.sha256(rec).hexdigest()
                        exp = record_hashes[idx]
                        if exp and got != exp:
                            raise RuntimeError(f"Record hash mismatch at idx={idx}")

                    out.write(struct.pack(">I", len(rec)))
                    out.write(rec)
                    hasher.update(rec)
                    bytes_total += len(rec)

                    tick += 1
                    if tick % 4 == 0 or (idx + 1) >= int(record_count):
                        try:
                            _progress({"phase": "download", "bytes_done": int(bytes_total), "bytes_total": int(bytes_total_hint or 0)})
                        except Exception:
                            pass
                        await asyncio.sleep(0)
        except Exception as e:

            try:
                if tmp_path.exists():
                    tmp_path.unlink()
            except Exception:
                pass

            if isinstance(e, RuntimeError) and str(e) == "NO_SEEDS_LEFT":
                try:
                    return await _download_from_cloud_file_id()
                except Exception:
                    if isinstance(cloud_blob_sha256, str) and cloud_blob_sha256:
                        return await _download_from_cloud_blob(cloud_blob_sha256)
            raise

        if hasher.hexdigest() != file_id:
            raise RuntimeError("File id mismatch after download")

        final_path = self._file_container_path(file_id)
        tmp_path.replace(final_path)
        self.db.upsert_file(file_id, None, bytes_total, int(record_count), list(record_hashes), str(final_path))
        await self.announce_file(file_id)
        return final_path

    async def decrypt_to_path(self, *, file_id: str, file_key_b64: str, out_path: str | os.PathLike[str], name: str | None = None, progress_cb=None) -> pathlib.Path:
        info = self.db.get_file(file_id)
        container = pathlib.Path(info["local_path"]) if info else self._file_container_path(file_id)
        if not container.exists():
            raise FileNotFoundError(str(container))

        out_path_p = pathlib.Path(out_path)
        out_path_p.parent.mkdir(parents=True, exist_ok=True)
        tmp_out = out_path_p.with_name(out_path_p.name + ".part")

        file_key = b64d(file_key_b64)

        def _progress(info: dict) -> None:
            if not callable(progress_cb):
                return
            try:
                payload = dict(info or {})
            except Exception:
                payload = {}
            if name and not payload.get("name"):
                payload["name"] = str(name)
            try:
                progress_cb(payload)
            except Exception:
                pass

        container_total = 0
        try:
            container_total = int(container.stat().st_size)
        except Exception:
            container_total = 0

        try:
            _progress({"phase": "decrypt", "bytes_done": 0, "bytes_total": int(container_total)})
        except Exception:
            pass

        bytes_read = 0
        tick = 0
        with container.open("rb") as fp, tmp_out.open("wb") as out:
            hdr = fp.read(4)
            if len(hdr) != 4:
                raise RuntimeError("Invalid container")
            (ln,) = struct.unpack(">I", hdr)
            header = fp.read(ln)
            if len(header) != ln:
                raise RuntimeError("Invalid container")

            bytes_read += 4 + int(ln)

            state = bindings.crypto_secretstream_xchacha20poly1305_state()
            bindings.crypto_secretstream_xchacha20poly1305_init_pull(state, header, file_key)

            while True:
                lbuf = fp.read(4)
                if not lbuf:
                    break
                if len(lbuf) != 4:
                    raise RuntimeError("Truncated container")
                (cln,) = struct.unpack(">I", lbuf)
                ct = fp.read(cln)
                if len(ct) != cln:
                    raise RuntimeError("Truncated container")

                bytes_read += 4 + int(cln)
                pt, tag = bindings.crypto_secretstream_xchacha20poly1305_pull(state, ct, b"")
                if pt:
                    out.write(pt)
                if tag == bindings.crypto_secretstream_xchacha20poly1305_TAG_FINAL:
                    break

                tick += 1
                if tick % 16 == 0:
                    try:
                        _progress({"phase": "decrypt", "bytes_done": int(bytes_read), "bytes_total": int(container_total)})
                    except Exception:
                        pass
                    await asyncio.sleep(0)

        try:
            _progress({"phase": "decrypt", "bytes_done": int(bytes_read), "bytes_total": int(container_total)})
        except Exception:
            pass

        tmp_out.replace(out_path_p)
        return out_path_p

    async def decrypt_to_downloads(self, *, file_id: str, file_key_b64: str, name: str) -> pathlib.Path:
        out_name = self._safe_filename(name or file_id)
        out_path = self.downloads_dir / out_name
        return await self.decrypt_to_path(file_id=file_id, file_key_b64=file_key_b64, out_path=out_path)

    async def send_file_offer(self, peer_alias: str, path: str, caption: str | None, reply_to, progress_cb=None):
        if peer_alias in self.blocked_users:
            return
        msg_id = str(uuid.uuid4())
        meta = await self.seed_file_from_path(path, progress_cb=progress_cb)
        file_id = None
        try:
            if isinstance(meta, dict) and meta.get("file_id"):
                file_id = str(meta.get("file_id"))
        except Exception:
            file_id = None

        preview_b64 = None
        try:
            mime = (meta.get("mime") or "")
            if isinstance(mime, str) and mime.startswith("image/"):
                preview_b64 = self._make_image_preview_b64(path)
        except Exception:
            preview_b64 = None

        payload = {
            "kind": "file",
            "msg_id": msg_id,
            "file": {
                "file_id": meta["file_id"],
                "name": meta.get("name"),
                "bytes_total": meta.get("bytes_total"),
                "record_count": meta.get("record_count"),
                "record_hashes": meta.get("record_hashes"),
                "file_key": meta.get("file_key"),
                "mime": meta.get("mime"),
                "preview_png_b64": preview_b64,

                "cloud_blob_sha256": None,
            },
            "caption": caption or "",
            "reply_to": reply_to,
        }

        try:
            if file_id:
                p = self._file_container_path(file_id)
                sha = self._sha256_hex_file(p) if p.exists() else None
                if isinstance(sha, str) and sha:
                    payload["file"]["cloud_blob_sha256"] = sha
        except Exception:
            pass

        body = (caption or "").strip()
        self.db.add_message(
            msg_id,
            peer_alias,
            self.ident.alias,
            now_ts(),
            body,
            reply_to,
            None,
            "pending" if peer_alias not in self.sessions else "ok",
            kind="file",
            meta=payload["file"],
        )
        self.request_cloud_publish()
        self.ui.request_refresh_active_chat()

        if peer_alias not in self.sessions:
            self.db.add_outbox(peer_alias, {"kind": "file", "msg_id": msg_id, "payload": payload})
            await self.start_handshake(peer_alias)

            if file_id:
                try:
                    ok, reason = await self.publish_file_container_if_needed(
                        file_id=file_id,
                        force=False,
                        progress_cb=progress_cb,
                        wait_for_ack=True,
                        timeout=120.0,
                    )
                    if not ok and reason not in ("already_published", "sent_no_wait"):
                        self.ui.system_log(f"Cloud file upload failed: {reason}")
                except asyncio.CancelledError:
                    raise
                except Exception as e:
                    self.ui.system_log(f"Cloud file upload error: {e}")
            return

        await self._send_encrypted(peer_alias, payload)

        self.db.add_message(
            msg_id,
            peer_alias,
            self.ident.alias,
            now_ts(),
            body,
            reply_to,
            None,
            "ok",
            kind="file",
            meta=payload["file"],
        )
        self.request_cloud_publish()
        self.ui.request_refresh_active_chat()

        if file_id:
            try:
                ok, reason = await self.publish_file_container_if_needed(
                    file_id=file_id,
                    force=False,
                    progress_cb=progress_cb,
                    wait_for_ack=True,
                    timeout=120.0,
                )
                if not ok and reason not in ("already_published", "sent_no_wait"):
                    self.ui.system_log(f"Cloud file upload failed: {reason}")
            except asyncio.CancelledError:
                raise
            except Exception as e:
                self.ui.system_log(f"Cloud file upload error: {e}")

    def _pin_check(self, b: Bundle):
        pr = self.db.get_peer(b.alias)
        pinned = pr["pinned_fp"] if pr else None
        if pinned and pinned != b.fp:
            self.ui.system_log(f"[PIN MISMATCH] {b.alias}: pinned={pinned} server={b.fp}")
        elif not pinned:
            self.db.pin_peer(b.alias, b.fp)
            self.ui.system_log(f"Pinned {b.alias} -> {b.fp} (TOFU)")

    async def lookup(self, alias: str):
        await self.send({"type": "lookup", "alias": alias})

    def _derive_session(self, peer: Bundle, my_eph_sk: bytes, peer_eph_pk: bytes, initiator: bool) -> Session:
        dh1 = bindings.crypto_scalarmult(my_eph_sk, peer_eph_pk)
        if initiator:
            dh2 = bindings.crypto_scalarmult(bytes(self.ident.dh_sk), peer_eph_pk)
            dh3 = bindings.crypto_scalarmult(my_eph_sk, peer.dh_pub)
        else:
            dh2 = bindings.crypto_scalarmult(my_eph_sk, peer.dh_pub)
            dh3 = bindings.crypto_scalarmult(bytes(self.ident.dh_sk), peer_eph_pk)

        secret = hashlib.sha256(dh1 + dh2 + dh3).digest()
        root = hkdf(b"e2echat-v1", secret, b"root", 32)

        my_fp_raw = sha256_hex(self.ident.sign_pub)
        peer_fp_raw = sha256_hex(peer.sign_pub)
        if my_fp_raw < peer_fp_raw:
            send_ck = hkdf(root, b"", b"ckA", 32)
            recv_ck = hkdf(root, b"", b"ckB", 32)
        else:
            send_ck = hkdf(root, b"", b"ckB", 32)
            recv_ck = hkdf(root, b"", b"ckA", 32)
        return Session(peer_alias=peer.alias, peer_fp=peer.fp, send_ck=send_ck, recv_ck=recv_ck)

    async def start_handshake(self, peer_alias: str) -> int:
        peer = self.peers.get(peer_alias)
        if peer_alias in self.blocked_users:
            return -1
        if not peer:
            await self.lookup(peer_alias)
            rid = self.db.add_request_out(peer_alias, None, {"note": "lookup_pending"}, state="pending")
            self.ui.request_refresh_sidebar()
            return rid

        eph = PrivateKey.generate()
        eph_sk = bytes(eph)
        eph_pk = bytes(eph.public_key)
        self.pending_eph_sk[peer_alias] = eph_sk

        payload = {
            "from": self.ident.alias,
            "to": peer_alias,
            "fp": self.ident.fp,
            "id_sign_pub": b64e(self.ident.sign_pub),
            "id_dh_pub": b64e(self.ident.dh_pub),
            "eph_pub": b64e(eph_pk),
            "ts": now_ts(),
            "nonce": b64e(os.urandom(12)),
        }
        sig = self.ident.sign_sk.sign(("hs1|" + canonical_json(payload)).encode()).signature
        payload["sig"] = b64e(sig)

        rid = self.db.add_request_out(peer_alias, peer.fp, payload, state="pending")
        await self.send({"type": "relay", "to": peer_alias, "payload": {"type": "hs1", **payload}})
        self.ui.request_refresh_sidebar()
        return rid

    async def send_hs2(self, peer_alias: str, peer_eph_pk_b64: str, peer_fp: str):
        peer = self.peers.get(peer_alias)
        if not peer:
            return
        eph_r = PrivateKey.generate()
        eph_sk_r = bytes(eph_r)
        eph_pk_r = bytes(eph_r.public_key)
        peer_eph_pk = b64d(peer_eph_pk_b64)

        sess = self._derive_session(peer, eph_sk_r, peer_eph_pk, initiator=False)
        self.sessions[peer_alias] = sess
        self.db.save_session(peer_alias, peer_fp, sess.send_ck, sess.recv_ck, sess.send_n, sess.recv_n)

        payload = {
            "from": self.ident.alias,
            "to": peer_alias,
            "fp": self.ident.fp,
            "id_sign_pub": b64e(self.ident.sign_pub),
            "id_dh_pub": b64e(self.ident.dh_pub),
            "eph_pub": b64e(eph_pk_r),
            "ts": now_ts(),
            "nonce": b64e(os.urandom(12)),
        }
        sig = self.ident.sign_sk.sign(("hs2|" + canonical_json(payload)).encode()).signature
        payload["sig"] = b64e(sig)

        await self.send({"type": "relay", "to": peer_alias, "payload": {"type": "hs2", **payload}})
        self.db.set_request_out_state_by_peer(peer_alias, "accepted")
        self.db.ensure_chat(peer_alias)
        await self.flush_outbox(peer_alias)
        self.ui.request_refresh_sidebar()

    async def update_profile(self, status: str = None, status_msg: str = None, bio: str = None, visibility: int = None):

        if not hasattr(self, "my_profile") or not isinstance(self.my_profile, dict):
            self.my_profile = {}

        current_status = self.my_profile.get("status", "online")
        current_msg = self.my_profile.get("status_msg", "")
        current_bio = self.my_profile.get("bio", "")
        current_vis = self.my_profile.get("visible", 1)

        final_status = status if status is not None else current_status
        final_msg = status_msg if status_msg is not None else current_msg
        final_bio = bio if bio is not None else current_bio
        final_vis = visibility if visibility is not None else current_vis

        payload = {
            "type": "update_profile",
            "status": final_status,
            "status_msg": final_msg,
            "bio": final_bio,
            "visible": final_vis
        }
        await self.send(payload)

        self.my_profile["status"] = final_status
        self.my_profile["status_msg"] = final_msg
        self.my_profile["bio"] = final_bio
        self.my_profile["visible"] = final_vis

        if final_status and final_status != "offline":
            await self.send_alive()

    async def block_user(self, fp: str):
        target = (fp or "").strip()
        if not target:
            return
        await self.send({"type": "block", "target": target})
        self.blocked_users.add(target)

    async def unblock_user(self, fp: str):
        target = (fp or "").strip()
        if not target:
            return
        await self.send({"type": "unblock", "target": target})
        if target in self.blocked_users:
            self.blocked_users.remove(target)

    def _group_chat_id(self, group_id: str) -> str:
        return f"g:{group_id}"

    def _is_group_chat_id(self, chat_id: str) -> bool:
        return isinstance(chat_id, str) and chat_id.startswith("g:") and len(chat_id) > 2

    def _group_id_from_chat_id(self, chat_id: str) -> str | None:
        if not self._is_group_chat_id(chat_id):
            return None
        return chat_id[2:]

    async def create_group(self, name: str, members: list[str]) -> str:
        gid = uuid.uuid4().hex
        group_name = (name or "").strip() or "Group"
        uniq = []
        seen = set()
        for m in (members or []):
            a = (m or "").strip()
            if not a or a == self.ident.alias:
                continue
            if a in seen:
                continue
            seen.add(a)
            uniq.append(a)

        self.db.create_group(gid, group_name)
        self.db.add_group_member(gid, self.ident.alias)
        for a in uniq:
            self.db.add_group_member(gid, a)
        chat_id = self._group_chat_id(gid)
        self.db.ensure_chat(chat_id)

        for a in uniq:
            await self.send_group_invite(group_id=gid, to_alias=a)
        return chat_id

    async def send_group_invite(self, group_id: str, to_alias: str) -> None:
        to_alias = (to_alias or "").strip()
        if not to_alias or to_alias == self.ident.alias:
            return
        if to_alias in self.blocked_users:
            return
        g = self.db.get_group(group_id)
        if not g:
            return
        members = self.db.list_group_members(group_id)
        payload = {
            "kind": "group_invite",
            "group_id": group_id,
            "name": g.get("name") or "Group",
            "members": members,
            "ts": now_ts(),
        }
        if to_alias not in self.sessions:
            self.db.add_outbox(to_alias, {"kind": "group_invite", "payload": payload})
            await self.start_handshake(to_alias)
            return
        await self._send_encrypted(to_alias, payload)

    async def broadcast_group_member_added(self, group_id: str, added_alias: str) -> None:
        added_alias = (added_alias or "").strip()
        if not added_alias or added_alias == self.ident.alias:
            return
        if not self.db.get_group(group_id):
            return
        if not self.db.is_group_member(group_id, self.ident.alias):
            return
        members = self.db.list_group_members(group_id)
        payload = {
            "kind": "group_update",
            "group_id": group_id,
            "op": "add_member",
            "member": added_alias,
            "ts": now_ts(),
        }
        for m in members:
            if m in (self.ident.alias, added_alias):
                continue
            if m in self.blocked_users:
                continue
            if m not in self.sessions:
                self.db.add_outbox(m, {"kind": "group_update", "payload": payload})
                await self.start_handshake(m)
                continue
            await self._send_encrypted(m, payload)

    async def send_group_text(self, group_id: str, text: str, reply_to: str | None):
        chat_id = self._group_chat_id(group_id)
        if not self.db.get_group(group_id):
            return
        if not self.db.is_group_member(group_id, self.ident.alias):
            return

        msg_id = str(uuid.uuid4())
        ts = now_ts()
        self.db.ensure_chat(chat_id)
        self.db.add_message(msg_id, chat_id, self.ident.alias, ts, text, reply_to, None, "ok")
        self.request_cloud_publish()

        members = self.db.list_group_members(group_id)
        for m in members:
            if m == self.ident.alias:
                continue
            if m in self.blocked_users:
                continue
            payload = {
                "kind": "group_msg",
                "group_id": group_id,
                "msg_id": msg_id,
                "text": text,
                "reply_to": reply_to,
            }
            if m not in self.sessions:
                self.db.add_outbox(m, {"kind": "group_msg", "payload": payload})
                await self.start_handshake(m)
                continue
            await self._send_encrypted(m, payload)

        self.ui.request_refresh_active_chat(immediate=True)

    async def send_group_file_offer(self, group_id: str, path: str, caption: str, reply_to: str | None, progress_cb=None):
        chat_id = self._group_chat_id(group_id)
        if not self.db.get_group(group_id):
            return
        if not self.db.is_group_member(group_id, self.ident.alias):
            return
        meta = await self.seed_file_from_path(path, name=(pathlib.Path(path).name if path else None), progress_cb=progress_cb)
        file_id = None
        try:
            if isinstance(meta, dict) and meta.get("file_id"):
                file_id = str(meta.get("file_id"))
        except Exception:
            file_id = None
        msg_id = str(uuid.uuid4())
        ts = now_ts()
        self.db.ensure_chat(chat_id)
        self.db.add_message(
            msg_id,
            chat_id,
            self.ident.alias,
            ts,
            caption or "",
            reply_to,
            None,
            "ok",
            kind="file",
            meta=meta,
        )
        self.request_cloud_publish()
        members = self.db.list_group_members(group_id)

        try:
            if file_id and isinstance(meta, dict):
                p = self._file_container_path(file_id)
                sha = self._sha256_hex_file(p) if p.exists() else None
                if isinstance(sha, str) and sha:
                    meta = dict(meta)
                    meta["cloud_blob_sha256"] = sha
        except Exception:
            pass
        payload = {
            "kind": "group_file",
            "group_id": group_id,
            "msg_id": msg_id,
            "file": meta,
            "caption": caption or "",
            "reply_to": reply_to,
        }
        for m in members:
            if m == self.ident.alias:
                continue
            if m in self.blocked_users:
                continue
            if m not in self.sessions:
                self.db.add_outbox(m, {"kind": "group_file", "payload": payload})
                await self.start_handshake(m)
                continue
            await self._send_encrypted(m, payload)
        self.ui.request_refresh_active_chat(immediate=True)

        if file_id:
            try:
                ok, reason = await self.publish_file_container_if_needed(
                    file_id=file_id,
                    force=False,
                    progress_cb=progress_cb,
                    wait_for_ack=True,
                    timeout=120.0,
                )
                if not ok and reason not in ("already_published", "sent_no_wait"):
                    self.ui.system_log(f"Cloud file upload failed: {reason}")
            except asyncio.CancelledError:
                raise
            except Exception as e:
                self.ui.system_log(f"Cloud file upload error: {e}")

    async def send_hs_reject(self, peer_alias: str, reason: str = "declined"):
        payload = {
            "from": self.ident.alias,
            "to": peer_alias,
            "fp": self.ident.fp,
            "reason": reason,
            "ts": now_ts(),
            "nonce": b64e(os.urandom(12)),
        }
        sig = self.ident.sign_sk.sign(("hs_reject|" + canonical_json(payload)).encode()).signature
        payload["sig"] = b64e(sig)
        await self.send({"type": "relay", "to": peer_alias, "payload": {"type": "hs_reject", **payload}})

    def _signed_obj(self, *, from_alias: str, to_alias: str, header: dict, obj: dict) -> dict:
        out = dict(obj)
        out.pop("sig", None)
        to_sign = {
            "from": from_alias,
            "to": to_alias,
            "header": header,
            "obj": out,
        }
        sig = self.ident.sign_sk.sign(("dm_sig|" + canonical_json(to_sign)).encode("utf-8")).signature
        out["sig"] = b64e(sig)
        return out

    def _verify_signed_obj(self, *, from_alias: str, to_alias: str, header: dict, obj: dict) -> bool:
        sig_b64 = obj.get("sig")
        if not isinstance(sig_b64, str) or not sig_b64:
            return False

        bundle = self.peers.get(from_alias)
        if not bundle:
            return False

        tmp = dict(obj)
        tmp.pop("sig", None)
        to_verify = {
            "from": from_alias,
            "to": to_alias,
            "header": header,
            "obj": tmp,
        }
        try:
            vk = VerifyKey(bundle.sign_pub)
            vk.verify(("dm_sig|" + canonical_json(to_verify)).encode("utf-8"), b64d(sig_b64))
            return True
        except Exception:
            return False

    async def _send_encrypted(self, peer_alias: str, payload_obj: dict):
        sess = self.sessions[peer_alias]
        sess.send_ck, mk = kdf_chain(sess.send_ck)
        sess.send_n += 1
        header = {"msg_id": payload_obj.get("msg_id", str(uuid.uuid4())), "n": sess.send_n, "ts": now_ts()}
        payload_obj = self._signed_obj(from_alias=self.ident.alias, to_alias=peer_alias, header=header, obj=payload_obj)
        aad = (f"dm|{self.ident.alias}|{peer_alias}|{header['msg_id']}|{header['n']}").encode()
        plaintext = canonical_json(payload_obj).encode("utf-8")
        nonce, ct = aead_encrypt(mk[:32], plaintext, aad)
        payload = {"type": "dm", "from": self.ident.alias, "to": peer_alias, "header": header, "nonce": b64e(nonce), "ct": b64e(ct)}
        await self.send({"type": "relay", "to": peer_alias, "payload": payload})
        self.db.save_session(peer_alias, sess.peer_fp, sess.send_ck, sess.recv_ck, sess.send_n, sess.recv_n)

    async def send_text(self, peer_alias: str, text: str, reply_to):
        msg_id = str(uuid.uuid4())
        if peer_alias in self.blocked_users:
            return
        if peer_alias not in self.sessions:
            self.db.add_message(msg_id, peer_alias, self.ident.alias, now_ts(), text, reply_to, None, "pending")
            self.request_cloud_publish()
            self.db.add_outbox(peer_alias, {"kind": "text", "msg_id": msg_id, "text": text, "reply_to": reply_to})
            self.ui.request_refresh_active_chat(immediate=True)
            await self.start_handshake(peer_alias)
            return
        await self._send_encrypted(peer_alias, {"kind": "text", "msg_id": msg_id, "text": text, "reply_to": reply_to})
        self.db.add_message(msg_id, peer_alias, self.ident.alias, now_ts(), text, reply_to, None, "ok")
        self.request_cloud_publish()
        self.ui.request_refresh_active_chat(immediate=True)

    def _voice_key(self, peer_alias: str, call_id: str) -> bytes | None:
        if not isinstance(peer_alias, str) or not isinstance(call_id, str):
            return None
        k = (peer_alias, call_id)
        cached = self._voice_key_cache.get(k)
        if isinstance(cached, (bytes, bytearray)) and len(cached) == 32:
            return bytes(cached)
        sess = self.sessions.get(peer_alias)
        if not sess:
            return None
        a = bytes(sess.send_ck)
        b = bytes(sess.recv_ck)
        lo, hi = (a, b) if a <= b else (b, a)
        seed = hashlib.sha256(lo + hi + call_id.encode("utf-8")).digest()
        info = f"voice|{min(self.ident.alias, peer_alias)}|{max(self.ident.alias, peer_alias)}|{call_id}".encode("utf-8")
        key32 = hkdf(b"voice", seed, info, 32)
        self._voice_key_cache[k] = key32
        return key32

    async def send_call_offer(self, peer_alias: str, call_id: str, *, rate: int = 48000, channels: int = 1, frame_ms: int = 20):
        if peer_alias in self.blocked_users:
            return
        if peer_alias not in self.sessions:
            raise RuntimeError("NO_SESSION")
        await self._send_encrypted(
            peer_alias,
            {
                "kind": "call",
                "op": "offer",
                "call_id": str(call_id),
                "codec": "pcm_s16le",
                "rate": int(rate),
                "channels": int(channels),
                "frame_ms": int(frame_ms),
            },
        )

    async def send_call_answer(self, peer_alias: str, call_id: str, *, accept: bool):
        if peer_alias in self.blocked_users:
            return
        if peer_alias not in self.sessions:
            return
        await self._send_encrypted(
            peer_alias,
            {
                "kind": "call",
                "op": "answer",
                "call_id": str(call_id),
                "accept": bool(accept),
            },
        )

    async def send_call_hangup(self, peer_alias: str, call_id: str, *, reason: str = "hangup"):
        if peer_alias in self.blocked_users:
            return
        if peer_alias not in self.sessions:
            return
        await self._send_encrypted(
            peer_alias,
            {
                "kind": "call",
                "op": "hangup",
                "call_id": str(call_id),
                "reason": str(reason or "hangup"),
            },
        )

    async def send_voice_pcm(self, peer_alias: str, call_id: str, pcm_s16le: bytes) -> None:
        if not isinstance(pcm_s16le, (bytes, bytearray)) or not pcm_s16le:
            return
        key = self._voice_key(peer_alias, call_id)
        if not key:
            return
        seq_key = (peer_alias, call_id)
        seq = int(self._voice_seq.get(seq_key, 0)) + 1
        self._voice_seq[seq_key] = seq

        aad = f"voice|{self.ident.alias}|{peer_alias}|{call_id}|{seq}".encode("utf-8")
        nonce, ct = aead_encrypt(key, bytes(pcm_s16le), aad)
        payload = {
            "type": "voice_frame",
            "from": self.ident.alias,
            "to": peer_alias,
            "call_id": str(call_id),
            "seq": int(seq),
            "nonce": b64e(nonce),
            "ct": b64e(ct),
        }
        await self.send({"type": "relay", "to": peer_alias, "payload": payload})

    def _group_voice_key(self, group_id: str, call_id: str) -> bytes | None:
        if not group_id or not call_id:
            return None
        k = ("group", group_id, call_id)
        cached = self._voice_key_cache.get(k)
        if cached:
            return cached
        seed = hashlib.sha256(f"{group_id}|{call_id}".encode("utf-8")).digest()
        info = f"group_voice|{group_id}|{call_id}".encode("utf-8")
        key32 = hkdf(b"group_voice", seed, info, 32)
        self._voice_key_cache[k] = key32
        return key32

    async def group_call_join(self, group_id: str, call_id: str) -> None:
        await self.send({
            "type": "group_call_join",
            "group_id": str(group_id),
            "call_id": str(call_id),
        })

    async def group_call_leave(self, group_id: str) -> None:
        await self.send({
            "type": "group_call_leave",
            "group_id": str(group_id),
        })

    async def group_call_ring(self, group_id: str, call_id: str, members: list[str], target: str | None = None) -> None:
        await self.send({
            "type": "group_call_ring",
            "group_id": str(group_id),
            "call_id": str(call_id),
            "members": list(members),
            "target": target,
        })

    async def send_group_ring(self, group_id: str, targets: list[str]) -> None:

        await self.send({
            "type": "group_member_ring",
            "group_id": str(group_id),
            "targets": list(targets),
        })

    async def kick_group_member(self, group_id: str, alias: str) -> None:
        await self.send({
            "type": "group_kick",
            "group_id": str(group_id),
            "target": str(alias),
        })

    async def group_call_query(self, group_id: str) -> None:
        await self.send({
            "type": "group_call_query",
            "group_id": str(group_id),
        })

    async def send_group_voice_pcm(self, group_id: str, call_id: str, pcm_s16le: bytes) -> None:
        if not isinstance(pcm_s16le, (bytes, bytearray)) or not pcm_s16le:
            return
        key = self._group_voice_key(group_id, call_id)
        if not key:
            return
        seq_key = ("group", group_id, call_id)
        seq = int(self._voice_seq.get(seq_key, 0)) + 1
        self._voice_seq[seq_key] = seq

        aad = f"group_voice|{group_id}|{call_id}|{self.ident.alias}|{seq}".encode("utf-8")
        nonce, ct = aead_encrypt(key, bytes(pcm_s16le), aad)
        await self.send({
            "type": "group_voice_frame",
            "group_id": str(group_id),
            "call_id": str(call_id),
            "seq": int(seq),
            "nonce": b64e(nonce),
            "ct": b64e(ct),
        })

    def accept_group_invite(self, payload: dict) -> str | None:
        gid = payload.get("group_id")
        if not isinstance(gid, str) or not gid:
            return None
        name = payload.get("name")
        members = payload.get("members")
        inviter = payload.get("from")

        group_name = str(name or "Group")
        member_list = list(members) if isinstance(members, list) else []
        if inviter and isinstance(inviter, str) and inviter not in member_list:
            member_list.append(inviter)
        if self.ident.alias not in member_list:
            member_list.append(self.ident.alias)

        self.db.create_group(gid, group_name)
        self.db.rename_group(gid, group_name)
        for a in member_list:
            if isinstance(a, str) and a.strip():
                self.db.add_group_member(gid, a.strip())
        chat_id = self._group_chat_id(gid)
        self.db.ensure_chat(chat_id)
        return chat_id

    async def send_control_delete(self, peer_alias: str, msg_id: str):
        if peer_alias not in self.sessions:
            return
        await self._send_encrypted(peer_alias, {"kind": "control", "op": "delete", "msg_id": msg_id})

        self.request_cloud_publish()

        try:
            m = self.db.get_message(msg_id)
            if m and m.get("kind") == "file":
                meta = m.get("meta")
                if isinstance(meta, dict):
                    fid = meta.get("file_id")
                    if isinstance(fid, str) and fid:
                        asyncio.create_task(self.cloud_delete_file_if_known(file_id=fid))
        except Exception:
            pass

    async def send_control_edit(self, peer_alias: str, msg_id: str, new_text: str):
        if peer_alias not in self.sessions:
            return
        await self._send_encrypted(peer_alias, {"kind": "control", "op": "edit", "msg_id": msg_id, "text": new_text})
        self.request_cloud_publish()

    async def flush_outbox(self, peer_alias: str):
        if peer_alias not in self.sessions:
            return
        for p in self.db.pop_outbox(peer_alias, limit=500):
            if p.get("kind") == "text":
                await self._send_encrypted(
                    peer_alias,
                    {"kind": "text", "msg_id": p["msg_id"], "text": p["text"], "reply_to": p.get("reply_to")},
                )
                self.db.mark_message_ok(p["msg_id"])
            elif p.get("kind") == "file":
                pl = p.get("payload")
                if isinstance(pl, dict):
                    await self._send_encrypted(peer_alias, pl)
                    self.db.mark_message_ok(p.get("msg_id"))
            elif p.get("kind") == "group_invite":
                pl = p.get("payload")
                if isinstance(pl, dict):
                    await self._send_encrypted(peer_alias, pl)
            elif p.get("kind") == "group_msg":
                pl = p.get("payload")
                if isinstance(pl, dict):
                    await self._send_encrypted(peer_alias, pl)
            elif p.get("kind") == "group_file":
                pl = p.get("payload")
                if isinstance(pl, dict):
                    await self._send_encrypted(peer_alias, pl)
            elif p.get("kind") == "group_update":
                pl = p.get("payload")
                if isinstance(pl, dict):
                    await self._send_encrypted(peer_alias, pl)
        self.ui.request_refresh_active_chat()

    async def handle_incoming(self, msg: dict):
        t = msg.get("type")

        if t == "cloud_put_ok":
            try:
                self.db.set_setting("cloud_last_publish_ts", str(now_ts()))
                sha = msg.get("blob_sha256")
                if isinstance(sha, str) and sha:
                    self.db.set_setting("cloud_last_publish_sha", sha)
                    self._cloud_pending_sha.discard(sha)
                    self._cloud_published_sha.add(sha)
                    fut = None
                    try:
                        fut = self._cloud_put_waiters.pop(sha, None)
                    except Exception:
                        fut = None
                    if fut is not None and not fut.done():
                        try:
                            fut.set_result({"ok": True, "blob_sha256": sha})
                        except Exception:
                            pass
            except Exception:
                pass
            return

        if t == "cloud_put_err":
            sha = msg.get("blob_sha256")
            err_msg = msg.get("error", "unknown")
            try:
                self.ui.system_log(f"Cloud upload error from server: {err_msg}")
            except Exception:
                pass
            if isinstance(sha, str) and sha:
                self._cloud_pending_sha.discard(sha)
                fut = None
                try:
                    fut = self._cloud_put_waiters.pop(sha, None)
                except Exception:
                    fut = None
                if fut is not None and not fut.done():
                    try:
                        fut.set_result({"ok": False, "blob_sha256": sha, "error": err_msg})
                    except Exception:
                        pass
            return

        if t == "cloud_blob":
            req_id = msg.get("req_id")
            if isinstance(req_id, str):
                fut = self._cloud_waiters.get(req_id)
                if fut is not None and not fut.done():
                    fut.set_result(msg)
            return

        if t == "cloud_blob_begin":
            req_id = msg.get("req_id")
            okv = msg.get("ok")
            if not isinstance(req_id, str):
                return
            if okv is not True:
                fut = self._cloud_waiters.get(req_id)
                if fut is not None and not fut.done():
                    fut.set_result(msg)
                return
            try:
                total = int(msg.get("bytes_total") or 0)
            except Exception:
                total = 0
            if total <= 0:
                return
            self._cloud_chunk_inflight[req_id] = {
                "meta": msg,
                "buf": bytearray(),
                "next_idx": 0,
                "expected": total,
                "progress_cb": self._cloud_waiter_progress.get(req_id),
                "last_emit": 0.0,
            }
            try:
                cb = self._cloud_chunk_inflight[req_id].get("progress_cb")
                if callable(cb):
                    cb({"phase": "download", "bytes_done": 0, "bytes_total": int(total)})
            except Exception:
                pass
            return

        if t == "cloud_blob_chunk":
            req_id = msg.get("req_id")
            idx = msg.get("idx")
            data = msg.get("data")
            if not isinstance(req_id, str) or not isinstance(idx, int) or not isinstance(data, str):
                return
            st = self._cloud_chunk_inflight.get(req_id)
            if not isinstance(st, dict):
                return
            if int(idx) != int(st.get("next_idx") or 0):
                return
            try:
                chunk = b64d(data)
            except Exception:
                return
            buf = st.get("buf")
            if not isinstance(buf, bytearray):
                return
            try:
                expected = int(st.get("expected") or 0)
            except Exception:
                expected = 0
            if expected and (len(buf) + len(chunk)) > expected:
                return
            buf.extend(chunk)
            st["next_idx"] = int(st.get("next_idx") or 0) + 1

            try:
                cb = st.get("progress_cb")
                last_emit = float(st.get("last_emit") or 0.0)
                now = time.monotonic()
                done = int(len(buf))
                if callable(cb) and (done == expected or (now - last_emit) >= 0.1):
                    st["last_emit"] = now
                    cb({"phase": "download", "bytes_done": int(done), "bytes_total": int(expected)})
            except Exception:
                pass
            return

        if t == "cloud_blob_end":
            req_id = msg.get("req_id")
            if not isinstance(req_id, str):
                return
            st = self._cloud_chunk_inflight.pop(req_id, None)
            if not isinstance(st, dict):
                return
            meta = st.get("meta") if isinstance(st.get("meta"), dict) else {}
            buf = st.get("buf")
            if not isinstance(buf, bytearray):
                return
            try:
                expected = int(st.get("expected") or 0)
            except Exception:
                expected = 0
            if expected and len(buf) != expected:
                fut = self._cloud_waiters.get(req_id)
                if fut is not None and not fut.done():
                    fut.set_result({"type": "cloud_blob", "req_id": req_id, "ok": False, "error": "BAD_BLOB"})
                return
            out = dict(meta)
            out["type"] = "cloud_blob"
            out["req_id"] = req_id
            out["ok"] = True
            out["blob_bytes"] = bytes(buf)
            fut = self._cloud_waiters.get(req_id)
            if fut is not None and not fut.done():
                fut.set_result(out)
            return
        if t == "blocks":
            items = msg.get("items")
            if isinstance(items, list):
                try:
                    self.blocked_users = set(str(x) for x in items if isinstance(x, str) and x)
                except Exception:
                    self.blocked_users = set()
                try:
                    self.ui.request_refresh_sidebar()
                except Exception:
                    pass
            return
        if t == "file_seeds":
            file_id = msg.get("file_id")
            seeds = msg.get("seeds") or []
            try:
                total = msg.get("seeds_total")
                if isinstance(file_id, str):
                    self._file_seeds_total[file_id] = int(total) if total is not None else len(list(seeds))
            except Exception:
                pass
            fut = self._file_find_waiters.get(file_id)
            if fut and not fut.done():
                fut.set_result(list(seeds))
            return

        if t == "file_get":

            if not self.seeding_enabled:
                try:
                    requester = msg.get("requester")
                    transfer_id = msg.get("transfer_id")
                    if isinstance(requester, str) and isinstance(transfer_id, str):
                        await self.send({"type": "file_err", "to": requester, "transfer_id": transfer_id, "error": "SEEDING_DISABLED"})
                except Exception:
                    pass
                return
            file_id = msg.get("file_id")
            transfer_id = msg.get("transfer_id")
            idx = msg.get("idx")
            requester = msg.get("requester")
            if not isinstance(file_id, str) or not isinstance(transfer_id, str) or not isinstance(idx, int) or not isinstance(requester, str):
                return
            info = self.db.get_file(file_id)
            local_path = None
            if info and info.get("local_path"):
                lp = pathlib.Path(info["local_path"])
                if lp.exists():
                    local_path = lp
            if not local_path:

                p = self._file_container_path(file_id)
                if p.exists():
                    local_path = p

            if not local_path:
                await self.send({"type": "file_err", "to": requester, "transfer_id": transfer_id, "error": "NO_SUCH_FILE"})
                return
            try:
                rec = self._read_record_by_index(local_path, idx)
            except Exception as e:
                await self.send({"type": "file_err", "to": requester, "transfer_id": transfer_id, "error": f"READ_ERR:{e}"})
                return
            await self.send({"type": "file_chunk", "to": requester, "transfer_id": transfer_id, "file_id": file_id, "idx": idx, "data": b64e(rec)})
            return

        if t == "file_chunk":
            transfer_id = msg.get("transfer_id")
            idx = msg.get("idx")
            data_b64 = msg.get("data")
            if not isinstance(transfer_id, str) or not isinstance(idx, int) or not isinstance(data_b64, str):
                return
            waiter_map = self._file_chunk_waiters.get(transfer_id)
            if not waiter_map:
                return
            fut = waiter_map.get(idx)
            if fut and not fut.done():
                try:
                    fut.set_result(b64d(data_b64))
                except Exception as e:
                    fut.set_exception(e)
            return

        if t == "file_err":
            transfer_id = msg.get("transfer_id")
            error = msg.get("error")
            if not isinstance(transfer_id, str):
                return
            waiter_map = self._file_chunk_waiters.get(transfer_id)
            if waiter_map:
                for fut in waiter_map.values():
                    if not fut.done():
                        fut.set_exception(RuntimeError(str(error)))
            return

        if t == "list":
            items = msg.get("items", [])
            self.all_users = items
            self.online_aliases = set(i["alias"] for i in items if i.get("status") != "offline")

            for u in items:
                if u["alias"] == self.ident.alias:
                    self.my_profile = u
                    break

            self.ui.request_refresh_sidebar()
            return

        if t == "profile_update":

            alias = msg.get("alias")
            for u in self.all_users:
                if u["alias"] == alias:
                    u.update(msg.get("data", {}))
                    break
            self.ui.request_refresh_sidebar()
            return

        if t == "lookup":
            if not msg.get("ok"):
                return
            b = Bundle(alias=msg["alias"], fp=msg["fp"], sign_pub=b64d(msg["sign_pub"]), dh_pub=b64d(msg["dh_pub"]),
                       pinned_fp=(self.db.get_peer(msg["alias"]) or {}).get("pinned_fp"))
            self.peers[b.alias] = b
            self.db.upsert_peer(b.alias, b.fp, msg["sign_pub"], msg["dh_pub"])
            self._pin_check(b)
            self.ui.request_refresh_sidebar()
            try:
                pending = self.db.find_pending_request_out(b.alias)
                if pending and isinstance(pending.get("payload"), dict) and pending["payload"].get("note") == "lookup_pending":
                    try:
                        self.db.delete_request_out(int(pending["id"]))
                    except Exception:
                        pass
                    await self.start_handshake(b.alias)
            except Exception:
                pass
            return

        if t == "hs1":
            await self._handle_hs1(msg); return
        if t == "hs2":
            await self._handle_hs2(msg); return
        if t == "hs_reject":
            frm = msg.get("from")
            if frm:
                self.db.set_request_out_state_by_peer(frm, "declined")
            self.ui.request_refresh_sidebar()
            return
        if t == "dm":
            await self._handle_dm(msg); return
        if t == "voice_frame":
            await self._handle_voice_frame(msg); return
        if t == "group_voice_frame":
            await self._handle_group_voice_frame(msg); return
        if t == "group_call_state":
            await self._handle_group_call_state(msg); return
        if t == "group_call_ring":
            await self._handle_group_call_ring(msg); return
        if t == "group_kicked":
            await self._handle_group_kicked(msg); return
        if t == "group_member_left":
            await self._handle_group_member_left(msg); return
        if t == "error":
            self.ui.system_log(f"[server error] {msg.get('error')}")
            return

    async def _handle_voice_frame(self, msg: dict) -> None:
        sender = msg.get("from")
        to_alias = msg.get("to")
        call_id = msg.get("call_id")
        seq = msg.get("seq")
        nonce_b64 = msg.get("nonce")
        ct_b64 = msg.get("ct")
        if not isinstance(sender, str) or not isinstance(to_alias, str) or not isinstance(call_id, str):
            return
        if to_alias != self.ident.alias:
            return
        if not isinstance(seq, int) or not isinstance(nonce_b64, str) or not isinstance(ct_b64, str):
            return
        if sender in self.blocked_users:
            return

        try:
            active = True
            if hasattr(self.ui, "is_voice_call_active"):
                active = bool(self.ui.is_voice_call_active(sender, call_id))
            if not active:
                return
        except Exception:
            return

        key = self._voice_key(sender, call_id)
        if not key:
            return
        try:
            nonce = b64d(nonce_b64)
            ct = b64d(ct_b64)
        except Exception:
            return
        aad = f"voice|{sender}|{self.ident.alias}|{call_id}|{int(seq)}".encode("utf-8")
        try:
            pcm = aead_decrypt(key, nonce, ct, aad)
        except Exception:
            return
        if not isinstance(pcm, (bytes, bytearray)):
            return
        try:
            if hasattr(self.ui, "on_voice_pcm"):
                res = self.ui.on_voice_pcm(sender, call_id, bytes(pcm))
                if asyncio.iscoroutine(res):
                    await res
        except Exception:
            return

    async def _handle_group_voice_frame(self, msg: dict) -> None:
        sender = msg.get("from")
        group_id = msg.get("group_id")
        call_id = msg.get("call_id")
        seq = msg.get("seq")
        nonce_b64 = msg.get("nonce")
        ct_b64 = msg.get("ct")
        if not all(isinstance(x, str) for x in [sender, group_id, call_id]):
            return
        if not isinstance(seq, int) or not isinstance(nonce_b64, str) or not isinstance(ct_b64, str):
            return

        if sender == self.ident.alias:
            return
        if sender in self.blocked_users:
            return

        try:
            active = False
            if hasattr(self.ui, "is_group_call_active"):
                active = bool(self.ui.is_group_call_active(group_id, call_id))
            if not active:
                return
        except Exception:
            return

        key = self._group_voice_key(group_id, call_id)
        if not key:
            return
        try:
            nonce = b64d(nonce_b64)
            ct = b64d(ct_b64)
        except Exception:
            return
        aad = f"group_voice|{group_id}|{call_id}|{sender}|{int(seq)}".encode("utf-8")
        try:
            pcm = aead_decrypt(key, nonce, ct, aad)
        except Exception:
            return
        if not isinstance(pcm, (bytes, bytearray)):
            return
        try:
            if hasattr(self.ui, "on_group_voice_pcm"):
                res = self.ui.on_group_voice_pcm(group_id, call_id, sender, bytes(pcm))
                if asyncio.iscoroutine(res):
                    await res
        except Exception:
            return

    async def _handle_group_call_state(self, msg: dict) -> None:
        group_id = msg.get("group_id")
        call_id = msg.get("call_id")
        participants = msg.get("participants", [])
        action = msg.get("action")
        who = msg.get("who")
        if not group_id:
            return
        try:
            if hasattr(self.ui, "on_group_call_state"):
                res = self.ui.on_group_call_state(group_id, call_id, participants, action, who)
                if asyncio.iscoroutine(res):
                    await res
        except Exception:
            return

    async def _handle_group_call_ring(self, msg: dict) -> None:
        group_id = msg.get("group_id")
        call_id = msg.get("call_id")
        caller = msg.get("from")
        if not all([group_id, call_id, caller]):
            return
        if caller in self.blocked_users:
            return
        try:
            if hasattr(self.ui, "on_group_call_ring"):
                res = self.ui.on_group_call_ring(group_id, call_id, caller)
                if asyncio.iscoroutine(res):
                    await res
        except Exception:
            return

    async def _handle_group_kicked(self, msg: dict) -> None:
        group_id = msg.get("group_id")
        by = msg.get("by")
        if not group_id:
            return
        try:

            self.db.delete_group(group_id)
            if hasattr(self.ui, "on_group_kicked"):
                res = self.ui.on_group_kicked(group_id, by)
                if asyncio.iscoroutine(res):
                    await res
        except Exception:
            return

    async def _handle_group_member_left(self, msg: dict) -> None:
        group_id = msg.get("group_id")
        alias = msg.get("alias")
        reason = msg.get("reason", "left")
        if not group_id or not alias:
            return
        try:

            g = self.db.get_group(group_id)
            if g:
                members = g.get("members", [])
                if alias in members:
                    members.remove(alias)
                    g["members"] = members
                    self.db.save_group(g)
            if hasattr(self.ui, "on_group_member_left"):
                res = self.ui.on_group_member_left(group_id, alias, reason)
                if asyncio.iscoroutine(res):
                    await res
        except Exception:
            return

    async def _handle_hs1(self, msg: dict):
        peer_alias = msg.get("from")
        peer_fp = msg.get("fp")
        if not peer_alias or not peer_fp:
            return
        if peer_alias in self.blocked_users:
            return
        sig_b64 = msg.get("sig")
        tmp = dict(msg)
        tmp.pop("sig", None); tmp.pop("type", None)
        to_verify = ("hs1|" + canonical_json(tmp)).encode()
        peer_sign_pub = b64d(msg["id_sign_pub"])
        try:
            VerifyKey(peer_sign_pub).verify(to_verify, b64d(sig_b64))
        except BadSignatureError:
            return

        peer = Bundle(alias=peer_alias, fp=peer_fp, sign_pub=peer_sign_pub, dh_pub=b64d(msg["id_dh_pub"]),
                      pinned_fp=(self.db.get_peer(peer_alias) or {}).get("pinned_fp"))
        self.peers[peer_alias] = peer
        self.db.upsert_peer(peer_alias, peer_fp, msg["id_sign_pub"], msg["id_dh_pub"])
        self._pin_check(peer)
        self.db.add_request_in(peer_alias, peer_fp, msg)
        self.ui.request_refresh_sidebar()
        try:
            if hasattr(self.ui, "notify_requests"):
                self.ui.notify_requests()
        except Exception:
            pass

        try:
            if hasattr(self.ui, "_on_new_request_receive"):
                asyncio.create_task(self.ui._on_new_request_receive(peer_alias, peer_fp, msg))
        except Exception:
            pass

    async def _handle_hs2(self, msg: dict):
        peer_alias = msg.get("from")
        peer_fp = msg.get("fp")
        if not peer_alias or not peer_fp:
            return
        sig_b64 = msg.get("sig")
        tmp = dict(msg)
        tmp.pop("sig", None); tmp.pop("type", None)
        to_verify = ("hs2|" + canonical_json(tmp)).encode()
        peer_sign_pub = b64d(msg["id_sign_pub"])
        try:
            VerifyKey(peer_sign_pub).verify(to_verify, b64d(sig_b64))
        except BadSignatureError:
            return

        peer = self.peers.get(peer_alias)
        if not peer:
            peer = Bundle(alias=peer_alias, fp=peer_fp, sign_pub=peer_sign_pub, dh_pub=b64d(msg["id_dh_pub"]),
                          pinned_fp=(self.db.get_peer(peer_alias) or {}).get("pinned_fp"))
            self.peers[peer_alias] = peer
            self.db.upsert_peer(peer_alias, peer_fp, msg["id_sign_pub"], msg["id_dh_pub"])
            self._pin_check(peer)

        eph_sk_i = self.pending_eph_sk.pop(peer_alias, None)
        if not eph_sk_i:
            return
        peer_eph_pk = b64d(msg["eph_pub"])
        sess = self._derive_session(peer, eph_sk_i, peer_eph_pk, initiator=True)
        self.sessions[peer_alias] = sess
        self.db.save_session(peer_alias, peer_fp, sess.send_ck, sess.recv_ck, sess.send_n, sess.recv_n)
        self.db.set_request_out_state_by_peer(peer_alias, "accepted")
        self.db.ensure_chat(peer_alias)
        self.ui.request_refresh_sidebar()
        await self.flush_outbox(peer_alias)

    async def _handle_dm(self, msg: dict):
        sender = msg.get("from")
        header = msg.get("header", {})
        if not sender or "msg_id" not in header or "n" not in header:
            return
        if sender in self.blocked_users:
            return
        sess = self.sessions.get(sender)
        if not sess:
            return
        nonce = b64d(msg["nonce"])
        ct = b64d(msg["ct"])
        aad = (f"dm|{sender}|{self.ident.alias}|{header['msg_id']}|{header['n']}").encode()
        target_n = int(header["n"])
        mk = None
        while sess.recv_n < target_n:
            sess.recv_ck, mk = kdf_chain(sess.recv_ck)
            sess.recv_n += 1
        try:
            pt = aead_decrypt(mk[:32], nonce, ct, aad)
        except Exception:
            return
        try:
            obj = json.loads(pt.decode("utf-8"))
        except Exception:
            return
        if not isinstance(obj, dict):
            return

        if not self._verify_signed_obj(from_alias=sender, to_alias=self.ident.alias, header=header, obj=obj):
            try:
                self.ui.system_log(f"Dropped forged/unsigned message from {sender}")
            except Exception:
                pass
            return
        self.db.save_session(sender, sess.peer_fp, sess.send_ck, sess.recv_ck, sess.send_n, sess.recv_n)

        if obj.get("kind") == "group_invite":
            gid = obj.get("group_id")
            name = obj.get("name")
            members = obj.get("members")
            if not isinstance(gid, str) or not gid:
                return
            payload = {
                "type": "group_invite",
                "group_id": gid,
                "name": str(name or "Group"),
                "members": list(members) if isinstance(members, list) else [],
                "from": sender,
            }
            try:
                self.db.add_request_in(sender, sess.peer_fp, payload)
            except Exception:
                return
            try:
                if hasattr(self.ui, "notify_requests"):
                    self.ui.notify_requests()
            except Exception:
                pass
            self.ui.request_refresh_sidebar()
            return

        if obj.get("kind") == "group_update":
            gid = obj.get("group_id")
            op = obj.get("op")
            member = obj.get("member")
            if not isinstance(gid, str) or not gid:
                return
            if not self.db.get_group(gid):
                return
            if not self.db.is_group_member(gid, self.ident.alias):
                return
            if not self.db.is_group_member(gid, sender):
                return
            if op == "add_member" and isinstance(member, str) and member.strip():
                self.db.add_group_member(gid, member.strip())
                chat_id = self._group_chat_id(gid)
                ts = int(header.get("ts", now_ts()))
                mid = str(uuid.uuid4())
                body = f"[+] {member.strip()}"
                self.db.ensure_chat(chat_id)
                self.db.add_message(mid, chat_id, sender, ts, body, None, None, "ok")
                self.request_cloud_publish()
                if getattr(self.ui, "active_chat", None) != chat_id:
                    try:
                        self.db.inc_chat_unread(chat_id, 1)
                    except Exception:
                        pass
                self.ui.request_refresh_sidebar()
                try:
                    if getattr(self.ui, "active_chat", None) == chat_id:
                        self.ui.request_refresh_active_chat(immediate=True)
                except Exception:
                    pass
            return

        if obj.get("kind") == "group_msg":
            gid = obj.get("group_id")
            if not isinstance(gid, str) or not gid:
                return
            if not self.db.get_group(gid):
                return
            if not self.db.is_group_member(gid, self.ident.alias):
                return
            if not self.db.is_group_member(gid, sender):
                return
            chat_id = self._group_chat_id(gid)
            mid = obj.get("msg_id") or header.get("msg_id") or str(uuid.uuid4())
            text = obj.get("text", "")
            reply_to = obj.get("reply_to")
            ts = int(header.get("ts", now_ts()))
            self.db.ensure_chat(chat_id)
            self.db.add_message(str(mid), chat_id, sender, ts, str(text), reply_to, None, "ok")
            self.request_cloud_publish()
            if self.ui.active_chat != chat_id:
                self.db.inc_chat_unread(chat_id, 1)
            self.ui.request_refresh_sidebar()
            if self.ui.active_chat == chat_id:
                self.ui.request_refresh_active_chat(immediate=True)
            return

        if obj.get("kind") == "group_file":
            gid = obj.get("group_id")
            if not isinstance(gid, str) or not gid:
                return
            if not self.db.get_group(gid):
                return
            if not self.db.is_group_member(gid, self.ident.alias):
                return
            if not self.db.is_group_member(gid, sender):
                return
            chat_id = self._group_chat_id(gid)
            mid = obj.get("msg_id") or header.get("msg_id") or str(uuid.uuid4())
            f = obj.get("file") or {}
            caption = obj.get("caption") or ""
            reply_to = obj.get("reply_to")
            ts = int(header.get("ts", now_ts()))
            self.db.ensure_chat(chat_id)
            try:
                self.db.add_file_offer(chat_id, sender, str(f.get("file_id")), f.get("name"), {"file": f, "caption": caption})
            except Exception:
                pass
            self.db.add_message(
                str(mid),
                chat_id,
                sender,
                ts,
                str(caption),
                reply_to,
                None,
                "ok",
                kind="file",
                meta=f,
            )
            self.request_cloud_publish()
            if self.ui.active_chat != chat_id:
                self.db.inc_chat_unread(chat_id, 1)
            self.ui.request_refresh_sidebar()
            if self.ui.active_chat == chat_id:
                self.ui.request_refresh_active_chat(immediate=True)
            return

        if obj.get("kind") == "text":
            mid = obj["msg_id"]
            text = obj.get("text", "")
            reply_to = obj.get("reply_to")
            ts = int(header.get("ts", now_ts()))
            self.db.ensure_chat(sender)
            self.db.add_message(mid, sender, sender, ts, text, reply_to, None, "ok")
            self.request_cloud_publish()
            if self.ui.active_chat != sender:
                self.db.inc_chat_unread(sender, 1)
            self.ui.request_refresh_sidebar()
            if self.ui.active_chat == sender:
                self.ui.request_refresh_active_chat(immediate=True)
            return

        if obj.get("kind") == "file":
            mid = obj.get("msg_id") or str(uuid.uuid4())
            f = obj.get("file") or {}
            caption = obj.get("caption") or ""
            reply_to = obj.get("reply_to")
            ts = int(header.get("ts", now_ts()))

            self.db.ensure_chat(sender)

            try:
                self.db.add_file_offer(sender, sender, str(f.get("file_id")), f.get("name"), {"file": f, "caption": caption})
            except Exception:
                pass

            self.db.add_message(
                mid,
                sender,
                sender,
                ts,
                str(caption),
                reply_to,
                None,
                "ok",
                kind="file",
                meta=f,
            )
            self.request_cloud_publish()
            if self.ui.active_chat != sender:
                self.db.inc_chat_unread(sender, 1)
            self.ui.request_refresh_sidebar()
            if self.ui.active_chat == sender:
                self.ui.request_refresh_active_chat(immediate=True)
            return

        if obj.get("kind") == "control":
            op = obj.get("op")
            mid = obj.get("msg_id")
            if op == "delete" and mid:

                msg_info = None
                try:
                    m = self.db.get_message(mid)
                    if m:
                        msg_info = {
                            "msg_id": mid,
                            "chat_id": sender,
                            "content": m.get("body", ""),
                            "sender": m.get("sender_alias", sender),
                            "ts": m.get("ts", 0),
                            "kind": m.get("kind", "text"),
                            "meta": m.get("meta", {}),
                        }
                        if m.get("kind") == "file":
                            meta = m.get("meta")
                            if isinstance(meta, dict):
                                fid = meta.get("file_id")
                                if isinstance(fid, str) and fid:
                                    asyncio.create_task(self.cloud_delete_file_if_known(file_id=fid))
                except Exception:
                    pass

                prevent_delete = False
                try:
                    if hasattr(self.ui, "on_remote_message_deleted") and msg_info:
                        prevent_delete = self.ui.on_remote_message_deleted(msg_info)
                except Exception:
                    pass

                if not prevent_delete:
                    self.db.delete_message(mid)
                else:

                    try:
                        self.db.update_message_meta(mid, {"_deleted": True, "_deleted_ts": now_ts()})
                    except Exception:
                        self.db.delete_message(mid)

                self.request_cloud_publish()
                if self.ui.active_chat == sender:
                    self.ui.request_refresh_active_chat(immediate=True)
            elif op == "edit" and mid:

                try:
                    if hasattr(self.ui, "on_remote_message_edited"):
                        old_msg = self.db.get_message(mid)
                        if old_msg:
                            edit_info = {
                                "msg_id": mid,
                                "chat_id": sender,
                                "old_content": old_msg.get("body", ""),
                                "new_content": obj.get("text", ""),
                                "sender": old_msg.get("sender_alias", sender),
                                "ts": old_msg.get("ts", 0),
                            }
                            self.ui.on_remote_message_edited(edit_info)
                except Exception:
                    pass

                self.db.update_message_body(mid, obj.get("text", ""), now_ts())
                self.request_cloud_publish()
                if self.ui.active_chat == sender:
                    self.ui.request_refresh_active_chat(immediate=True)
            try:
                self.ui.request_refresh_sidebar()
            except Exception:
                pass
            return

        if obj.get("kind") == "call":
            op = obj.get("op")
            call_id = obj.get("call_id")
            if not isinstance(op, str) or not isinstance(call_id, str) or not call_id:
                return
            try:
                if op == "offer" and hasattr(self.ui, "on_call_offer"):
                    res = self.ui.on_call_offer(sender, call_id, obj)
                    if asyncio.iscoroutine(res):
                        await res
                elif op == "answer" and hasattr(self.ui, "on_call_answer"):
                    res = self.ui.on_call_answer(sender, call_id, obj)
                    if asyncio.iscoroutine(res):
                        await res
                elif op == "hangup" and hasattr(self.ui, "on_call_hangup"):
                    res = self.ui.on_call_hangup(sender, call_id, obj)
                    if asyncio.iscoroutine(res):
                        await res
            except Exception:
                pass
            return

    async def run(self):
        connect_args = {
            "max_size": 32 * 1024 * 1024,

            "open_timeout": 20,

            "ping_interval": 20,
            "ping_timeout": 120,
        }

        mode = self.proxy_config.get("mode")

        if mode in ("proxy", "tor"):
            try:
                import socks

                u = urlparse(self.server_url)
                if u.scheme not in ("ws", "wss"):
                    raise RuntimeError(f"Unsupported server URL scheme for WebSocket: {u.scheme}")

                proxy_host = self.proxy_config.get("proxy_host", "127.0.0.1")
                proxy_port = int(self.proxy_config.get("proxy_port", self.proxy_config.get("tor_socks_port", 9050)))

                self._force_global_socks(proxy_host, proxy_port)

                self.ui.system_log(f"Connecting via Proxy (SOCKS5 {proxy_host}:{proxy_port})...")
                if hasattr(self.ui, "update_loading_status"):
                    self.ui.update_loading_status(f"Connecting via Proxy ({proxy_host}:{proxy_port})...")

                host = u.hostname
                if not host:
                    raise RuntimeError("Invalid server URL (missing hostname)")
                port = u.port or (443 if u.scheme == "wss" else 80)

                if hasattr(self.ui, "update_loading_status"):
                    self.ui.update_loading_status("Connecting to Server via Proxy...")

                loop = asyncio.get_running_loop()

                def create_proxy_socket():
                    s = socks.socksocket()

                    s.set_proxy(socks.SOCKS5, proxy_host, proxy_port, rdns=True)
                    s.settimeout(20)
                    s.connect((host, port))
                    return s

                connect_args["sock"] = await loop.run_in_executor(None, create_proxy_socket)
            except ImportError as e:
                self.ui.system_log("Error: PySocks not installed. Cannot use Proxy.")
                raise RuntimeError("PySocks not installed. Install 'PySocks' or disable proxy.") from e
            except Exception as e:
                self.ui.system_log(f"Proxy connection setup failed: {e}")
                if hasattr(self.ui, "update_loading_status"):
                    self.ui.update_loading_status(f"Connection Failed: {e}")

                raise RuntimeError(f"Proxy Error: {e}") from e

        try:
            if hasattr(self.ui, "update_loading_status"):
                self.ui.update_loading_status("Handshaking with Server...")
            async with websockets.connect(self.server_url, **connect_args) as ws:
                self.ws = ws
                await self._register_and_auth()
                if hasattr(self.ui, "on_connected"):
                    await self.ui.on_connected()

                async for raw in ws:
                    try:
                        msg = json.loads(raw)
                    except Exception:
                        continue
                    await self.handle_incoming(msg)
        except Exception as e:

            raise e
        finally:
            self._restore_global_socks()

    async def get_public_ip(self):
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self._get_public_ip_sync)

    def _get_public_ip_sync(self):
        import urllib.request
        import socket

        url = "https://api.ipify.org"

        try:

            mode = self.proxy_config.get("mode")
            if mode == "proxy" or mode == "tor":
                import socks
                proxy_host = self.proxy_config.get("proxy_host", "127.0.0.1")
                proxy_port = int(self.proxy_config.get("proxy_port", self.proxy_config.get("tor_socks_port", 9050)))

                s = socks.socksocket()
                s.set_proxy(socks.SOCKS5, proxy_host, proxy_port, rdns=True)
                s.settimeout(10)
                s.connect(("api.ipify.org", 80))
                request = b"GET / HTTP/1.1\r\nHost: api.ipify.org\r\nConnection: close\r\n\r\n"
                s.sendall(request)
                response = b""
                while True:
                    data = s.recv(4096)
                    if not data: break
                    response += data

                body = response.split(b"\r\n\r\n")[-1].decode("utf-8")
                return body.strip()

            else:

                with urllib.request.urlopen(url, timeout=5) as response:
                    return response.read().decode("utf-8").strip()
        except Exception as e:
            return f"Error: {e}"
