import argparse
import asyncio
import base64
import configparser
import hashlib
import json
import os
import time
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import pathlib
import websockets
from nacl.signing import SigningKey
from config import APP_DIR


NODE_SIGN_KEY_PATH = APP_DIR / "node_sign_ed25519.key"

def b64e(b: bytes) -> str:
    return base64.b64encode(b).decode("ascii")

def b64d(s: str) -> bytes:
    return base64.b64decode(s.encode("ascii"))

def load_or_create_node_signing_key(path: Path = NODE_SIGN_KEY_PATH):
    if SigningKey is None:
        raise RuntimeError(
            "PyNaCl is required for node AUTH but isn't available in this Python environment. "
            "Install it for /usr/bin/python3 (e.g. `python3 -m pip install --user PyNaCl` or distro package `python3-nacl`) "
            "or update your systemd service to use the venv that has PyNaCl."
        )

    try:
        if path.exists():
            seed_b64 = path.read_text(encoding="utf-8").strip()
            seed = b64d(seed_b64)
            if len(seed) != 32:
                raise ValueError("bad seed length")
            return SigningKey(seed)
    except Exception:
        try:
            if path.exists():
                bad = path.with_suffix(path.suffix + f".bad.{now_ts()}")
                path.rename(bad)
        except Exception:
            pass

    sk = SigningKey.generate()
    try:
        seed = sk.encode()
        path.write_text(b64e(seed) + "\n", encoding="utf-8")
        try:
            os.chmod(path, 0o600)
        except Exception:
            pass
    except Exception:
        pass
    return sk

def canonical_json(obj) -> str:
    return json.dumps(obj, separators=(",", ":"), sort_keys=True, ensure_ascii=False)

def now_ts() -> int:
    return int(time.time())

def compute_file_id_from_container(blob: bytes) -> Optional[str]:

    try:
        h = hashlib.sha256()
        off = 0
        n = len(blob)
        while off + 4 <= n:
            ln = int.from_bytes(blob[off : off + 4], "big")
            off += 4
            if ln <= 0 or ln > 64 * 1024 * 1024:
                return None
            if off + ln > n:
                return None
            rec = blob[off : off + ln]
            off += ln
            h.update(rec)

        if off != n:
            return None
        return h.hexdigest()
    except Exception:
        return None

def _parse_bool(v, default: bool = False) -> bool:
    if isinstance(v, bool):
        return v
    if v is None:
        return default
    try:
        s = str(v).strip().lower()
    except Exception:
        return default
    if s in ("1", "true", "yes", "y", "on"):
        return True
    if s in ("0", "false", "no", "n", "off"):
        return False
    return default

def load_conf(path: Path, default: dict) -> dict:
    try:
        text = path.read_text(encoding="utf-8")
    except Exception:
        return default

    def _strip_wrapping_quotes(v: str) -> str:
        if not isinstance(v, str):
            return str(v)
        s = v.strip()
        if len(s) >= 2 and ((s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'"))):
            return s[1:-1]
        return s

    try:
        parser = configparser.ConfigParser(
            interpolation=None,
            delimiters=("=",),
            comment_prefixes=("#", ";"),
            inline_comment_prefixes=("#", ";"),
            strict=False,
            default_section="DEFAULT",
        )

        first_content_line = None
        for raw in text.splitlines():
            s = raw.strip()
            if not s:
                continue
            if s.startswith(("#", ";")):
                continue
            first_content_line = s
            break

        if first_content_line and not first_content_line.startswith("["):
            parse_text = "[DEFAULT]\n" + text
        else:
            parse_text = text

        parser.read_string(parse_text)

        out: dict = {}

        try:
            for k, v in parser.defaults().items():
                ks = str(k).strip()
                if not ks:
                    continue
                out[ks] = _strip_wrapping_quotes(str(v))
        except Exception:
            pass

        for sec in parser.sections():
            sec_name = str(sec).strip().lower()
            sec_items: dict[str, str] = {}
            try:
                for k, v in parser.items(sec, raw=True):
                    ks = str(k).strip()
                    if not ks:
                        continue

                    if ks in parser.defaults():
                        continue
                    sec_items[ks] = _strip_wrapping_quotes(str(v))
            except Exception:
                continue

            if sec_name in ("network", "net"):
                out["network"] = sec_items
            else:
                out[sec_name] = sec_items

        return out if isinstance(out, dict) else default
    except Exception:
        return default

def load_any_config(path: Path, default: dict) -> dict:
    if not path or not isinstance(path, Path):
        return default
    if not path.exists():
        return default
    return load_conf(path, default)

NODE_CONF_DOCS = """# Antch Node Configuration (node.conf)
#
# Format:
# - key = value
# - Comments: '#' or ';'
# - Sections: [network]
#
# This file configures the node (seed). Use [network] for connection settings.
#
# ----------------------------
# Keys
# ----------------------------
# node_id
#   Node identity string used when connecting to server.
#   Example: node_id = MY-NODE-1
#
# data_dir
#   Directory where blobs/metas are stored.
#   Example: data_dir = ~/.antch/node_store
#
# max_storage_mb
#   Global storage budget (MB). Policy controlled by on_storage_exceed.
#   Example: max_storage_mb = 102400
#
# max_blob_mb
#   Per-blob safety limit (MB) for any kind.
#
# min_file_blob_mb / max_file_blob_mb
#   For kind=file only: minimum/maximum size per file blob (MB).
#
# max_user_file_storage_mb
#   Per-owner total quota for file blobs (MB). 0 disables.
#
# on_user_quota_exceed
#   When per-owner quota would be exceeded: reject | evict_oldest
#
# on_storage_exceed
#   When global storage would be exceeded: reject | evict_oldest
#
# connect_timeout_sec
#   WebSocket connect timeout (seconds).
#
# seed_chat_owners
#   Restrict db snapshot seeding to a comma-separated list. Empty means ALL.
#   Example: seed_chat_owners = alice,bob
#
# seed_files
#   Enable/disable storing kind=file blobs on disk. If false, the node won't store files.
#   Example: seed_files = false
#
# [network] section
# server_url
#   Server WebSocket URL (ws:// or wss://).
# mode
#   direct | proxy (SOCKS5)
# proxy_host / proxy_port / proxy_rdns
#   SOCKS5 settings. proxy_rdns is true/false.
"""

def default_node_conf_text() -> str:
    cfg = default_node_config()
    net = cfg.get("network") if isinstance(cfg.get("network"), dict) else {}

    def _b(v: bool) -> str:
        return "true" if bool(v) else "false"

    return (
        NODE_CONF_DOCS
        + "\n"
        + f"node_id = {cfg.get('node_id')}\n"
        + f"node_description = {cfg.get('node_description', '')}\n"
        + f"data_dir = {cfg.get('data_dir')}\n"
        + f"max_storage_mb = {cfg.get('max_storage_mb')}\n"
        + f"max_blob_mb = {cfg.get('max_blob_mb')}\n"
        + "\n"
        + "# File seeding limits (kind=file)\n"
        + f"seed_files = {str(cfg.get('seed_files', False)).lower()}\n"
        + f"min_file_blob_mb = {cfg.get('min_file_blob_mb')}\n"
        + f"max_file_blob_mb = {cfg.get('max_file_blob_mb')}\n"
        + f"max_user_file_storage_mb = {cfg.get('max_user_file_storage_mb')}\n"
        + f"on_user_quota_exceed = {cfg.get('on_user_quota_exceed')}\n"
        + f"on_storage_exceed = {cfg.get('on_storage_exceed')}\n"
        + "\n"
        + f"connect_timeout_sec = {cfg.get('connect_timeout_sec')}\n"
        + "# seed_chat_owners = alice,bob   # empty means ALL\n"
        + "\n"
        + "[network]\n"
        + f"server_url = {net.get('server_url')}\n"
        + f"mode = {net.get('mode')}\n"
        + f"proxy_host = {net.get('proxy_host')}\n"
        + f"proxy_port = {net.get('proxy_port')}\n"
        + f"proxy_rdns = {_b(net.get('proxy_rdns', True))}\n"
    )

def default_node_config() -> dict:
    return {
        "node_id": f"node-{os.uname().nodename}",
        "node_description": "",
        "network": {
            "server_url": "wss://antch.lama2923.com",

            "mode": "direct",
            "proxy_host": "127.0.0.1",
            "proxy_port": 9050,
            "proxy_rdns": True,
        },
        "data_dir": str(APP_DIR / "node_store"),
        "seed_files": True,
        "max_storage_mb": 102400,

        "max_blob_mb": 64,

        "min_file_blob_mb": 0,
        "max_file_blob_mb": 64,

        "max_user_file_storage_mb": 0,

        "on_user_quota_exceed": "reject",

        "on_storage_exceed": "evict_oldest",
        "connect_timeout_sec": 10,
    }

def ensure_node_config(node_config_path: Path) -> Path:
    try:
        node_config_path.parent.mkdir(parents=True, exist_ok=True)
    except Exception:
        pass

    if node_config_path.exists():
        return node_config_path

    fallback_conf = APP_DIR / "node.conf"
    if fallback_conf.exists():
        return fallback_conf

    conf_text = default_node_conf_text()
    try:
        node_config_path.write_text(conf_text + ("\n" if not conf_text.endswith("\n") else ""), encoding="utf-8")
    except Exception:
        return node_config_path

    example_path = APP_DIR / "node_config.example.conf"
    try:
        if not example_path.exists():
            example_path.write_text(conf_text + ("\n" if not conf_text.endswith("\n") else ""), encoding="utf-8")
    except Exception:
        pass

    print(f"Created node config: {node_config_path}")
    print(f"Example config: {example_path}")
    return node_config_path

@dataclass
class NodeConfig:
    server_url: str
    node_id: str
    node_description: str
    data_dir: Path
    max_storage_mb: int
    max_blob_mb: int
    min_file_blob_mb: int
    max_file_blob_mb: int
    max_user_file_storage_mb: int
    on_user_quota_exceed: str
    on_storage_exceed: str
    connect_timeout_sec: int
    proxy_mode: str
    proxy_host: str
    proxy_port: int
    proxy_rdns: bool
    seed_chat_owners: list[str]
    seed_files: bool

    @staticmethod
    def from_file(node_config_path: Path) -> "NodeConfig":
        node_cfg = load_any_config(node_config_path, {})
        node_net = node_cfg.get("network") if isinstance(node_cfg.get("network"), dict) else {}

        server_url = str(node_net.get("server_url") or node_cfg.get("server_url") or "ws://127.0.0.1:8765")

        node_id = str(node_cfg.get("node_id") or f"node-{os.uname().nodename}")

        node_description = ""
        try:
            node_description = str(node_cfg.get("node_description") or "").strip()
        except Exception:
            node_description = ""
        if len(node_description) > 200:
            node_description = node_description[:200]

        raw_data_dir = node_cfg.get("data_dir") or str(APP_DIR / "node_store")
        if isinstance(raw_data_dir, str):
            raw_data_dir = os.path.expanduser(raw_data_dir)
        data_dir = Path(raw_data_dir)
        max_storage_mb = int(node_cfg.get("max_storage_mb") or 102400)
        max_blob_mb = int(node_cfg.get("max_blob_mb") or 64)

        def _int_or(default: int, v) -> int:
            try:
                return int(v)
            except Exception:
                return int(default)

        min_file_blob_mb = _int_or(0, node_cfg.get("min_file_blob_mb"))
        max_file_blob_mb = _int_or(max_blob_mb, node_cfg.get("max_file_blob_mb"))
        if max_file_blob_mb <= 0:
            max_file_blob_mb = max_blob_mb
        if min_file_blob_mb < 0:
            min_file_blob_mb = 0
        if max_file_blob_mb < min_file_blob_mb:
            max_file_blob_mb = min_file_blob_mb

        max_user_file_storage_mb = _int_or(0, node_cfg.get("max_user_file_storage_mb"))
        if max_user_file_storage_mb < 0:
            max_user_file_storage_mb = 0

        def _policy(v, default: str) -> str:
            try:
                s = str(v or "").strip().lower()
            except Exception:
                s = ""
            if s in ("reject", "evict_oldest"):
                return s
            return default

        on_user_quota_exceed = _policy(node_cfg.get("on_user_quota_exceed"), "reject")
        on_storage_exceed = _policy(node_cfg.get("on_storage_exceed"), "evict_oldest")

        connect_timeout_sec = int(node_cfg.get("connect_timeout_sec") or 10)

        proxy_mode = str(node_net.get("mode") or node_cfg.get("proxy_mode") or "direct").strip().lower()
        proxy_host = str(node_net.get("proxy_host") or node_cfg.get("proxy_host") or "127.0.0.1").strip()
        try:
            proxy_port = int(node_net.get("proxy_port") or node_cfg.get("proxy_port") or 9050)
        except Exception:
            proxy_port = 9050
        proxy_rdns = _parse_bool(node_net.get("proxy_rdns"), True)

        seed_chat_owners: list[str] = []
        try:
            raw = node_cfg.get("seed_chat_owners")
            if isinstance(raw, list):
                seed_chat_owners = [str(x).strip() for x in raw if isinstance(x, str) and str(x).strip()]
            elif isinstance(raw, str):
                seed_chat_owners = [s.strip() for s in raw.split(",") if s.strip()]
        except Exception:
            seed_chat_owners = []

        seed_files = True
        try:
            v = node_cfg.get("seed_files")
            if isinstance(v, bool):
                seed_files = bool(v)
            elif isinstance(v, (int, float)):
                seed_files = bool(int(v))
            elif isinstance(v, str):
                seed_files = v.strip().lower() in ("1", "true", "yes", "y", "on")
        except Exception:
            seed_files = True

        return NodeConfig(
            server_url=server_url,
            node_id=node_id,
            node_description=node_description,
            data_dir=data_dir,
            max_storage_mb=max_storage_mb,
            max_blob_mb=max_blob_mb,
            min_file_blob_mb=min_file_blob_mb,
            max_file_blob_mb=max_file_blob_mb,
            max_user_file_storage_mb=max_user_file_storage_mb,
            on_user_quota_exceed=on_user_quota_exceed,
            on_storage_exceed=on_storage_exceed,
            connect_timeout_sec=connect_timeout_sec,
            proxy_mode=proxy_mode,
            proxy_host=proxy_host,
            proxy_port=proxy_port,
            proxy_rdns=proxy_rdns,
            seed_chat_owners=seed_chat_owners,
            seed_files=seed_files,
        )

class BlobStore:
    def __init__(self, root: Path, max_storage_mb: int):
        self.root = root
        self.blobs_dir = root / "blobs"
        self.meta_dir = root / "meta"
        self.max_bytes = int(max_storage_mb) * 1024 * 1024
        self.blobs_dir.mkdir(parents=True, exist_ok=True)
        self.meta_dir.mkdir(parents=True, exist_ok=True)

    def _blob_path(self, sha256_hex: str) -> Path:
        return self.blobs_dir / f"{sha256_hex}.bin"

    def _meta_path(self, sha256_hex: str) -> Path:
        return self.meta_dir / f"{sha256_hex}.json"

    def _owner_key(self, owner: str) -> str:
        return hashlib.sha256(owner.encode("utf-8")).hexdigest()

    def _latest_db_path(self, owner: str) -> Path:
        return self.meta_dir / f"latest_db_{self._owner_key(owner)}.json"

    def get_latest_db(self, owner: str) -> Optional[dict]:
        p = self._latest_db_path(owner)
        if not p.exists():
            return None
        try:
            v = json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            return None
        if not isinstance(v, dict):
            return None
        return v

    def set_latest_db(self, owner: str, blob_sha256: str, created_at: int) -> None:
        p = self._latest_db_path(owner)
        data = {
            "owner": str(owner),
            "kind": "db",
            "blob_sha256": str(blob_sha256),
            "created_at": int(created_at),
            "updated_at": now_ts(),
        }
        try:
            p.write_text(canonical_json(data), encoding="utf-8")
        except Exception:
            pass

    def delete(self, sha256_hex: str) -> None:
        try:
            self._blob_path(sha256_hex).unlink(missing_ok=True)
        except Exception:
            pass
        try:
            self._meta_path(sha256_hex).unlink(missing_ok=True)
        except Exception:
            pass

    def clear_latest_db_if_matches(self, owner: str, blob_sha256: str) -> None:
        try:
            st = self.get_latest_db(owner)
            if not isinstance(st, dict):
                return
            if st.get("blob_sha256") != blob_sha256:
                return
            p = self._latest_db_path(owner)
            p.unlink(missing_ok=True)
        except Exception:
            pass

    def has(self, sha256_hex: str) -> bool:
        return self._blob_path(sha256_hex).exists()

    def put(self, sha256_hex: str, blob: bytes, meta: dict):
        p = self._blob_path(sha256_hex)
        if p.exists():
            try:
                self._meta_path(sha256_hex).write_text(canonical_json(meta), encoding="utf-8")
            except Exception:
                pass
            return
        tmp = p.with_suffix(".tmp")
        tmp.write_bytes(blob)
        os.replace(tmp, p)
        try:
            self._meta_path(sha256_hex).write_text(canonical_json(meta), encoding="utf-8")
        except Exception:
            pass

    def get(self, sha256_hex: str) -> Optional[bytes]:
        p = self._blob_path(sha256_hex)
        if not p.exists():
            return None
        try:
            return p.read_bytes()
        except Exception:
            return None

    def get_meta(self, sha256_hex: str) -> Optional[dict]:
        p = self._meta_path(sha256_hex)
        if not p.exists():
            return None
        try:
            v = json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            return None
        if not isinstance(v, dict):
            return None
        return v

    def ensure_file_id_for_sha(self, sha256_hex: str) -> Optional[str]:
        meta = self.get_meta(sha256_hex) or {}
        if not isinstance(meta, dict):
            meta = {}
        fid = meta.get("file_id")
        if isinstance(fid, str) and len(fid) == 64:
            return fid
        blob = self.get(sha256_hex)
        if blob is None:
            return None
        derived = compute_file_id_from_container(blob)
        if not isinstance(derived, str) or len(derived) != 64:
            return None

        meta["file_id"] = derived
        meta["file_id_source"] = "derived"
        try:
            self._meta_path(sha256_hex).write_text(canonical_json(meta), encoding="utf-8")
        except Exception:
            pass
        return derived

    def total_bytes(self) -> int:
        total = 0
        try:
            for f in self.blobs_dir.glob("*.bin"):
                try:
                    total += f.stat().st_size
                except Exception:
                    pass
        except Exception:
            return 0
        return total

    def cleanup_if_needed(self):
        total = self.total_bytes()
        if total <= self.max_bytes:
            return

        files = []
        for f in self.blobs_dir.glob("*.bin"):
            try:
                files.append((f.stat().st_mtime, f))
            except Exception:
                pass
        files.sort(key=lambda x: x[0])

        for _, f in files:
            if total <= self.max_bytes:
                break
            try:
                sz = f.stat().st_size
            except Exception:
                sz = 0
            try:
                f.unlink(missing_ok=True)
            except Exception:
                pass
            meta = self.meta_dir / (f.stem + ".json")
            try:
                meta.unlink(missing_ok=True)
            except Exception:
                pass
            total -= sz

    def _iter_meta_rows(self):
        try:
            for mp in self.meta_dir.glob("*.json"):
                stem = mp.stem
                if len(stem) != 64:
                    continue
                try:
                    meta = json.loads(mp.read_text(encoding="utf-8"))
                except Exception:
                    continue
                if not isinstance(meta, dict):
                    continue
                yield stem, meta
        except Exception:
            return

    def find_file_sha_by_file_id(self, file_id: str) -> Optional[str]:
        if not isinstance(file_id, str) or not file_id or len(file_id) != 64:
            return None
        best_sha = None
        best_ts = -1
        for sha, meta in self._iter_meta_rows():
            try:
                if meta.get("kind") != "file":
                    continue
                fid = meta.get("file_id")
                if fid != file_id:
                    if not fid:
                        fid2 = self.ensure_file_id_for_sha(sha)
                        if fid2 != file_id:
                            continue
                    else:
                        continue
                ts = int(meta.get("stored_at") or 0)
            except Exception:
                continue
            if ts > best_ts:
                best_ts = ts
                best_sha = sha
        return best_sha

    def total_bytes_for_owner_kind(self, owner: str, kind: str) -> int:
        if not isinstance(owner, str) or not owner or kind not in ("db", "file"):
            return 0
        total = 0
        for _, meta in self._iter_meta_rows():
            try:
                if meta.get("owner") != owner or meta.get("kind") != kind:
                    continue
                total += int(meta.get("bytes_total") or 0)
            except Exception:
                continue
        return int(total)

    def evict_oldest_for_owner_kind(self, owner: str, kind: str, bytes_to_free: int) -> int:
        if not isinstance(owner, str) or not owner or kind not in ("db", "file"):
            return 0
        try:
            need = int(bytes_to_free)
        except Exception:
            need = 0
        if need <= 0:
            return 0

        cands: list[tuple[int, str, int]] = []
        for sha, meta in self._iter_meta_rows():
            try:
                if meta.get("owner") != owner or meta.get("kind") != kind:
                    continue
                stored_at = int(meta.get("stored_at") or 0)
                sz = int(meta.get("bytes_total") or 0)
                cands.append((stored_at, sha, sz))
            except Exception:
                continue
        cands.sort(key=lambda x: x[0])

        freed = 0
        for _, sha, sz in cands:
            if freed >= need:
                break
            self.delete(sha)
            try:
                freed += int(sz)
            except Exception:
                pass
        return int(freed)

class NodeClient:
    def __init__(self, cfg: NodeConfig, store: BlobStore):
        self.cfg = cfg
        self.store = store
        self._max_blob_bytes = max(1, int(cfg.max_blob_mb)) * 1024 * 1024
        self._min_file_blob_bytes = max(0, int(cfg.min_file_blob_mb)) * 1024 * 1024
        self._max_file_blob_bytes = max(1, int(cfg.max_file_blob_mb)) * 1024 * 1024
        self._debug = str(os.getenv("ANTCH_NODE_DEBUG") or "").strip().lower() in ("1", "true", "yes", "y", "on")
        self._sign_sk = load_or_create_node_signing_key()
        self._sign_pub_b64 = b64e(bytes(self._sign_sk.verify_key))
        self._chunked_uploads: dict[str, dict] = {}

    def _log(self, msg: str) -> None:
        if not self._debug:
            return
        try:
            print(f"[node][debug] {msg}", flush=True)
        except Exception:
            pass

    async def run_forever(self):
        backoff = 1.0
        while True:
            try:
                await self._run_once()
                backoff = 1.0
            except Exception as e:
                try:
                    print(f"[node] connection error: {e}", flush=True)
                except Exception:
                    pass
                await asyncio.sleep(backoff)
                backoff = min(backoff * 2.0, 30.0)

    async def _run_once(self):
        try:
            print(f"[node] starting: node_id={self.cfg.node_id} server={self.cfg.server_url} mode={self.cfg.proxy_mode}", flush=True)
            print(f"[node] store_dir={self.cfg.data_dir} max_storage_mb={self.cfg.max_storage_mb} max_blob_mb={self.cfg.max_blob_mb}", flush=True)
            print(
                f"[node] file_limits=min={self.cfg.min_file_blob_mb}MB max={self.cfg.max_file_blob_mb}MB user_quota={self.cfg.max_user_file_storage_mb}MB on_user_quota_exceed={self.cfg.on_user_quota_exceed} on_storage_exceed={self.cfg.on_storage_exceed}",
                flush=True,
            )
            print(f"[node] seed_files={bool(self.cfg.seed_files)}", flush=True)
            if self.cfg.seed_chat_owners:
                print(f"[node] seed_chat_owners={self.cfg.seed_chat_owners}", flush=True)
            else:
                print("[node] seed_chat_owners=ALL", flush=True)
        except Exception:
            pass

        connect_args = {
            "ping_interval": 20,
            "ping_timeout": 120,
            "open_timeout": self.cfg.connect_timeout_sec,
        }

        try:
            connect_args["max_size"] = 256 * 1024 * 1024
        except Exception:
            pass

        try:
            if not str(self.cfg.server_url).startswith(("ws://", "wss://")):
                raise RuntimeError(f"server_url must be ws:// or wss:// (got {self.cfg.server_url!r})")
        except Exception as e:
            raise

        if self.cfg.proxy_mode in ("proxy", "tor", "socks5"):
            try:
                import socks
            except Exception as e:
                raise RuntimeError("PySocks not installed but proxy mode is enabled") from e

            u = urlparse(self.cfg.server_url)
            host = u.hostname
            port = u.port or (443 if u.scheme == "wss" else 80)
            if host:
                loop = asyncio.get_running_loop()

                proxy_port = int(self.cfg.proxy_port)
                if proxy_port <= 0 or proxy_port > 65535:
                    raise RuntimeError(f"Invalid proxy_port={proxy_port}; expected 1..65535")

                def create_proxy_socket():
                    s = socks.socksocket()
                    s.set_proxy(socks.SOCKS5, self.cfg.proxy_host, int(proxy_port), rdns=bool(self.cfg.proxy_rdns))
                    s.settimeout(int(self.cfg.connect_timeout_sec))
                    s.connect((host, int(port)))
                    return s

                connect_args["sock"] = await loop.run_in_executor(None, create_proxy_socket)

        async with websockets.connect(self.cfg.server_url, **connect_args) as ws:
            await ws.send(
                canonical_json(
                    {
                        "type": "node_hello",
                        "node_id": self.cfg.node_id,
                        "description": self.cfg.node_description,
                        "sign_pub": self._sign_pub_b64,
                        "caps": {
                            "store": True,
                            "fetch": True,
                            "max_storage_mb": int(self.cfg.max_storage_mb),
                            "max_blob_mb": int(self.cfg.max_blob_mb),
                        },
                    }
                )
            )

            try:
                raw = await ws.recv()
            except Exception:
                return
            try:
                msg = json.loads(raw)
            except Exception:
                return

            if msg.get("type") == "node_challenge":
                nonce = msg.get("nonce")
                srv_ts = msg.get("ts")
                if not isinstance(nonce, str) or not nonce:
                    return
                try:
                    srv_ts_i = int(srv_ts)
                except Exception:
                    return
                payload = {"node_id": str(self.cfg.node_id), "nonce": str(nonce), "ts": int(srv_ts_i)}
                to_sign = ("node_auth|" + canonical_json(payload)).encode("utf-8")
                try:
                    sig = self._sign_sk.sign(to_sign).signature
                except Exception:
                    return
                await ws.send(
                    canonical_json(
                        {
                            "type": "node_auth",
                            "node_id": str(self.cfg.node_id),
                            "nonce": str(nonce),
                            "ts": int(srv_ts_i),
                            "sign_pub": self._sign_pub_b64,
                            "sig": b64e(sig),
                        }
                    )
                )

                try:
                    raw2 = await ws.recv()
                    msg2 = json.loads(raw2)
                except Exception:
                    return
                if msg2.get("type") != "node_ok":
                    return
            elif msg.get("type") != "node_ok":
                return

            try:
                print("[node] connected (node_ok)", flush=True)
            except Exception:
                pass

            async def _alive_loop():
                while True:
                    try:
                        await asyncio.sleep(15)
                    except Exception:
                        return
                    try:
                        await ws.send(canonical_json({"type": "node_alive", "ts": now_ts()}))
                    except Exception:
                        return

            asyncio.create_task(_alive_loop())

            while True:
                raw = await ws.recv()
                try:
                    msg = json.loads(raw)
                except Exception:
                    continue

                t = msg.get("type")
                if t == "node_store":
                    await self._handle_store(ws, msg)
                elif t == "node_store_begin":
                    await self._handle_store_begin(ws, msg)
                elif t == "node_store_chunk":
                    await self._handle_store_chunk(ws, msg)
                elif t == "node_store_end":
                    await self._handle_store_end(ws, msg)
                elif t == "node_fetch":
                    await self._handle_fetch(ws, msg)
                elif t == "node_fetch_file":
                    await self._handle_fetch_file(ws, msg)
                elif t == "node_delete":
                    await self._handle_delete(msg)
                elif t == "node_db_candidates_req":
                    await self._handle_db_candidates(ws, msg)
                elif t == "node_db_has_newer_req":
                    await self._handle_db_has_newer(ws, msg)

    async def _handle_delete(self, msg: dict) -> None:
        blob_sha = msg.get("blob_sha256")
        kind = msg.get("kind")
        owner = msg.get("owner")
        if not isinstance(blob_sha, str) or not blob_sha:
            return
        if kind not in ("db", "file"):
            return

        if kind == "db" and isinstance(owner, str) and owner:
            self.store.clear_latest_db_if_matches(owner, blob_sha)

        self.store.delete(blob_sha)
        try:
            print(f"[node] deleted blob {blob_sha[:12]}.. kind={kind} owner={owner}", flush=True)
        except Exception:
            pass

    async def _handle_store(self, ws, msg: dict):
        blob_sha = msg.get("blob_sha256")
        ct = msg.get("ct")
        kind = msg.get("kind")
        owner = msg.get("owner")
        created_at = msg.get("created_at")
        sig = msg.get("sig")
        file_id = msg.get("file_id")
        if not isinstance(blob_sha, str) or not isinstance(ct, str):
            return

        if kind not in ("db", "file"):
            return

        if kind == "file" and not bool(getattr(self.cfg, "seed_files", True)):
            return

        if kind == "file":
            if str(self.cfg.on_storage_exceed).strip().lower() == "reject":
                try:
                    if (self.store.total_bytes() + int(msg.get("bytes_total") or 0)) > int(self.store.max_bytes):
                        return
                except Exception:
                    pass

        if kind == "db":
            if not isinstance(owner, str) or not owner.strip():
                return
            if self.cfg.seed_chat_owners and owner not in self.cfg.seed_chat_owners:
                return

        if len(ct) > int(self._max_blob_bytes * 1.5) + 16:
            return

        try:
            blob = b64d(ct)
        except Exception:
            return

        if len(blob) > self._max_blob_bytes:
            return

        if kind == "file":
            if self._min_file_blob_bytes and len(blob) < self._min_file_blob_bytes:
                return
            if self._max_file_blob_bytes and len(blob) > self._max_file_blob_bytes:
                return

            if str(self.cfg.on_storage_exceed).strip().lower() == "reject":
                if (self.store.total_bytes() + len(blob)) > int(self.store.max_bytes):
                    return

            if isinstance(owner, str) and owner and int(self.cfg.max_user_file_storage_mb or 0) > 0:
                limit_bytes = int(self.cfg.max_user_file_storage_mb) * 1024 * 1024
                cur = self.store.total_bytes_for_owner_kind(owner, "file")
                if (cur + len(blob)) > limit_bytes:
                    pol = str(self.cfg.on_user_quota_exceed).strip().lower()
                    if pol == "evict_oldest":
                        need = (cur + len(blob)) - limit_bytes
                        self.store.evict_oldest_for_owner_kind(owner, "file", need)
                        cur2 = self.store.total_bytes_for_owner_kind(owner, "file")
                        if (cur2 + len(blob)) > limit_bytes:
                            return
                    else:
                        return

        sha = hashlib.sha256(blob).hexdigest()
        if sha != blob_sha:
            return

        derived_file_id = None
        file_id_source = None
        if kind == "file":
            derived_file_id = compute_file_id_from_container(blob)
            if not isinstance(derived_file_id, str) or len(derived_file_id) != 64:
                return
            if isinstance(file_id, str) and file_id:
                if str(file_id) != derived_file_id:
                    return
                file_id_source = "provided"
            else:
                file_id = derived_file_id
                file_id_source = "derived"

        try:
            created_at_i = int(created_at) if isinstance(created_at, int) else int(created_at or now_ts())
        except Exception:
            created_at_i = now_ts()

        old_sha = None
        old_created = 0
        if kind == "db" and isinstance(owner, str) and owner:
            st = self.store.get_latest_db(owner)
            if isinstance(st, dict):
                try:
                    old_sha = st.get("blob_sha256")
                    old_created = int(st.get("created_at") or 0)
                except Exception:
                    old_sha = None
                    old_created = 0

            if old_sha and old_created and created_at_i <= old_created:
                return

        meta = {
            "owner": owner,
            "kind": kind,
            "created_at": created_at_i,
            "blob_sha256": blob_sha,
            "bytes_total": len(blob),
            "stored_at": now_ts(),
        }

        if kind == "file" and isinstance(file_id, str) and file_id:
            meta["file_id"] = str(file_id)
            if file_id_source:
                meta["file_id_source"] = str(file_id_source)

        if isinstance(sig, str) and sig:
            meta["sig_b64"] = sig

        self.store.put(blob_sha, blob, meta)

        if kind == "db" and isinstance(owner, str) and owner:
            self.store.set_latest_db(owner, blob_sha, created_at_i)
            if old_sha and isinstance(old_sha, str) and old_sha != blob_sha:
                self.store.delete(old_sha)

        if str(self.cfg.on_storage_exceed).strip().lower() == "evict_oldest":
            self.store.cleanup_if_needed()

        try:
            print(
                f"[node] stored blob {blob_sha[:12]}.. bytes={len(blob)} kind={meta.get('kind')} owner={meta.get('owner')}",
                flush=True,
            )
        except Exception:
            pass

    async def _handle_store_begin(self, ws, msg: dict) -> None:
        upload_id = msg.get("upload_id")
        if not isinstance(upload_id, str) or not upload_id:
            return
        self._chunked_uploads[upload_id] = {
            "owner": msg.get("owner"),
            "kind": msg.get("kind"),
            "created_at": msg.get("created_at"),
            "blob_sha256": msg.get("blob_sha256"),
            "file_id": msg.get("file_id"),
            "bytes_total": msg.get("bytes_total"),
            "nonce": msg.get("nonce"),
            "sig": msg.get("sig"),
            "owner_sign_pub": msg.get("owner_sign_pub"),
            "chunks": {},
        }

    async def _handle_store_chunk(self, ws, msg: dict) -> None:
        upload_id = msg.get("upload_id")
        if not isinstance(upload_id, str) or upload_id not in self._chunked_uploads:
            return
        st = self._chunked_uploads[upload_id]
        idx = msg.get("idx")
        data = msg.get("data")
        if not isinstance(idx, int) or not isinstance(data, str):
            return
        try:
            st["chunks"][idx] = b64d(data)
        except Exception:
            pass

    async def _handle_store_end(self, ws, msg: dict) -> None:
        upload_id = msg.get("upload_id")
        if not isinstance(upload_id, str) or upload_id not in self._chunked_uploads:
            return
        st = self._chunked_uploads.pop(upload_id)
        chunks = st.get("chunks", {})
        if not chunks:
            return
        buf = bytearray()
        for idx in sorted(chunks.keys()):
            buf.extend(chunks[idx])
        fake_msg = {
            "blob_sha256": st.get("blob_sha256"),
            "ct": b64e(bytes(buf)),
            "kind": st.get("kind"),
            "owner": st.get("owner"),
            "created_at": st.get("created_at"),
            "sig": st.get("sig"),
            "file_id": st.get("file_id"),
            "bytes_total": st.get("bytes_total"),
        }
        await self._handle_store(ws, fake_msg)

    def _list_db_items(self, owner: str, limit: int = 10, since_created_at: int | None = None) -> list[dict]:
        if not isinstance(owner, str) or not owner:
            return []
        try:
            lim = int(limit)
        except Exception:
            lim = 10
        if lim <= 0:
            lim = 10
        lim = min(lim, 50)

        since = None
        if since_created_at is not None:
            try:
                since = int(since_created_at)
            except Exception:
                since = None

        items: list[dict] = []
        for sha, meta in self.store._iter_meta_rows():
            if not isinstance(meta, dict):
                continue
            if meta.get("kind") != "db" or meta.get("owner") != owner:
                continue
            try:
                created_at = int(meta.get("created_at") or 0)
                bytes_total = int(meta.get("bytes_total") or 0)
            except Exception:
                continue
            if created_at <= 0 or bytes_total <= 0:
                continue
            if since is not None and created_at <= since:
                continue
            sig_b64 = meta.get("sig_b64")
            if not isinstance(sig_b64, str) or not sig_b64:
                continue
            items.append(
                {
                    "blob_sha256": sha,
                    "created_at": int(created_at),
                    "bytes_total": int(bytes_total),
                    "sig": sig_b64,
                }
            )

        items.sort(key=lambda x: int(x.get("created_at") or 0), reverse=True)
        return items[:lim]

    async def _handle_db_candidates(self, ws, msg: dict) -> None:
        req_id = msg.get("req_id")
        owner = msg.get("owner")
        limit = msg.get("limit")
        if not isinstance(req_id, str) or not isinstance(owner, str):
            return
        try:
            lim = int(limit)
        except Exception:
            lim = 10
        items = self._list_db_items(owner, limit=lim)
        try:
            await ws.send(canonical_json({"type": "node_db_candidates", "req_id": req_id, "owner": owner, "items": items}))
        except Exception:
            pass

    async def _handle_db_has_newer(self, ws, msg: dict) -> None:
        req_id = msg.get("req_id")
        owner = msg.get("owner")
        since = msg.get("since_created_at")
        if not isinstance(req_id, str) or not isinstance(owner, str):
            return
        try:
            since_i = int(since)
        except Exception:
            since_i = 0
        items = self._list_db_items(owner, limit=1, since_created_at=since_i)
        item = items[0] if items else None
        try:
            await ws.send(canonical_json({"type": "node_db_has_newer", "req_id": req_id, "owner": owner, "item": item}))
        except Exception:
            pass

    async def _handle_fetch(self, ws, msg: dict):
        req_id = msg.get("req_id")
        blob_sha = msg.get("blob_sha256")
        if not isinstance(req_id, str) or not isinstance(blob_sha, str):
            return

        self._log(f"fetch req_id={req_id} sha={blob_sha[:12]}..")

        meta = self.store.get_meta(blob_sha) or {}
        if not isinstance(meta, dict):
            meta = {}

        if meta.get("kind") == "file" and not meta.get("file_id"):
            fid2 = self.store.ensure_file_id_for_sha(blob_sha)
            if fid2:
                meta = self.store.get_meta(blob_sha) or meta
        extra = {
            "owner": meta.get("owner"),
            "kind": meta.get("kind"),
            "created_at": meta.get("created_at"),
            "file_id": meta.get("file_id"),
            "sig": (meta.get("sig_b64") if str(meta.get("file_id_source") or "") != "derived" else None),
        }

        blob = self.store.get(blob_sha)
        if blob is None:
            self._log(f"fetch miss req_id={req_id} sha={blob_sha[:12]}..")
            await ws.send(canonical_json({"type": "node_blob", "req_id": req_id, "ok": False, "error": "NOT_FOUND"}))
            return

        MAX_RAW_CHUNK = 20 * 1024 * 1024
        if len(blob) <= MAX_RAW_CHUNK:
            self._log(f"fetch hit(req_id={req_id}) sha={blob_sha[:12]}.. bytes={len(blob)}")
            out = {"type": "node_blob", "req_id": req_id, "ok": True, "blob_sha256": blob_sha, "ct": b64e(blob)}
            out.update(extra)
            await ws.send(canonical_json(out))
            return

        await ws.send(
            canonical_json(
                {
                    "type": "node_blob_begin",
                    "req_id": req_id,
                    "ok": True,
                    "blob_sha256": blob_sha,
                    "bytes_total": int(len(blob)),
                    "owner": extra.get("owner"),
                    "kind": extra.get("kind"),
                    "created_at": extra.get("created_at"),
                    "file_id": extra.get("file_id"),
                    "sig": extra.get("sig"),
                }
            )
        )

        idx = 0
        for off in range(0, len(blob), MAX_RAW_CHUNK):
            chunk = blob[off : off + MAX_RAW_CHUNK]
            if not chunk:
                continue
            await ws.send(canonical_json({"type": "node_blob_chunk", "req_id": req_id, "idx": int(idx), "data": b64e(chunk)}))
            idx += 1

        await ws.send(canonical_json({"type": "node_blob_end", "req_id": req_id}))

    async def _handle_fetch_file(self, ws, msg: dict):
        req_id = msg.get("req_id")
        file_id = msg.get("file_id")
        if not isinstance(req_id, str) or not isinstance(file_id, str):
            return
        if len(file_id) != 64:
            return

        self._log(f"fetch_file req_id={req_id} file_id={file_id[:12]}..")

        sha = self.store.find_file_sha_by_file_id(file_id)
        if not isinstance(sha, str) or not sha:
            self._log(f"fetch_file miss req_id={req_id} file_id={file_id[:12]}..")
            await ws.send(canonical_json({"type": "node_blob", "req_id": req_id, "ok": False, "error": "NOT_FOUND"}))
            return

        meta = self.store.get_meta(sha) or {}
        if not isinstance(meta, dict):
            meta = {}

        if meta.get("kind") == "file" and not meta.get("file_id"):
            fid2 = self.store.ensure_file_id_for_sha(sha)
            if fid2:
                meta = self.store.get_meta(sha) or meta
        extra = {
            "owner": meta.get("owner"),
            "kind": meta.get("kind"),
            "created_at": meta.get("created_at"),
            "file_id": meta.get("file_id"),
            "sig": (meta.get("sig_b64") if str(meta.get("file_id_source") or "") != "derived" else None),
        }

        blob = self.store.get(sha)
        if blob is None:
            self._log(f"fetch_file meta_hit_blob_miss req_id={req_id} file_id={file_id[:12]}.. sha={sha[:12]}..")
            await ws.send(canonical_json({"type": "node_blob", "req_id": req_id, "ok": False, "error": "NOT_FOUND"}))
            return

        MAX_RAW_CHUNK = 20 * 1024 * 1024
        if len(blob) <= MAX_RAW_CHUNK:
            self._log(f"fetch_file hit(req_id={req_id}) file_id={file_id[:12]}.. sha={sha[:12]}.. bytes={len(blob)}")
            out = {"type": "node_blob", "req_id": req_id, "ok": True, "blob_sha256": sha, "ct": b64e(blob)}
            out.update(extra)
            await ws.send(canonical_json(out))
            return

        await ws.send(
            canonical_json(
                {
                    "type": "node_blob_begin",
                    "req_id": req_id,
                    "ok": True,
                    "blob_sha256": sha,
                    "bytes_total": int(len(blob)),
                    "owner": extra.get("owner"),
                    "kind": extra.get("kind"),
                    "created_at": extra.get("created_at"),
                    "file_id": extra.get("file_id"),
                    "sig": extra.get("sig"),
                }
            )
        )

        idx = 0
        for off in range(0, len(blob), MAX_RAW_CHUNK):
            chunk = blob[off : off + MAX_RAW_CHUNK]
            if not chunk:
                continue
            await ws.send(canonical_json({"type": "node_blob_chunk", "req_id": req_id, "idx": int(idx), "data": b64e(chunk)}))
            idx += 1

        await ws.send(canonical_json({"type": "node_blob_end", "req_id": req_id}))

def main():
    try:
        sys.stdout.reconfigure(line_buffering=True)
        sys.stderr.reconfigure(line_buffering=True)
    except Exception:
        pass

    parser = argparse.ArgumentParser(description="Antch full-node (cloud blob store)")
    parser.add_argument("--node-config", default=str(APP_DIR / "node.conf"))
    args = parser.parse_args()

    node_cfg_path = ensure_node_config(Path(args.node_config))
    cfg = NodeConfig.from_file(node_cfg_path)
    store = BlobStore(cfg.data_dir, cfg.max_storage_mb)
    client = NodeClient(cfg, store)

    asyncio.run(client.run_forever())

if __name__ == "__main__":
    main()
