"""微信本地 DB 只读访问。

通过解密缓存避免锁定原 DB，再用 sqlite3 读取消息。所有方法为同步阻塞操作，
在 server.py 中通过 asyncio.to_thread() 包装调用，避免阻塞 event loop。

架构升级（bridge 1.3.0+）：
- 密钥统一由 KeyCache 管理（single source of truth），DbReader 不再自持密钥存储
- 密钥持久化、salt 映射、默认 key、已拒绝 salt 全部由 KeyCache 负责
- 解密后的明文 DB 缓存到 /config/woc-decrypted/<wxid>/，按 mtime 失效
- WAL 文件自动合并到明文缓存
- /api/status 不触发内存扫描，直接判断 keys 文件和缓存有效性
- 新增 clear_default_key 转发，env/api key 验证失败时仅清默认 key
"""

from __future__ import annotations

import atexit
import hashlib
import logging
import os
import re as _re
import sqlite3
import threading
from typing import Optional

from woc_bridge.db.decryptor import Decryptor
from woc_bridge.db.key_cache import KeyCache
from woc_bridge.models import BridgeError


# SQLCipher page 常量
PAGE_SIZE = 4096
SALT_SIZE = 16

logger = logging.getLogger("woc-bridge")

# 消息类型 → render_type 映射（规格 12.2 节）
_TYPE_TO_RENDER_TYPE: dict[int, str] = {
    1: "text",
    3: "image",
    34: "voice",
    43: "video",
    49: "file",
    10000: "system",
    10002: "system",
}


class DbReader:
    """微信本地 DB 只读访问器。

    核心变化：
    1. 以 <wxid>/db_storage 为根，通过相对路径访问各类 DB
    2. 密钥统一由 key_cache（KeyCache）管理，DbReader 仅查询不存储
    3. 解密缓存到 decrypted_dir（/config/woc-decrypted/<wxid>/）
    4. 查询时按需解密 + 自动 WAL 合并
    """

    def __init__(
        self,
        db_root: str = "/config",
        key_cache: Optional[KeyCache] = None,
        decrypted_dir: str = "/config/woc-decrypted",
    ) -> None:
        """初始化 DbReader。

        Args:
            db_root: 微信数据根目录，默认 /config
            key_cache: 密钥统一存储（KeyCache 实例）。为 None 时内部创建
                默认 KeyCache（/config/woc-keys.json），便于独立测试
            decrypted_dir: 解密缓存根目录
        """
        self.db_root = db_root
        self.decrypted_dir = decrypted_dir
        # 密钥统一来源：所有 key 查询/注入通过 key_cache
        self.key_cache: KeyCache = key_cache if key_cache is not None else KeyCache()

        self._lock = threading.Lock()

        # 解密缓存的内存索引：rel_path -> (db_mtime, wal_mtime, decrypted_path)
        self._decrypted_cache: dict[str, tuple[float, float, str]] = {}

        # 媒体路径缓存：msg_id -> path（或 None），避免每次 get_media 都 os.walk
        self._media_path_cache: dict[str, Optional[str]] = {}
        self._media_path_cache_max = 2000

        # 进程退出时清理未持久化的临时文件
        atexit.register(self._cleanup_temp_files)

    # ------------------------------------------------------------------
    # 当前账号 / db_storage 路径探测
    # ------------------------------------------------------------------
    def _get_current_wxid(self) -> Optional[str]:
        """通过 /config/xwechat_files/all_users/login/<wxid>/key_info.db mtime 判断当前登录账号。"""
        login_dir = os.path.join(self.db_root, "xwechat_files", "all_users", "login")
        if not os.path.isdir(login_dir):
            return None
        candidates = []
        for name in os.listdir(login_dir):
            key_info = os.path.join(login_dir, name, "key_info.db")
            if os.path.isfile(key_info):
                candidates.append((name, os.path.getmtime(key_info)))
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[1], reverse=True)
        return candidates[0][0]

    def _find_db_storage_dir(self) -> Optional[str]:
        """查找当前登录账号的 db_storage 目录。"""
        base = os.path.join(self.db_root, "xwechat_files")
        if not os.path.isdir(base):
            return None

        candidates = []
        for name in os.listdir(base):
            if name == "all_users":
                continue
            path = os.path.join(base, name, "db_storage")
            if os.path.isdir(path):
                # 确认目录下确实有 DB
                has_db = False
                for root, _, files in os.walk(path):
                    if any(f.endswith(".db") for f in files):
                        has_db = True
                        break
                if has_db:
                    candidates.append((path, os.path.getmtime(path)))

        if not candidates:
            return None

        # 优先匹配当前 wxid
        current_wxid = self._get_current_wxid()
        if current_wxid:
            for path, _ in candidates:
                if current_wxid in path:
                    return path

        # 否则选最近修改的
        candidates.sort(key=lambda x: x[1], reverse=True)
        return candidates[0][0]

    def _find_db_path(self) -> Optional[str]:
        """返回一个可直接用于验证/探测的 DB 文件路径（contact.db 或 message_0.db）。"""
        storage = self._find_db_storage_dir()
        if storage is None:
            return None
        for rel in ("contact/contact.db", "message/message_0.db"):
            path = os.path.join(storage, rel)
            if os.path.isfile(path):
                return path
        return None

    def _get_db_abs_path(self, rel_path: str) -> Optional[str]:
        """把相对路径（如 contact/contact.db）转成绝对路径。

        优先按拼接路径返回；若文件不存在则按 basename 在 storage 下递归查找，
        兼容 WeChat 4.x 不同版本 DB 实际存放路径可能与硬编码相对路径不一致的情况。
        """
        storage = self._find_db_storage_dir()
        if storage is None:
            return None
        abs_path = os.path.normpath(os.path.join(storage, rel_path))
        # 防止路径穿越
        if not abs_path.startswith(os.path.normpath(storage) + os.sep):
            return None
        # 快速路径：拼接路径存在则直接返回
        if os.path.isfile(abs_path):
            return abs_path
        # fallback：按 basename 在 storage 下递归查找
        target_name = os.path.basename(rel_path)
        for root, _, files in os.walk(storage):
            if target_name in files:
                return os.path.join(root, target_name)
        return None

    def _get_wxid_from_storage(self, storage_dir: Optional[str] = None) -> Optional[str]:
        """从 db_storage 路径推断 wxid。"""
        if storage_dir is None:
            storage_dir = self._find_db_storage_dir()
        if storage_dir is None:
            return None
        # 路径形如 /config/xwechat_files/wxid_xxx_1234/db_storage
        parts = os.path.normpath(storage_dir).split(os.sep)
        for part in reversed(parts):
            if part.startswith("wxid_"):
                return part
        return None

    # ------------------------------------------------------------------
    # 密钥访问（全部转发到 KeyCache，DbReader 不再自持密钥存储）
    # ------------------------------------------------------------------
    def set_keys(
        self,
        key_map: dict[str, str],
        source: str = "auto_extract",
        pid: Optional[int] = None,
        start_time: Optional[float] = None,
    ) -> None:
        """批量设置 salt -> enc_key 映射。

        转发到 KeyCache.set_key_map，由 KeyCache 负责持久化。
        keys 变化后清空解密缓存索引，避免旧密钥解密的脏数据被复用。

        Args:
            key_map: {salt_hex: enc_key_hex}
            source: 密钥来源（auto_extract / api / env）
            pid: 自动提取时关联的微信进程 PID（用于进程重启后失效检测）
            start_time: 自动提取时关联的进程启动时间
        """
        wxid = self._get_current_wxid()
        self.key_cache.set_wxid(wxid)
        self.key_cache.set_key_map(
            key_map,
            source=source,
            verified=True,
            pid=pid,
            start_time=start_time,
        )
        # keys 变化后清空解密缓存，让查询重新生成
        self._decrypted_cache.clear()

    def set_key(
        self,
        key_hex: Optional[str],
        salt_hex: Optional[str] = None,
    ) -> None:
        """设置单个密钥（转发到 KeyCache）。

        任何 key 变更都会清空解密缓存索引，避免旧密钥解密的脏数据被复用。
        """
        if key_hex is None:
            self.key_cache.clear()
        else:
            self.key_cache.set_key(key_hex, salt_hex=salt_hex)
        self._decrypted_cache.clear()

    def set_default_key(self, key_hex: Optional[str]) -> None:
        """设置默认密钥（env 注入专用，转发到 KeyCache）。

        不影响 salt_to_key 持久化映射，env key 仅作为默认兜底。
        """
        self.key_cache.set_default_key(key_hex, source="env", verified=False)

    def clear_default_key(self) -> None:
        """仅清空默认 key（保留持久化 salt_to_key 映射）。

        用于 env/api key 验证失败后清除默认 key，不影响已持久化的多 salt 映射。
        keys 变化后清空解密缓存索引。
        """
        self.key_cache.clear_default_key()
        self._decrypted_cache.clear()

    def get_keys(self) -> dict[str, str]:
        """返回当前持久化的 salt -> key 映射副本。"""
        return self.key_cache.get_keys()

    def has_keys(self) -> bool:
        """是否有可用的密钥（持久化或默认 key）。"""
        return self.key_cache.has_keys()

    def _get_effective_key(self, salt_hex: Optional[str] = None) -> Optional[str]:
        """返回指定 salt 的有效密钥（转发到 KeyCache）。"""
        return self.key_cache.get_key(salt_hex)

    def _mark_default_key_mismatch(self, salt_hex: str) -> None:
        """标记默认 key 不匹配该 salt（转发到 KeyCache）。"""
        self.key_cache.mark_default_key_mismatch(salt_hex)

    def _read_db_salt(self, db_path: str) -> Optional[str]:
        """读取 DB 第 1 页前 16 字节 salt。"""
        try:
            with open(db_path, "rb") as f:
                page1 = f.read(PAGE_SIZE)
            if len(page1) < SALT_SIZE:
                return None
            return page1[:SALT_SIZE].hex()
        except OSError:
            return None

    # ------------------------------------------------------------------
    # 解密缓存
    # ------------------------------------------------------------------
    def _cleanup_temp_files(self) -> None:
        """进程退出时清理临时解密文件（如果有）。"""
        # 持久化缓存保留，不删除
        pass

    def _decrypted_path_for(self, rel_path: str, wxid: Optional[str] = None) -> str:
        """计算解密缓存文件路径。"""
        if wxid is None:
            wxid = self._get_wxid_from_storage() or "unknown"
        # rel_path 本身通常以 .db 结尾（如 contact/contact.db），直接保留即可
        safe_rel = rel_path.replace(os.sep, "_").replace("/", "_")
        cache_dir = os.path.join(self.decrypted_dir, wxid)
        os.makedirs(cache_dir, exist_ok=True)
        return os.path.join(cache_dir, safe_rel)

    def _is_plain_db(self, db_path: str) -> bool:
        """DB 是否为明文 SQLite。"""
        try:
            with open(db_path, "rb") as f:
                header = f.read(16)
            return header == b"SQLite format 3\x00"
        except OSError:
            return False

    def _ensure_decrypted(self, rel_path: str) -> str:
        """确保指定相对路径的 DB 已解密到缓存，返回明文 DB 绝对路径。

        流程：
        1. 定位原 DB
        2. 明文 DB 直接返回原路径
        3. 读取 salt，查找对应 key
        4. 检查解密缓存是否有效（mtime + key 一致）
        5. 无效则重新解密，并合并 WAL
        """
        db_path = self._get_db_abs_path(rel_path)
        if db_path is None:
            raise BridgeError(
                code="DB_NOT_FOUND",
                message=f"未找到微信 DB: {rel_path}",
            )
        if not os.path.isfile(db_path):
            raise BridgeError(
                code="DB_NOT_FOUND",
                message=f"DB 文件不存在: {rel_path}",
            )

        # 明文直接返回
        if self._is_plain_db(db_path):
            return db_path

        # 加密 DB：需要 key
        salt_hex = self._read_db_salt(db_path)
        if not salt_hex:
            raise BridgeError(
                code="DB_ENCRYPTED",
                message=f"无法读取 DB salt: {rel_path}",
            )

        key_hex = self._get_effective_key(salt_hex)
        if not key_hex:
            raise BridgeError(
                code="DB_NEED_INIT",
                message="DB 已加密且没有可用密钥，请先调用 /api/db/init",
            )

        wxid = self._get_wxid_from_storage()
        decrypted_path = self._decrypted_path_for(rel_path, wxid)
        wal_path = db_path + "-wal"

        try:
            db_mtime = os.path.getmtime(db_path)
            wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
        except OSError as e:
            raise BridgeError(
                code="DB_NOT_FOUND",
                message=f"无法获取 DB mtime: {e}",
            )

        # 检查内存缓存索引
        cached = self._decrypted_cache.get(rel_path)
        if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]):
            return cached[2]

        with self._lock:
            # 双重检查
            cached = self._decrypted_cache.get(rel_path)
            if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]):
                return cached[2]

            # 解密
            decryptor = Decryptor(key_hex)
            result = decryptor.decrypt_db(db_path, decrypted_path)
            if not result.get("success"):
                error = result.get("error", "unknown")
                if os.path.exists(decrypted_path):
                    os.remove(decrypted_path)
                # 默认 key 解密失败时标记 salt，避免后续重复尝试
                if key_hex == self.key_cache.get_default_key() and salt_hex:
                    self._mark_default_key_mismatch(salt_hex)
                raise BridgeError(
                    code="DB_ENCRYPTED",
                    message=f"解密 {rel_path} 失败: {error}",
                )

            # 合并 WAL
            if os.path.exists(wal_path):
                try:
                    decryptor.decrypt_wal(wal_path, decrypted_path)
                except Exception:
                    # WAL 合并失败不影响主 DB 读取，只是可能缺最新数据
                    pass

            os.chmod(decrypted_path, 0o644)
            self._decrypted_cache[rel_path] = (db_mtime, wal_mtime, decrypted_path)
            return decrypted_path

    def invalidate_decrypted_cache(self) -> None:
        """清空解密缓存索引（不删除文件，下次按 mtime 重新判断）。"""
        with self._lock:
            self._decrypted_cache.clear()

    # ------------------------------------------------------------------
    # DB 可达性状态
    # ------------------------------------------------------------------
    def check_db_status(self) -> str:
        """检测 DB 状态。

        Returns:
            "ok": 有 DB 且可直接/解密读取
            "not_found": 未找到 DB
            "need_init": DB 加密但没有密钥文件/内存 key
            "key_invalid": 有密钥但验证失败（极少见）
        """
        storage = self._find_db_storage_dir()
        if storage is None:
            return "not_found"

        # 找一个需要读取的 DB 探针（contact.db 或 message_0.db）
        probe_rel_paths = ["contact/contact.db", "message/message_0.db"]
        probe_path = None
        for rel in probe_rel_paths:
            p = os.path.join(storage, rel)
            if os.path.isfile(p):
                probe_path = p
                break

        if probe_path is None:
            # 目录存在但找不到探针文件，可能 DB 还没完全生成
            return "not_found"

        if self._is_plain_db(probe_path):
            return "ok"

        if not self.has_keys():
            return "need_init"

        # 有 keys，快速验证是否能解密
        salt_hex = self._read_db_salt(probe_path)
        if salt_hex and self._get_effective_key(salt_hex):
            return "ok"

        return "key_invalid"

    def is_db_accessible(self) -> bool:
        """DB 是否可读。"""
        return self.check_db_status() == "ok"

    def get_db_mtime(self, rel_path: str) -> Optional[tuple[float, float]]:
        """返回指定 DB 及其 WAL 文件的 mtime，用于增量轮询感知变化。

        Args:
            rel_path: 相对路径，如 message/message_0.db

        Returns:
            (db_mtime, wal_mtime)：DB 不存在时返回 None；
            WAL 不存在时 wal_mtime 为 0.0；mtime 读取失败记为 0.0。
        """
        db_path = self._get_db_abs_path(rel_path)
        if db_path is None:
            return None
        try:
            db_mtime = os.path.getmtime(db_path)
        except OSError:
            db_mtime = 0.0
        wal_path = db_path + "-wal"
        try:
            wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
        except OSError:
            wal_mtime = 0.0
        return db_mtime, wal_mtime

    # ------------------------------------------------------------------
    # 辅助：表/列探测
    # ------------------------------------------------------------------
    @staticmethod
    def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
        """用 PRAGMA table_info 取指定表的所有列名。"""
        try:
            cur = conn.execute(f"PRAGMA table_info({table})")
            return [str(row[1]) for row in cur.fetchall() if row[1]]
        except sqlite3.Error:
            return []

    @staticmethod
    def _pick_column(columns: list[str], candidates: list[str]) -> Optional[str]:
        """从候选列名中返回第一个匹配（不区分大小写）。"""
        lower = {c.lower(): c for c in columns}
        for cand in candidates:
            if cand.lower() in lower:
                return lower[cand.lower()]
        return None

    @staticmethod
    def _find_contact_table(conn: sqlite3.Connection) -> Optional[str]:
        """探测联系人表名。"""
        try:
            cur = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table'"
            )
            all_tables = {row[0] for row in cur.fetchall() if row[0]}
        except sqlite3.Error:
            return None
        for cand in ("contact", "contacts", "rcontact"):
            if cand in all_tables:
                return cand
        for name in all_tables:
            if "contact" in name.lower():
                return name
        return None

    @staticmethod
    def _find_table_by_name(conn: sqlite3.Connection, candidates: list[str]) -> Optional[str]:
        """按候选名查找表。"""
        try:
            cur = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table'"
            )
            all_tables = {row[0] for row in cur.fetchall() if row[0]}
        except sqlite3.Error:
            return None
        for cand in candidates:
            if cand in all_tables:
                return cand
        return None

    # ------------------------------------------------------------------
    # 当前账号信息
    # ------------------------------------------------------------------
    def get_self_info(self) -> dict:
        """读取当前登录账号的 wxid 与 nickname。"""
        result = {"wxid": "", "nickname": ""}
        storage = self._find_db_storage_dir()
        if storage is None:
            return result
        wxid = self._get_wxid_from_storage(storage)
        result["wxid"] = wxid or ""
        try:
            db_path = self._ensure_decrypted("contact/contact.db")
            conn = sqlite3.connect(db_path, isolation_level=None)
            conn.row_factory = sqlite3.Row
            try:
                table = self._find_contact_table(conn)
                if table is None:
                    return result
                cols = self._table_columns(conn, table)
                username_col = self._pick_column(cols, ["username", "wxid"])
                nickname_col = self._pick_column(cols, ["nickname", "nick_name"])
                if username_col is None or not wxid:
                    return result
                cur = conn.execute(
                    f"SELECT {nickname_col or 'NULL'} AS nickname FROM {table} "
                    f"WHERE {username_col} = ? LIMIT 1",
                    (wxid,),
                )
                row = cur.fetchone()
                if row:
                    result["nickname"] = str(row["nickname"] or "")
            finally:
                conn.close()
        except Exception:
            pass
        return result

    # ------------------------------------------------------------------
    # 消息查询
    # ------------------------------------------------------------------
    def _row_to_message(self, row: sqlite3.Row) -> dict:
        """将 SQL 行映射为 Message dict。"""
        msg_id = str(row["msg_id"])
        talker = row["talker"] or ""
        is_sender = bool(row["is_sender"])
        msg_type = int(row["type"]) if row["type"] is not None else 0
        content = row["content"] or ""
        create_time = int(row["create_time"]) if row["create_time"] is not None else 0

        render_type = _TYPE_TO_RENDER_TYPE.get(msg_type, "text")
        session_type = "group" if talker.endswith("@chatroom") else "p2p"

        sender = ""
        if session_type == "group" and is_sender is False and "\n" in content:
            head, _, _ = content.partition("\n")
            if head.endswith(":"):
                sender = head[:-1]
                content = content.split("\n", 1)[1]

        return {
            "msg_id": msg_id,
            "talker": talker,
            "sender": sender,
            "is_sender": is_sender,
            "type": msg_type,
            "render_type": render_type,
            "content": content,
            "create_time": create_time,
            "session_type": session_type,
        }

    def _load_name2id(self, conn: sqlite3.Connection) -> dict[int, str]:
        """加载 Name2Id 表，返回 {rowid: user_name} 映射。"""
        id_to_username: dict[int, str] = {}
        try:
            for rowid, user_name in conn.execute(
                "SELECT rowid, user_name FROM Name2Id"
            ).fetchall():
                if user_name:
                    id_to_username[rowid] = user_name
        except sqlite3.Error:
            pass
        return id_to_username

    def _resolve_msg_table_talkers(self, conn: sqlite3.Connection) -> dict[str, str]:
        """解析所有 Msg_* 分片表名到 talker username 的映射。

        WeChat 4.x 用 Msg_<MD5(username)> 作为每个会话的消息分片表名。
        遍历 Name2Id 中的 user_name，计算 MD5 匹配实际存在的表名。
        """
        tables = [
            r[0]
            for r in conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
            ).fetchall()
        ]
        if not tables:
            return {}

        table_set = set(tables)
        table_to_talker: dict[str, str] = {}
        try:
            for (user_name,) in conn.execute(
                "SELECT user_name FROM Name2Id"
            ).fetchall():
                if not user_name:
                    continue
                table_name = "Msg_" + hashlib.md5(user_name.encode()).hexdigest()
                if table_name in table_set and table_name not in table_to_talker:
                    table_to_talker[table_name] = user_name
        except sqlite3.Error:
            pass

        for t in tables:
            if t not in table_to_talker:
                table_to_talker[t] = t
        return table_to_talker

    @staticmethod
    def _decompress_msg_content(content, ct_flag) -> str:
        """解压消息内容。ct_flag=4 表示 zstd 压缩。"""
        if content is None:
            return ""
        if ct_flag == 4 and isinstance(content, (bytes, bytearray)):
            try:
                import zstandard

                return (
                    zstandard.ZstdDecompressor()
                    .decompress(content)
                    .decode("utf-8", errors="replace")
                )
            except Exception:
                return ""
        if isinstance(content, (bytes, bytearray)):
            try:
                return content.decode("utf-8", errors="replace")
            except Exception:
                return ""
        return str(content)

    def _row_to_message_dict(
        self,
        row: sqlite3.Row,
        talker: str,
        id_to_username: dict[int, str],
        self_wxid: str,
    ) -> dict:
        """把 Msg_* 表的 SQL 行映射为 Message dict（统一三处调用方逻辑）。

        处理：zstd/GBK 解压、real_sender_id → is_sender、群消息首行 sender 剥离、
        render_type 映射。
        """
        content = self._decompress_msg_content(
            row["message_content"], row["WCDB_CT_message_content"]
        )
        sender_username = id_to_username.get(row["real_sender_id"], "")
        is_sender = bool(sender_username) and sender_username == self_wxid
        msg_type = int(row["local_type"]) if row["local_type"] is not None else 0
        create_time = int(row["create_time"]) if row["create_time"] is not None else 0

        sender = ""
        session_type = "group" if talker.endswith("@chatroom") else "p2p"
        if session_type == "group" and not is_sender and "\n" in content:
            head, sep, rest = content.partition("\n")
            if head.endswith(":"):
                sender = head[:-1]
                content = rest

        render_type = _TYPE_TO_RENDER_TYPE.get(msg_type & 0xFFFFFFFF, "text")
        return {
            "msg_id": str(row["local_id"]),
            "talker": talker,
            "sender": sender,
            "is_sender": is_sender,
            "type": msg_type,
            "render_type": render_type,
            "content": content,
            "create_time": create_time,
            "session_type": session_type,
        }

    def get_messages_since(self, cursor: int, limit: int = 50) -> dict:
        """读取 create_time > cursor 的增量消息。

        WeChat 4.x 消息存储在 Msg_<MD5(talker)> 分片表中，每个会话一张表。
        遍历所有分片表合并结果后按 create_time 排序返回。
        """
        db_path = self._ensure_decrypted("message/message_0.db")
        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            table_to_talker = self._resolve_msg_table_talkers(conn)
            if not table_to_talker:
                raise BridgeError(
                    code="DB_NOT_FOUND",
                    message="未找到 Msg_* 消息分片表",
                )
            id_to_username = self._load_name2id(conn)
            self_wxid = self._get_current_wxid() or ""

            all_rows: list[dict] = []
            for table_name, talker in table_to_talker.items():
                cur = conn.execute(
                    f"SELECT local_id, local_type, create_time, real_sender_id, "
                    f"message_content, WCDB_CT_message_content "
                    f"FROM [{table_name}] WHERE create_time > ? "
                    f"ORDER BY create_time ASC LIMIT ?",
                    (cursor, limit),
                )
                for row in cur.fetchall():
                    all_rows.append(self._row_to_message_dict(
                        row, talker, id_to_username, self_wxid
                    ))

            all_rows.sort(key=lambda r: r["create_time"])
            messages = all_rows[:limit]
        finally:
            conn.close()

        next_cursor = messages[-1]["create_time"] if messages else cursor
        has_more = len(messages) >= limit
        return {
            "messages": messages,
            "next_cursor": next_cursor,
            "has_more": has_more,
        }

    def get_messages_by_session(
        self,
        talker: str,
        cursor: int = 0,
        limit: int = 50,
        direction: str = "before",
    ) -> dict:
        """按会话拉取历史消息（单会话，O(1) 定位分片表）。

        通过 talker 计算 Msg_<MD5(talker)> 表名，直接查单表，避免遍历所有分片。
        支持向前翻页（before：取 create_time < cursor 的旧消息）和向后翻页
       （after：取 create_time > cursor 的新消息）。

        Args:
            talker: 会话对方 wxid（群消息为 chatroom id）
            cursor: 游标，首次传 0；before 模式取该时间之前的旧消息，
                after 模式取该时间之后的新消息
            limit: 最多返回条数，1~200
            direction: before（默认，往前翻历史）/ after（往后拉新消息）

        Returns:
            {"messages": [...], "next_cursor": int, "has_more": bool, "talker": str}
            before 模式 next_cursor 为最旧消息的 create_time；
            after 模式 next_cursor 为最新消息的 create_time
        """
        # clamping 防止 limit 越界（-1 在 SQLite 中表示无限制，有性能风险）
        limit = max(1, min(limit, 200))
        db_path = self._ensure_decrypted("message/message_0.db")
        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            table_name = "Msg_" + hashlib.md5(talker.encode()).hexdigest()
            # 探测表是否存在，不存在返回空
            row = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
                (table_name,),
            ).fetchone()
            if row is None:
                return {
                    "messages": [],
                    "next_cursor": cursor,
                    "has_more": False,
                    "talker": talker,
                }

            id_to_username = self._load_name2id(conn)
            self_wxid = self._get_current_wxid() or ""

            if direction == "after":
                where_sql = "create_time > ?"
                order_sql = "create_time ASC"
            else:
                # before 模式：cursor=0 时不加时间过滤（取最新 limit 条）
                if cursor > 0:
                    where_sql = "create_time < ?"
                else:
                    where_sql = "1=1"
                order_sql = "create_time DESC"

            if cursor > 0 or direction == "after":
                sql = (
                    f"SELECT local_id, local_type, create_time, real_sender_id, "
                    f"message_content, WCDB_CT_message_content "
                    f"FROM [{table_name}] WHERE {where_sql} "
                    f"ORDER BY {order_sql} LIMIT ?"
                )
                params: tuple = (cursor, limit)
            else:
                sql = (
                    f"SELECT local_id, local_type, create_time, real_sender_id, "
                    f"message_content, WCDB_CT_message_content "
                    f"FROM [{table_name}] ORDER BY {order_sql} LIMIT ?"
                )
                params = (limit,)

            cur = conn.execute(sql, params)
            rows = cur.fetchall()
            messages = [
                self._row_to_message_dict(row, talker, id_to_username, self_wxid)
                for row in rows
            ]
            # before 模式取的是 DESC，返回时反转为升序便于客户端展示
            if direction == "before":
                messages.reverse()
        finally:
            conn.close()

        if messages:
            if direction == "after":
                next_cursor = messages[-1]["create_time"]
            else:
                next_cursor = messages[0]["create_time"]
        else:
            next_cursor = cursor
        has_more = len(messages) >= limit
        return {
            "messages": messages,
            "next_cursor": next_cursor,
            "has_more": has_more,
            "talker": talker,
        }

    def search_messages(
        self,
        keyword: str,
        talker: Optional[str] = None,
        start_time: Optional[int] = None,
        end_time: Optional[int] = None,
        limit: int = 50,
    ) -> dict:
        """按关键词搜索历史消息。

        遍历所有 Msg_* 分片表（或指定 talker 的单表），对 message_content
        做 LIKE 模糊匹配，合并后按 create_time 降序返回（最新匹配在前）。

        Args:
            keyword: 搜索关键词（非空），对原始 message_content 列做子串匹配
            talker: 可选，限定在指定会话内搜索
            start_time: 可选，起始时间戳（含）
            end_time: 可选，结束时间戳（含）
            limit: 最多返回条数，1~200

        Returns:
            {"messages": [...], "total": int}
            total 为合并后返回的条数（受每表 LIMIT 截断，可能小于实际命中数）

        Notes:
            - 限制：LIKE 在 SQL 层对原始 message_content 列操作，无法搜索
              zstd 压缩消息（WCDB_CT_message_content==4 时存储的是压缩 BLOB，
              LIKE 不匹配二进制）。压缩消息会被静默漏掉。
            - 如需搜索压缩消息，需先 SELECT 候选行再在 Python 侧解压后过滤。
            - keyword 中的 % 和 _ 会按 SQL 通配符解释，已做转义处理为字面量。
        """
        if not keyword:
            return {"messages": [], "total": 0}

        # clamping 防止 limit 越界
        limit = max(1, min(limit, 200))
        db_path = self._ensure_decrypted("message/message_0.db")
        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            id_to_username = self._load_name2id(conn)
            self_wxid = self._get_current_wxid() or ""

            # 构建 WHERE 子句（LIKE 转义 % _ \ 为字面量）
            escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
            where_clauses = ["message_content LIKE ? ESCAPE '\\'"]
            params: list = [f"%{escaped}%"]
            if start_time is not None:
                where_clauses.append("create_time >= ?")
                params.append(start_time)
            if end_time is not None:
                where_clauses.append("create_time <= ?")
                params.append(end_time)
            where_sql = " AND ".join(where_clauses)

            # 确定要搜索的表集合
            if talker:
                table_name = "Msg_" + hashlib.md5(talker.encode()).hexdigest()
                row = conn.execute(
                    "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
                    (table_name,),
                ).fetchone()
                if row is None:
                    return {"messages": [], "total": 0}
                tables_to_search = {table_name: talker}
            else:
                tables_to_search = self._resolve_msg_table_talkers(conn)
                if not tables_to_search:
                    return {"messages": [], "total": 0}

            all_rows: list[dict] = []
            for table_name, tk in tables_to_search.items():
                try:
                    sql = (
                        f"SELECT local_id, local_type, create_time, real_sender_id, "
                        f"message_content, WCDB_CT_message_content "
                        f"FROM [{table_name}] WHERE {where_sql} "
                        f"ORDER BY create_time DESC LIMIT ?"
                    )
                    cur = conn.execute(sql, (*params, limit))
                    for row in cur.fetchall():
                        all_rows.append(self._row_to_message_dict(
                            row, tk, id_to_username, self_wxid
                        ))
                except sqlite3.Error:
                    continue
        finally:
            conn.close()

        # 合并后按 create_time 降序，取前 limit 条
        all_rows.sort(key=lambda r: r["create_time"], reverse=True)
        total = len(all_rows)
        messages = all_rows[:limit]
        return {"messages": messages, "total": total}

    def get_max_create_time(self) -> Optional[int]:
        """返回所有 Msg_* 分片表中最大的 create_time，用于初始化推送游标。

        DB 不可读或无消息表时返回 None。供 MessageStreamer 在启动时
        把全局游标对齐到当前最新消息，避免向新连接推送全量历史。
        """
        try:
            db_path = self._ensure_decrypted("message/message_0.db")
        except BridgeError:
            return None
        conn = sqlite3.connect(db_path, isolation_level=None)
        try:
            table_to_talker = self._resolve_msg_table_talkers(conn)
            if not table_to_talker:
                return None
            max_ct: Optional[int] = None
            for table_name in table_to_talker:
                try:
                    row = conn.execute(
                        f"SELECT MAX(create_time) FROM [{table_name}]"
                    ).fetchone()
                    if row and row[0] is not None:
                        ct = int(row[0])
                        if max_ct is None or ct > max_ct:
                            max_ct = ct
                except sqlite3.Error:
                    continue
            return max_ct
        except Exception:
            return None
        finally:
            conn.close()

    # ------------------------------------------------------------------
    # 朋友圈（Moments）DB 探测与读取
    # ------------------------------------------------------------------
    # WeChat 4.x Linux 朋友圈数据存放路径与表结构官方未公开，以下实现基于
    # 常见命名做容错探测：优先查找 moment/sns 相关 DB 文件，再按候选表名/列名探测。
    _MOMENT_DB_CANDIDATES = (
        "moment/moment_0.db",
        "sns/sns_0.db",
        "moment/moment.db",
        "sns/sns.db",
    )
    _MOMENT_TABLE_CANDIDATES = (
        "Moment", "MomentInfo", "SNS", "SnsInfo",
        "moment", "moment_info", "sns_info",
    )

    def _find_moment_db_path(self) -> Optional[str]:
        """探测朋友圈 DB 文件路径。

        依次尝试 _MOMENT_DB_CANDIDATES 中的相对路径，命中即返回；
        全部不存在时按 basename 在 storage 下递归查找 moment_0.db / sns_0.db。
        """
        for rel in self._MOMENT_DB_CANDIDATES:
            path = self._get_db_abs_path(rel)
            if path and os.path.isfile(path):
                return path
        # fallback：递归查找
        storage = self._find_db_storage_dir()
        if storage is None:
            return None
        for target in ("moment_0.db", "sns_0.db", "moment.db", "sns.db"):
            for root, _, files in os.walk(storage):
                if target in files:
                    return os.path.join(root, target)
        return None

    def _find_moment_table(self, conn: sqlite3.Connection) -> Optional[str]:
        """探测朋友圈表名。"""
        try:
            cur = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table'"
            )
            all_tables = {row[0] for row in cur.fetchall() if row[0]}
        except sqlite3.Error:
            return None
        for cand in self._MOMENT_TABLE_CANDIDATES:
            if cand in all_tables:
                return cand
        # 模糊匹配含 moment / sns 的表名（收紧匹配避免误匹配短子串）
        for name in all_tables:
            low = name.lower()
            if low.startswith("moment") or low.startswith("sns"):
                return name
        return None

    def get_moments_timeline(
        self,
        cursor: int = 0,
        limit: int = 20,
    ) -> dict:
        """读取朋友圈时间线。

        WeChat 4.x 朋友圈 schema 未公开，本方法做容错探测：
        1. 探测 moment/sns DB 文件
        2. 探测朋友圈表名（Moment / SNS / MomentInfo 等）
        3. 探测列名（content / text / description / create_time / create_time_sec 等）
        4. 按 create_time 降序返回

        Args:
            cursor: 游标，首次传 0；取 create_time < cursor 的更旧朋友圈
            limit: 最多返回条数，1~50

        Returns:
            {"moments": [...], "next_cursor": int, "has_more": bool}
            单条 moment dict 字段：moment_id / content / create_time /
            author_wxid / extra（原始列名→值的字典，供前端兜底展示）

        Raises:
            BridgeError(DB_NOT_FOUND): 未找到朋友圈 DB 文件
            BridgeError(MOMENT_TABLE_NOT_FOUND): DB 存在但无朋友圈表（不抛
                异常，改返回空列表 + 可读的 status 字段；仅当 DB 完全不可读时抛 DB_NOT_FOUND）
        """
        # clamping 防止 limit 越界
        limit = max(1, min(limit, 50))
        moment_db = self._find_moment_db_path()
        if moment_db is None:
            return {
                "moments": [],
                "next_cursor": cursor,
                "has_more": False,
                "status": "db_not_found",
            }

        # 复用解密缓存机制：把绝对路径转成相对路径调用 _ensure_decrypted
        storage = self._find_db_storage_dir()
        if storage is None:
            return {
                "moments": [],
                "next_cursor": cursor,
                "has_more": False,
                "status": "storage_not_found",
            }
        try:
            rel_path = os.path.relpath(moment_db, storage)
        except ValueError:
            rel_path = os.path.basename(moment_db)
        # 规范为 unix 风格相对路径
        rel_path = rel_path.replace(os.sep, "/")

        try:
            db_path = self._ensure_decrypted(rel_path)
        except BridgeError:
            return {
                "moments": [],
                "next_cursor": cursor,
                "has_more": False,
                "status": "db_encrypted",
            }

        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            table = self._find_moment_table(conn)
            if table is None:
                return {
                    "moments": [],
                    "next_cursor": cursor,
                    "has_more": False,
                    "status": "table_not_found",
                }

            cols = self._table_columns(conn, table)
            if not cols:
                return {
                    "moments": [],
                    "next_cursor": cursor,
                    "has_more": False,
                    "status": "no_columns",
                }

            # 探测关键列名
            id_col = self._pick_column(cols, [
                "id", "moment_id", "sns_id", "rowid",
            ]) or "rowid"
            content_col = self._pick_column(cols, [
                "content", "text", "description", "moment_content",
            ])
            time_col = self._pick_column(cols, [
                "create_time", "create_time_sec", "ctime", "post_time", "time",
            ])
            author_col = self._pick_column(cols, [
                "author", "wxid", "username", "user_name", "poster",
            ])

            if time_col is None:
                return {
                    "moments": [],
                    "next_cursor": cursor,
                    "has_more": False,
                    "status": "no_time_column",
                }

            # 构造 SELECT
            select_parts = [f"{id_col} AS moment_id"]
            if content_col:
                select_parts.append(f"{content_col} AS content")
            if author_col:
                select_parts.append(f"{author_col} AS author_wxid")
            select_parts.append(f"{time_col} AS create_time")
            select_sql = ", ".join(select_parts)

            if cursor > 0:
                sql = (
                    f"SELECT {select_sql} FROM [{table}] "
                    f"WHERE {time_col} < ? ORDER BY {time_col} DESC LIMIT ?"
                )
                params: tuple = (cursor, limit)
            else:
                sql = (
                    f"SELECT {select_sql} FROM [{table}] "
                    f"ORDER BY {time_col} DESC LIMIT ?"
                )
                params = (limit,)

            cur = conn.execute(sql, params)
            moments: list[dict] = []
            for row in cur.fetchall():
                ct = int(row["create_time"]) if row["create_time"] is not None else 0
                moments.append({
                    "moment_id": str(row["moment_id"]),
                    "content": str(row["content"] or "") if content_col else "",
                    "create_time": ct,
                    "author_wxid": str(row["author_wxid"] or "") if author_col else "",
                })
        except sqlite3.Error as e:
            # 对外只返回通用错误码，详细异常记日志避免信息泄漏
            logger.warning("get_moments_timeline 查询失败: %s", e)
            return {
                "moments": [],
                "next_cursor": cursor,
                "has_more": False,
                "status": "query_error",
            }
        finally:
            conn.close()

        next_cursor = moments[-1]["create_time"] if moments else cursor
        has_more = len(moments) >= limit
        return {
            "moments": moments,
            "next_cursor": next_cursor,
            "has_more": has_more,
            "status": "ok",
        }

    # ------------------------------------------------------------------
    # 联系人字段映射（公共）
    # ------------------------------------------------------------------
    def _pick_contact_columns(self, cols: list[str]) -> dict[str, Optional[str]]:
        """根据实际表列名，挑选 contact 表各字段对应的列名。"""
        return {
            "username": self._pick_column(cols, ["username", "wxid"]),
            "nickname": self._pick_column(cols, ["nickname", "nick_name"]),
            "remark": self._pick_column(cols, ["remark", "conRemark"]),
            "alias": self._pick_column(cols, ["alias"]),
            "encrypt_username": self._pick_column(cols, ["encrypt_username", "encryptUsername"]),
            "quan_pin": self._pick_column(cols, ["quan_pin", "quanPin"]),
            "pin_yin_initial": self._pick_column(cols, ["pin_yin_initial", "pinYinInitial"]),
            "big_head_url": self._pick_column(cols, ["big_head_url", "bigHeadImgUrl"]),
            "small_head_url": self._pick_column(cols, ["small_head_url", "smallHeadImgUrl", "head_img_url"]),
            "description": self._pick_column(cols, ["description", "signature"]),
            "local_type": self._pick_column(cols, ["local_type", "localType"]),
            "verify_flag": self._pick_column(cols, ["verify_flag", "verifyFlag"]),
            "delete_flag": self._pick_column(cols, ["delete_flag", "deleteFlag"]),
            "chat_room_type": self._pick_column(cols, ["chat_room_type", "chatRoomType"]),
        }

    @staticmethod
    def _avatar_url_from_row(row: sqlite3.Row, wxid: str) -> str:
        """优先使用 DB 里的头像 URL，没有则回退到本地 avatar 接口。"""
        for col in ("small_head_url", "big_head_url", "avatar"):
            val = row[col] if col in row.keys() else None
            if val:
                return str(val)
        return f"/api/media/avatar/{wxid}"

    # 系统账号白名单（去掉 @stranger 等后缀后的 base 部分）
    _SYSTEM_WXIDS = frozenset({
        "weixin", "weixinteam", "filehelper", "fmessage", "medianote",
        "floatbottle", "qmessage", "newsapp", "tmessage", "qmail",
        "brandsessionholder", "helper_entry",
    })

    @staticmethod
    def _infer_contact_type(
        wxid: str,
        local_type: Optional[int],
        verify_flag: Optional[int],
        fallback: str = "person",
    ) -> str:
        """根据 wxid 前缀 / local_type / verify_flag 推断联系人类型。

        返回: person / official / system / group
        - 群聊（@chatroom 后缀）由调用方直接传 fallback="group" 标记
        - 真人好友 → person
        - 公众号 / 服务号 → official
        - 系统账号（微信团队、文件传输助手等）→ system
        """
        if not wxid:
            return fallback
        # 公众号 / 服务号前缀
        if wxid.startswith("gh_"):
            return "official"
        # 系统账号白名单（忽略 @stranger 等后缀）
        base = wxid.split("@", 1)[0]
        if base in DbReader._SYSTEM_WXIDS:
            return "system"
        # local_type 判断（WeChat 4.x 常见值）
        if local_type is not None:
            if local_type == 2:        # 公众号
                return "official"
            if local_type == 512:      # 系统账号
                return "system"
        # verify_flag 判断（0x08 = 已认证的公众号/品牌号）
        if verify_flag is not None and (verify_flag & 0x08):
            return "official"
        return fallback

    def _row_to_contact(self, row: sqlite3.Row, contact_type: str = "friend") -> dict:
        """把 sqlite3.Row 转成 Contact 兼容字典。"""
        wxid = str(row["username"] or "")

        def _val(col: str) -> Optional[str]:
            if col not in row.keys():
                return None
            v = row[col]
            if v is None:
                return None
            return str(v) if not isinstance(v, bytes) else None

        def _int_val(col: str) -> Optional[int]:
            if col not in row.keys():
                return None
            v = row[col]
            if v is None:
                return None
            try:
                return int(v)
            except (TypeError, ValueError):
                return None

        # 群聊直接标记为 group；其他按字段推断 person/official/system
        if contact_type == "group":
            inferred_type = "group"
        else:
            inferred_type = self._infer_contact_type(
                wxid,
                _int_val("local_type"),
                _int_val("verify_flag"),
                fallback="person",
            )

        return {
            "wxid": wxid,
            "nickname": _val("nickname") or "",
            "remark": _val("remark") or "",
            "avatar_url": self._avatar_url_from_row(row, wxid),
            "type": inferred_type,
            "alias": _val("alias"),
            "encrypt_username": _val("encrypt_username"),
            "quan_pin": _val("quan_pin"),
            "pin_yin_initial": _val("pin_yin_initial"),
            "big_head_url": _val("big_head_url"),
            "small_head_url": _val("small_head_url"),
            "description": _val("description"),
            "local_type": _int_val("local_type"),
            "verify_flag": _int_val("verify_flag"),
            "delete_flag": _int_val("delete_flag"),
            "chat_room_type": _int_val("chat_room_type"),
        }

    def _select_contact_columns(self, col_map: dict[str, Optional[str]]) -> str:
        """生成 SELECT 子句，只选实际存在的列。"""
        selects = [f"{col_map['username']} AS username"]
        for alias, col in col_map.items():
            if alias == "username" or col is None:
                continue
            selects.append(f"{col} AS {alias}")
        return ", ".join(selects)

    def get_contacts(self, keyword: str = "", limit: int = 50) -> dict:
        """联系人查询。"""
        empty = {"contacts": [], "total": 0}
        try:
            db_path = self._ensure_decrypted("contact/contact.db")
        except BridgeError:
            return empty

        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            table = self._find_contact_table(conn)
            if table is None:
                return empty
            cols = self._table_columns(conn, table)
            col_map = self._pick_contact_columns(cols)
            username_col = col_map["username"]
            nickname_col = col_map["nickname"]
            remark_col = col_map["remark"]
            if username_col is None:
                return empty

            select_sql = self._select_contact_columns(col_map)
            if keyword:
                escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
                like = f"%{escaped}%"
                where_clauses = [f"{username_col} LIKE ? ESCAPE '\\'"]
                params: list = [like]
                if nickname_col:
                    where_clauses.append(f"{nickname_col} LIKE ? ESCAPE '\\'")
                    params.append(like)
                if remark_col:
                    where_clauses.append(f"{remark_col} LIKE ? ESCAPE '\\'")
                    params.append(like)
                where_sql = " OR ".join(where_clauses)
                sql = (
                    f"SELECT {select_sql} "
                    f"FROM {table} WHERE ({where_sql}) "
                    f"AND {username_col} NOT LIKE '%@chatroom' "
                    f"LIMIT ?"
                )
                params.append(limit)
                cur = conn.execute(sql, params)
            else:
                sql = (
                    f"SELECT {select_sql} "
                    f"FROM {table} "
                    f"WHERE {username_col} NOT LIKE '%@chatroom' "
                    f"LIMIT ?"
                )
                cur = conn.execute(sql, (limit,))
            contacts = [self._row_to_contact(row, contact_type="friend") for row in cur.fetchall()]
            return {"contacts": contacts, "total": len(contacts)}
        except sqlite3.Error:
            return empty
        finally:
            conn.close()

    def get_contact_detail(self, wxid: str) -> Optional[dict]:
        """单条联系人查询。"""
        try:
            db_path = self._ensure_decrypted("contact/contact.db")
        except BridgeError:
            return None

        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            table = self._find_contact_table(conn)
            if table is None:
                return None
            cols = self._table_columns(conn, table)
            col_map = self._pick_contact_columns(cols)
            username_col = col_map["username"]
            if username_col is None:
                return None
            select_sql = self._select_contact_columns(col_map)
            sql = f"SELECT {select_sql} FROM {table} WHERE {username_col} = ? LIMIT 1"
            cur = conn.execute(sql, (wxid,))
            row = cur.fetchone()
            if row is None:
                return None
            return self._row_to_contact(row, contact_type="friend")
        except sqlite3.Error:
            return None
        finally:
            conn.close()

    # ------------------------------------------------------------------
    # 群聊与群成员查询
    # ------------------------------------------------------------------
    def get_groups(self, limit: int = 50) -> dict:
        """查询群聊列表。"""
        empty = {"groups": [], "total": 0}
        try:
            db_path = self._ensure_decrypted("contact/contact.db")
        except BridgeError:
            return empty

        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            table = self._find_contact_table(conn)
            if table is None:
                return empty
            cols = self._table_columns(conn, table)
            col_map = self._pick_contact_columns(cols)
            username_col = col_map["username"]
            if username_col is None:
                return empty
            select_sql = self._select_contact_columns(col_map)
            sql = (
                f"SELECT {select_sql} "
                f"FROM {table} WHERE {username_col} LIKE '%@chatroom' LIMIT ?"
            )
            cur = conn.execute(sql, (limit,))
            groups = [self._row_to_contact(row, contact_type="group") for row in cur.fetchall()]
            return {"groups": groups, "total": len(groups)}
        except sqlite3.Error:
            return empty
        finally:
            conn.close()

    def get_group_members(self, group_wxid: str) -> dict:
        """查询群成员。"""
        empty = {"group_wxid": group_wxid, "members": [], "total": 0}
        try:
            db_path = self._ensure_decrypted("contact/contact.db")
        except BridgeError:
            return empty

        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            table = self._find_table_by_name(
                conn,
                ["chatroom_member", "chatroom_members", "group_member", "group_members"],
            )
            if table is None:
                return empty
            cols = self._table_columns(conn, table)
            member_wxid_col = self._pick_column(cols, ["username", "wxid", "member_wxid"])
            nickname_col = self._pick_column(cols, ["nickname", "nick_name"])
            display_name_col = self._pick_column(cols, ["display_name", "displayName", "group_nickname"])
            group_col = self._pick_column(cols, ["chatroom", "group_id", "chatroom_username", "room_id"])
            admin_col = self._pick_column(
                cols,
                ["is_admin", "admin", "is_manager", "manager", "room_admin", "is_room_admin"],
            )
            if member_wxid_col is None or group_col is None:
                return empty
            sql = (
                f"SELECT {member_wxid_col} AS wxid, "
                f"{nickname_col or 'NULL'} AS nickname, "
                f"{display_name_col or 'NULL'} AS display_name, "
                f"{admin_col or 'NULL'} AS is_admin "
                f"FROM {table} WHERE {group_col} = ?"
            )
            cur = conn.execute(sql, (group_wxid,))

            owner_wxid = self._get_chatroom_owner(conn, group_wxid)

            members: list[dict] = []
            for row in cur.fetchall():
                wxid = str(row["wxid"] or "")
                raw_admin = row["is_admin"]
                if isinstance(raw_admin, str) and raw_admin.lower() in ("false", "0", "no", ""):
                    raw_admin = False
                is_admin = bool(raw_admin) or (bool(wxid) and wxid == owner_wxid)
                members.append({
                    "wxid": wxid,
                    "nickname": str(row["nickname"] or ""),
                    "display_name": str(row["display_name"] or ""),
                    "is_admin": is_admin,
                })
            return {"group_wxid": group_wxid, "members": members, "total": len(members)}
        except sqlite3.Error:
            return empty
        finally:
            conn.close()

    def _get_chatroom_owner(self, conn: sqlite3.Connection, group_wxid: str) -> Optional[str]:
        """从 chatroom 表查询群主 wxid。"""
        table = self._find_table_by_name(conn, ["chatroom", "chatrooms", "chat_room"])
        if table is None:
            return None
        cols = self._table_columns(conn, table)
        owner_col = self._pick_column(cols, ["roomowner", "room_owner", "owner"])
        if owner_col is None:
            return None
        group_col = self._pick_column(
            cols,
            ["chatroom", "chatroomname", "chatroom_username", "group_id", "username", "room_id"],
        )
        if group_col is None:
            return None
        try:
            cur = conn.execute(
                f"SELECT {owner_col} AS owner FROM {table} "
                f"WHERE {group_col} = ? LIMIT 1",
                (group_wxid,),
            )
            row = cur.fetchone()
            if row is None:
                return None
            return str(row["owner"] or "") or None
        except sqlite3.Error:
            return None

    # ------------------------------------------------------------------
    # 媒体路径解析
    # ------------------------------------------------------------------
    def get_media_path(self, msg_id: str) -> Optional[str]:
        """根据 msg_id 解析媒体文件路径。"""
        info = self._get_media_info(msg_id)
        if info is None:
            return None
        return info.get("path")

    def get_media_bytes(self, msg_id: str) -> Optional[tuple[bytes, str]]:
        """读取媒体文件内容。"""
        path = self.get_media_path(msg_id)
        if path is None or not os.path.isfile(path) or not os.access(path, os.R_OK):
            return None
        try:
            with open(path, "rb") as f:
                data = f.read()
        except OSError:
            return None
        mime = self._guess_mime(path)
        return (data, mime)

    def _get_media_info(self, msg_id: str) -> Optional[dict]:
        """从 DB 查询单条消息的 type / content。"""
        try:
            db_path = self._ensure_decrypted("message/message_0.db")
        except BridgeError:
            return None

        conn = sqlite3.connect(db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        try:
            cols = self._table_columns(conn, "message")
            if not cols:
                return None
            msg_id_col = self._pick_column(cols, ["msg_id"])
            type_col = self._pick_column(cols, ["type"])
            content_col = self._pick_column(cols, ["content"])
            if not (msg_id_col and type_col and content_col):
                return None
            cur = conn.execute(
                f"SELECT {type_col} AS type, {content_col} AS content "
                f"FROM message WHERE {msg_id_col} = ? LIMIT 1",
                (msg_id,),
            )
            row = cur.fetchone()
            if row is None:
                return None
            msg_type = int(row["type"]) if row["type"] is not None else 0
            content = str(row["content"] or "")
            path = self._resolve_media_path(msg_id, msg_type, content)
            return {"type": msg_type, "content": content, "path": path}
        except sqlite3.Error:
            return None
        finally:
            conn.close()

    def _resolve_media_path(self, msg_id: str, msg_type: int, content: str) -> Optional[str]:
        """根据消息类型与内容启发式解析媒体文件路径。

        优化点：
        - 媒体路径按 msg_id 缓存，避免每次 get_media 都 os.walk 全盘扫描
        - 优先扫描已知媒体目录（attachment/message/file/image/video/voice），
          避免遍历 db_storage 下的 .db 文件
        - 移除 5000 文件硬上限（改为按目录优先级扫描，命中即返回）
        """
        if msg_type not in (3, 34, 43, 49):
            return None
        # 命中缓存
        if msg_id in self._media_path_cache:
            return self._media_path_cache[msg_id]

        result: Optional[str] = None
        db_root_abs = os.path.realpath(self.db_root)

        # 策略 1：从 content 提取路径
        if content:
            matches = _re.findall(r"/[^\s\"'<>]+", content)
            for cand in matches:
                if not os.path.isfile(cand) or not os.access(cand, os.R_OK):
                    continue
                cand_abs = os.path.realpath(cand)
                if cand_abs.startswith(db_root_abs + os.sep):
                    result = cand
                    break

        # 策略 2：在已知媒体目录下按 msg_id 前缀查找
        if result is None and msg_id:
            storage = self._find_db_storage_dir()
            search_dirs: list[str] = []
            if storage:
                # 媒体文件通常在 db_storage 同级或上层目录
                parent = os.path.dirname(storage)
                for sub in ("attachment", "message", "file", "image", "video", "voice", "audio"):
                    p = os.path.join(parent, sub)
                    if os.path.isdir(p):
                        search_dirs.append(p)
                # db_storage 本身也作为兜底
                search_dirs.append(storage)
            if not search_dirs and os.path.isdir(self.db_root):
                search_dirs = [self.db_root]

            for search_dir in search_dirs:
                if result:
                    break
                for dirpath, _, filenames in os.walk(search_dir):
                    if result:
                        break
                    for name in filenames:
                        if name.endswith(".db"):
                            continue
                        stem, _ = os.path.splitext(name)
                        if stem == msg_id or stem.startswith(msg_id + "_") or stem.startswith(msg_id + "-"):
                            full = os.path.join(dirpath, name)
                            if os.access(full, os.R_OK):
                                result = full
                                break

        # 入缓存（含 None 结果，避免重复扫描未命中的 msg_id）
        if len(self._media_path_cache) >= self._media_path_cache_max:
            # FIFO 淘汰最旧
            oldest = next(iter(self._media_path_cache))
            del self._media_path_cache[oldest]
        self._media_path_cache[msg_id] = result
        return result

    @staticmethod
    def _guess_mime(path: str) -> str:
        """根据扩展名粗略猜测 MIME 类型。"""
        ext = os.path.splitext(path)[1].lower()
        return {
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".png": "image/png",
            ".gif": "image/gif",
            ".bmp": "image/bmp",
            ".webp": "image/webp",
            ".mp3": "audio/mpeg",
            ".wav": "audio/wav",
            ".amr": "audio/amr",
            ".mp4": "video/mp4",
            ".pdf": "application/pdf",
            ".doc": "application/msword",
            ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            ".xls": "application/vnd.ms-excel",
            ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            ".zip": "application/zip",
            ".txt": "text/plain",
        }.get(ext, "application/octet-stream")
