#!/usr/bin/env python3
"""
ZLibrary Book Downloader - AI-friendly CLI tool

用法:
  zlib_dl.py search  --title "书名" --author "作者"
  zlib_dl.py download --title "书名" --author "作者" [--proxy-file proxies.txt]

支持搜索参数: title, author, isbn, doi, publisher, 或自由查询 query
输出格式: 默认人类友好, 加 --json 输出结构化 JSON
"""

import argparse
import gzip
import hashlib
import json
import os
import platform
import re
import shutil
import signal
import subprocess
import sys
import time
import unicodedata
import urllib.request
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple

try:
    from curl_cffi import requests as cffi_requests
    from bs4 import BeautifulSoup
except ImportError:
    print("错误: 缺少依赖。请运行: pip install curl_cffi beautifulsoup4 lxml", file=sys.stderr)
    sys.exit(1)


# ============================================================
# 数据模型
# ============================================================

@dataclass
class BookResult:
    """图书搜索结果"""
    id: str
    title: str
    author: str
    format: str
    filesize: str
    language: str
    year: str
    publisher: str
    isbn: str
    quality: str
    rating: str
    download_path: str
    page_url: str
    detail_path: str = ""
    deleted: str = "0"

    def to_dict(self) -> dict:
        return asdict(self)

    def display_line(self, index: int) -> str:
        """人类可读的单行显示"""
        parts = [
            f"{index:3d}.",
            f"[{self.format:5s}]",
            f"{self.filesize:>10s}",
            f"| {self.title[:55]:<55s}",
            f"| {self.author[:30]:<30s}",
            f"| {self.language[:8]:<8s}",
            f"| {self.year}",
        ]
        if self.quality and self.quality != "0":
            parts.append(f"| Q:{self.quality}")
        if self.isbn:
            parts.append(f"| ISBN:{self.isbn}")
        return " ".join(parts)


@dataclass
class SearchResponse:
    """搜索响应（AI友好）"""
    status: str  # "success" | "error" | "no_results"
    query: Dict[str, str]
    total_results: int
    results: List[BookResult]
    recommended: Dict[str, Optional[BookResult]]  # 按格式推荐
    message: str

    def to_dict(self) -> dict:
        d = {
            "status": self.status,
            "query": self.query,
            "total_results": self.total_results,
            "results": [r.to_dict() for r in self.results],
            "recommended": {
                fmt: (r.to_dict() if r else None)
                for fmt, r in self.recommended.items()
            },
            "message": self.message,
        }
        return d


@dataclass
class DownloadResult:
    """下载结果"""
    status: str  # "success" | "error" | "skipped"
    title: str
    author: str
    format: str
    filesize: str
    filepath: str
    message: str
    proxy_used: str = ""

    def to_dict(self) -> dict:
        return asdict(self)


@dataclass
class ProxyConfig:
    """代理配置"""
    name: str
    type: str  # 'socks5', 'http', 'ss'
    host: str
    port: int
    # SS 特有
    method: str = ""
    password: str = ""
    obfs: str = ""
    obfs_host: str = ""
    # 运行时
    local_port: int = 0
    downloads_count: int = 0
    _process: Any = field(default=None, repr=False)


# ============================================================
# PoW 挑战解决器
# ============================================================

class ChallengeSolver:
    """解决 zlib.li 的 SHA1 工作量证明挑战"""

    @staticmethod
    def solve(html: str) -> Optional[Tuple[str, str]]:
        """
        解析并解决 PoW 挑战。
        返回 (c_token, c_time) 或 None（如果不是挑战页面）
        """
        if "Checking your browser" not in html:
            return None

        # 提取目标哈希
        match = re.search(r"\['([A-F0-9]{40})'", html)
        if not match:
            return None

        target_hash = match.group(1)
        n1 = int(target_hash[0], 16)

        start_time = time.time()
        i = 0
        while True:
            data = target_hash + str(i)
            digest = hashlib.sha1(data.encode()).digest()
            if digest[n1] == 0xB0 and digest[n1 + 1] == 0x0B:
                elapsed = time.time() - start_time
                c_token = target_hash + str(i)
                c_time = f"{elapsed:.3f}"
                return (c_token, c_time)
            i += 1
            if i > 10_000_000:
                return None


# ============================================================
# ZLib 会话管理
# ============================================================

class ZLibSession:
    """管理与 zlib.li 的会话，含 PoW 挑战处理"""

    BASE_URL = "https://zlib.li"

    def __init__(self, proxy_url: Optional[str] = None, verbose: bool = False):
        self.session = cffi_requests.Session()
        self.proxy_url = proxy_url
        self.verbose = verbose
        self._challenge_solved = False

    def _get_proxies(self) -> Optional[dict]:
        if self.proxy_url:
            return {"https": self.proxy_url, "http": self.proxy_url}
        return None

    def _request(self, url: str, **kwargs) -> cffi_requests.Response:
        """发起请求，自动处理 PoW 挑战"""
        proxies = self._get_proxies()
        resp = self.session.get(
            url, impersonate="chrome", proxies=proxies, timeout=30, **kwargs
        )

        # 检查是否需要解决挑战
        if resp.status_code == 503 and "Checking your browser" in resp.text:
            if self.verbose:
                print("  ⏳ 正在解决 PoW 挑战...", file=sys.stderr)

            result = ChallengeSolver.solve(resp.text)
            if result is None:
                raise RuntimeError("无法解决 PoW 挑战")

            c_token, c_time = result
            self.session.cookies.set("c_token", c_token, domain="zlib.li", path="/")
            self.session.cookies.set("c_time", c_time, domain="zlib.li", path="/")
            self._challenge_solved = True

            if self.verbose:
                print(f"  ✅ 挑战已解决 (耗时 {c_time}s)", file=sys.stderr)

            # 重新请求
            resp = self.session.get(
                url, impersonate="chrome", proxies=proxies, timeout=30, **kwargs
            )

        return resp

    def search(self, query: str, max_results: int = 50) -> List[BookResult]:
        """搜索图书，返回结果列表"""
        url = f"{self.BASE_URL}/s/?q={query}"
        resp = self._request(url)

        if resp.status_code != 200:
            raise RuntimeError(f"搜索失败: HTTP {resp.status_code}")

        soup = BeautifulSoup(resp.text, "lxml")
        bookcards = soup.find_all("z-bookcard")

        results = []
        for card in bookcards[:max_results]:
            title_el = card.find("div", slot="title")
            author_el = card.find("div", slot="author")

            result = BookResult(
                id=card.get("id", "").strip(),
                title=title_el.get_text(strip=True) if title_el else "",
                author=author_el.get_text(strip=True) if author_el else "",
                format=card.get("extension", "").strip(),
                filesize=card.get("filesize", "").strip(),
                language=card.get("language", "").strip(),
                year=card.get("year", "").strip(),
                publisher=card.get("publisher", "").strip(),
                isbn=card.get("isbn", "").strip(),
                quality=card.get("quality", "").strip(),
                rating=card.get("rating", "").strip(),
                download_path=card.get("download", "").strip().replace("\n", "").replace("\r", ""),
                page_url=card.get("href", "").strip().replace("\n", "").replace("\r", ""),
                deleted=card.get("deleted", "0").strip(),
            )
            # 跳过已删除的
            if result.deleted == "1":
                continue
            results.append(result)

        return results

    def _request_download(self, url: str, **kwargs) -> cffi_requests.Response:
        """发起下载请求，自动处理 PoW 挑战（支持 allow_redirects）"""
        proxies = self._get_proxies()
        resp = self.session.get(
            url, impersonate="chrome", proxies=proxies, timeout=300,
            allow_redirects=True, **kwargs
        )

        # 检查是否需要解决挑战 (503 或 200 但返回挑战页面)
        needs_challenge = (
            (resp.status_code == 503 and "Checking your browser" in resp.text)
            or (resp.status_code == 200
                and "text/html" in resp.headers.get("Content-Type", "")
                and "Checking your browser" in resp.text)
        )

        if needs_challenge:
            if self.verbose:
                print("  ⏳ 下载遇到 PoW 挑战，正在解决...", file=sys.stderr)

            result = ChallengeSolver.solve(resp.text)
            if result is None:
                raise RuntimeError("无法解决 PoW 挑战")

            c_token, c_time = result
            from urllib.parse import urlparse
            chal_domain = urlparse(resp.url).hostname or "zlib.li"
            self.session.cookies.set("c_token", c_token, domain=chal_domain, path="/")
            self.session.cookies.set("c_time", c_time, domain=chal_domain, path="/")

            if self.verbose:
                print(f"  ✅ 挑战已解决 (耗时 {c_time}s，节点 {chal_domain})", file=sys.stderr)

            # 无论什么页面，PoW 是在哪个域名的哪个具体 URL 上发生的，就全盘用带着 Cookie 的新状态重新向该域名发出最初的请求
            resp = self.session.get(
                resp.url, impersonate="chrome", proxies=proxies, timeout=300,
                allow_redirects=True, **kwargs
            )

        return resp

    def download_file(
        self, download_path: str, page_url: str, output_path: str
    ) -> Tuple[bool, str]:
        """
        下载文件。
        """
        if not download_path.startswith("/"):
            download_path = "/" + download_path
        
        url = f"{self.BASE_URL}{download_path}"
        detail_url = f"{self.BASE_URL}{page_url}" if page_url else ""

        # 模拟自然人行为：
        # 根据用户的经验，代理切换后，必须先在同一个全新代理 Session 下访问一遍“书籍详情页”，
        # 且下载链接是被CDN基于Session重新动态分配的。如果强行用旧链接，会被踢走。
        if detail_url:
            if self.verbose:
                print(f"  🔗 正在预热书籍详情页以注册当前代理指纹和获取新 Session...", file=sys.stderr)
            try:
                dt_resp = self._request_download(detail_url, headers={"Referer": self.BASE_URL + "/"})
                import re
                # 尝试从详情页 HTML 中抓取服务器新动态生成的专属下载路径
                match = re.search(r'href="(/dl/[^"]+)"', dt_resp.text)
                if match:
                    dynamic_dl = match.group(1)
                    url = f"{self.BASE_URL}{dynamic_dl}"
                    if self.verbose:
                        print(f"  ⚡ 成功从详情页解析到动态下载链接: {dynamic_dl}", file=sys.stderr)
                else:
                    try:
                        with open("failed_dl_page.html", "w") as f:
                            f.write(dt_resp.text)
                    except Exception:
                        pass
                    return False, f"未能从书籍详情页解析出最新的真实下载链接，已保存现场到 failed_dl_page.html"
            except Exception as e:
                return False, f"预热书籍详情页或解析出错: {e}"

        try:
            resp = self._request_download(url, headers={"Referer": detail_url} if detail_url else {})
        except Exception as e:
            return False, f"下载请求失败: {e}"

        if resp.status_code != 200:
            # 检查是否下载限制
            if resp.status_code == 429:
                return False, "下载次数已达上限 (每IP每天5本)"
            content = ""
            try:
                content = resp.text[:500]
            except Exception:
                pass
            if "limit" in content.lower():
                return False, "下载次数已达上限 (每IP每天5本)"
            return False, f"下载失败: HTTP {resp.status_code}"

        content_type = resp.headers.get("Content-Type", "")
        if "text/html" in content_type:
            # 下载接口返回了 HTML，这说明被系统拦截甚至踢回了首页
            html_text = resp.text[:2000].lower()
            if "daily limit" in html_text or "limit reached" in html_text or "已达下载上限" in html_text:
                return False, "下载次数已达真实上限"
            elif "sign in" in html_text or "login" in html_text or "登录" in html_text:
                return False, "系统要求登录或达到匿名限制"
            else:
                return False, "下载失败，服务端依然返回了 HTML 重定向网页！"

        # 写入文件
        os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
        with open(output_path, "wb") as f:
            f.write(resp.content)

        return True, f"下载完成: {len(resp.content)} bytes"


# ============================================================
# 代理管理器
# ============================================================

class ProxyManager:
    """管理代理列表和轮询"""

    MAX_DOWNLOADS_PER_PROXY = 5

    def __init__(self, proxy_file: Optional[str] = None, verbose: bool = False):
        self.proxies: List[ProxyConfig] = []
        self.current_index = -1  # -1 means direct (no proxy)
        self.verbose = verbose
        self._active_processes: List[subprocess.Popen] = []
        self._mihomo_bin: Optional[str] = None

        if proxy_file:
            self.proxies = self._load_proxies(proxy_file)
            if self.verbose:
                print(f"  📡 已加载 {len(self.proxies)} 个代理", file=sys.stderr)

    def _ensure_mihomo(self) -> Optional[str]:
        """下载并配置 mihomo 代理引擎到当前环境"""
        if self._mihomo_bin and os.path.exists(self._mihomo_bin):
            return self._mihomo_bin

        # 优先选择虚拟环境的 bin 目录，否则取当前目录的 bin
        base_dir = Path(sys.prefix) if sys.prefix != sys.base_prefix else Path.cwd()
        bin_dir = base_dir / "bin"
        bin_dir.mkdir(parents=True, exist_ok=True)

        mihomo_path = bin_dir / ("mihomo.exe" if platform.system().lower() == "windows" else "mihomo")

        if mihomo_path.exists():
            self._mihomo_bin = str(mihomo_path)
            return self._mihomo_bin

        system = platform.system().lower()
        machine = platform.machine().lower()
        is_arm = machine in ("arm64", "aarch64")

        if system == "darwin":
            arch_str = "arm64" if is_arm else "amd64-compatible"
        elif system == "linux":
            arch_str = "arm64" if is_arm else "amd64-compatible"
        else:
            print(f"代理错误: 暂不支持自动下载当前系统 ({system}) 的 mihomo", file=sys.stderr)
            return None

        version = "v1.19.21"
        url = f"https://github.com/MetaCubeX/mihomo/releases/download/{version}/mihomo-{system}-{arch_str}-{version}.gz"

        if self.verbose:
            print(f"  ⬇️ 首次使用 SS 代理，正在自动下载代理引擎...", file=sys.stderr)
            print(f"     URL: {url}", file=sys.stderr)

        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
            with urllib.request.urlopen(req, timeout=120) as response:
                with gzip.GzipFile(fileobj=response) as uncompressed:
                    with open(mihomo_path, 'wb') as f:
                        f.write(uncompressed.read())

            os.chmod(mihomo_path, 0o755)
            self._mihomo_bin = str(mihomo_path)

            if self.verbose:
                print(f"  ✅ 代理引擎下载完成: {mihomo_path}", file=sys.stderr)
            return self._mihomo_bin
        except Exception as e:
            print(f"代理错误: 自动下载代理引擎失败: {e}", file=sys.stderr)
            return None

    def _load_proxies(self, filepath: str) -> List[ProxyConfig]:
        """加载代理文件，支持 Surge SS 格式和 socks5:// 格式"""
        proxies = []
        with open(filepath, "r", encoding="utf-8") as f:
            for line_num, line in enumerate(f, 1):
                line = line.strip()
                if not line or line.startswith("#"):
                    continue

                # 尝试 socks5:// 或 http:// 格式
                if line.startswith(("socks5://", "http://", "https://")):
                    proxy = self._parse_url_proxy(line, line_num)
                    if proxy:
                        proxies.append(proxy)
                    continue

                # 尝试 Surge SS 格式
                proxy = self._parse_surge_ss(line, line_num)
                if proxy:
                    proxies.append(proxy)

        return proxies

    def _parse_url_proxy(self, line: str, line_num: int) -> Optional[ProxyConfig]:
        """解析 socks5://host:port 格式"""
        match = re.match(r"(socks5|http|https)://([^:]+):(\d+)", line)
        if match:
            return ProxyConfig(
                name=f"proxy-{line_num}",
                type=match.group(1),
                host=match.group(2),
                port=int(match.group(3)),
            )
        return None

    def _parse_surge_ss(self, line: str, line_num: int) -> Optional[ProxyConfig]:
        """
        解析 Surge Shadowsocks 格式:
        🇭🇰 香港 01= ss, host, port, encrypt-method=xxx, password=xxx, ...
        """
        # 分离名称和配置
        parts = line.split("=", 1)
        if len(parts) != 2:
            return None

        name = parts[0].strip()
        config_str = parts[1].strip()

        # 解析配置项
        config_parts = [p.strip() for p in config_str.split(",")]
        if len(config_parts) < 3 or config_parts[0] != "ss":
            return None

        host = config_parts[1].strip()
        try:
            port = int(config_parts[2].strip())
        except ValueError:
            return None

        # 解析键值对参数
        params = {}
        for part in config_parts[3:]:
            if "=" in part:
                k, v = part.split("=", 1)
                params[k.strip()] = v.strip()

        return ProxyConfig(
            name=name,
            type="ss",
            host=host,
            port=port,
            method=params.get("encrypt-method", ""),
            password=params.get("password", ""),
            obfs=params.get("obfs", ""),
            obfs_host=params.get("obfs-host", ""),
        )

    def _start_mihomo(self, proxy: ProxyConfig, local_port: int) -> bool:
        """为 SS 代理启动 mihomo 进程"""
        mihomo_bin = self._ensure_mihomo()
        if not mihomo_bin:
            return False

        # 生成配置文件
        cfg_path = Path(mihomo_bin).parent / f"config_{local_port}.yaml"

        # 处理 obfs 插件配置
        plugin_yaml = ""
        if proxy.obfs:
            plugin_yaml = f"""
    plugin: obfs
    plugin-opts:
      mode: {proxy.obfs}
      host: {proxy.obfs_host}"""

        # 尝试绕过 macOS 的 Surge 增强模式
        # 探测当前物理网卡 (避开 VPN 的 utun 接口)
        interface_yaml = ""
        if platform.system().lower() == "darwin":
            try:
                out = subprocess.check_output(
                    "netstat -rn -f inet | grep -E '^default' | grep -v 'utun'",
                    shell=True, timeout=1
                ).decode()
                for line in out.strip().split("\n"):
                    parts = line.split()
                    if len(parts) >= 4:
                        interface_yaml = f"\n    interface-name: {parts[3]}"
                        break
            except Exception:
                pass

        yaml_config = f"""
mode: global
port: 0
mixed-port: {local_port}
bind-address: '127.0.0.1'

# 独立的 DNS 解析，避免 Surge 的 Fake IP 污染导致流量绕回 Surge
dns:
  enable: true
  nameserver:
    - https://223.5.5.5/dns-query
    - https://1.1.1.1/dns-query

proxies:
  - name: "{proxy.name}"
    type: {proxy.type}
    server: {proxy.host}
    port: {proxy.port}
    cipher: "{proxy.method}"
    password: "{proxy.password}"{plugin_yaml}{interface_yaml}
"""
        with open(cfg_path, 'w', encoding='utf-8') as f:
            f.write(yaml_config)

        cmd = [mihomo_bin, "-d", str(Path(mihomo_bin).parent), "-f", str(cfg_path)]

        try:
            proc = subprocess.Popen(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            time.sleep(1.5)  # 等待引擎启动绑定端口

            if proc.poll() is not None:
                if self.verbose:
                    print(f"  ❌ mihomo 启动失败: {proxy.name}", file=sys.stderr)
                return False

            proxy._process = proc
            proxy.local_port = local_port
            # 记录对应的配置文件路径以便停止时清理
            proxy._cfg_path = str(cfg_path)
            self._active_processes.append(proc)

            if self.verbose:
                print(
                    f"  🔗 已启动代理隧道: {proxy.name} -> 127.0.0.1:{local_port}",
                    file=sys.stderr,
                )
            return True

        except Exception as e:
            print(f"错误: 启动 mihomo 代理引擎失败: {e}", file=sys.stderr)
            return False

    def _stop_mihomo(self, proxy: ProxyConfig):
        """停止代理的 mihomo 进程并清理"""
        if proxy._process and proxy._process.poll() is None:
            proxy._process.terminate()
            try:
                proxy._process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proxy._process.kill()
            if self.verbose:
                print(f"  🔌 已停止代理隧道: {proxy.name}", file=sys.stderr)

        # 清理配置文件
        if hasattr(proxy, '_cfg_path') and os.path.exists(proxy._cfg_path):
            try:
                os.remove(proxy._cfg_path)
            except Exception:
                pass

    def get_current_proxy_url(self) -> Optional[str]:
        """获取当前活跃的代理 URL"""
        if self.current_index < 0 or self.current_index >= len(self.proxies):
            return None

        proxy = self.proxies[self.current_index]

        if proxy.type == "ss":
            if proxy.local_port == 0:
                return None
            return f"socks5://127.0.0.1:{proxy.local_port}"
        elif proxy.type in ("socks5", "http", "https"):
            return f"{proxy.type}://{proxy.host}:{proxy.port}"

        return None

    def get_current_proxy_name(self) -> str:
        """获取当前代理名称"""
        if self.current_index < 0 or self.current_index >= len(self.proxies):
            return "直连"
        return self.proxies[self.current_index].name

    def record_download(self):
        """记录一次下载"""
        if 0 <= self.current_index < len(self.proxies):
            self.proxies[self.current_index].downloads_count += 1

    def should_rotate(self) -> bool:
        """是否需要轮换代理"""
        if self.current_index < 0:
            return len(self.proxies) > 0  # 有代理可用就应该开始用
        if self.current_index >= len(self.proxies):
            return False  # 已经用完了
        proxy = self.proxies[self.current_index]
        return proxy.downloads_count >= self.MAX_DOWNLOADS_PER_PROXY

    def rotate(self) -> bool:
        """
        轮换到下一个代理。
        返回 True 表示成功切换，False 表示没有更多代理可用。
        """
        # 停止当前代理
        if 0 <= self.current_index < len(self.proxies):
            current = self.proxies[self.current_index]
            if current.type == "ss":
                self._stop_mihomo(current)

        self.current_index += 1

        if self.current_index >= len(self.proxies):
            if self.verbose:
                print("  ⚠️ 所有代理已用完", file=sys.stderr)
            return False

        proxy = self.proxies[self.current_index]

        if proxy.type == "ss":
            local_port = 10800 + self.current_index
            success = self._start_mihomo(proxy, local_port)
            if not success:
                # 尝试下一个
                return self.rotate()
        else:
            if self.verbose:
                print(
                    f"  📡 切换到代理: {proxy.name} ({proxy.type}://{proxy.host}:{proxy.port})",
                    file=sys.stderr,
                )

        return True

    def remaining_proxies(self) -> int:
        """剩余可用代理数"""
        return max(0, len(self.proxies) - self.current_index - 1)

    def remaining_downloads(self) -> int:
        """当前代理剩余下载次数"""
        if self.current_index < 0 or self.current_index >= len(self.proxies):
            return 0
        proxy = self.proxies[self.current_index]
        return max(0, self.MAX_DOWNLOADS_PER_PROXY - proxy.downloads_count)

    def cleanup(self):
        """清理所有 proxy 相关的进程和配置"""
        for proxy in self.proxies:
            if proxy._process:
                self._stop_mihomo(proxy)


# ============================================================
# 智能匹配与推荐
# ============================================================

def normalize_text(text: str) -> str:
    """规范化文本用于比较（去除标点、统一大小写）"""
    text = unicodedata.normalize("NFKC", text)
    text = text.lower().strip()
    # 去除常见前后缀和标点
    text = re.sub(r"[^\w\s]", " ", text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def fuzzy_match(query: str, target: str, threshold: float = 0.6) -> float:
    """
    模糊匹配评分（0-1）。
    基于词汇重叠度。
    """
    if not query or not target:
        return 0.0

    q_norm = normalize_text(query)
    t_norm = normalize_text(target)

    # 完全匹配
    if q_norm == t_norm:
        return 1.0

    # 包含匹配
    if q_norm in t_norm or t_norm in q_norm:
        return 0.95

    # 词汇重叠
    q_words = set(q_norm.split())
    t_words = set(t_norm.split())

    if not q_words:
        return 0.0

    overlap = len(q_words & t_words)
    score = overlap / len(q_words)

    return score


def recommend_books(
    results: List[BookResult],
    title: str = "",
    author: str = "",
    language: Optional[str] = None,
) -> Dict[str, Optional[BookResult]]:
    """
    从结果中推荐最佳的 epub 和 pdf 版本。
    优先级: epub > pdf > mobi > 其他
    """
    FORMAT_PRIORITY = {"epub": 1, "pdf": 2, "mobi": 3, "azw3": 4, "fb2": 5, "djvu": 6}

    # 过滤和评分
    scored: List[Tuple[float, BookResult]] = []
    for book in results:
        score = 0.0

        # 标题匹配
        if title:
            title_score = fuzzy_match(title, book.title)
            if title_score < 0.5:
                continue  # 标题不匹配，跳过
            score += title_score * 50

        # 作者匹配
        if author:
            author_score = fuzzy_match(author, book.author)
            score += author_score * 30

        # 语言匹配（不做硬过滤，做加分）
        if language:
            if language.lower() in book.language.lower():
                score += 10

        # 质量评分
        try:
            q = float(book.quality) if book.quality else 0
            score += q * 2
        except ValueError:
            pass

        # 格式优先级（epub 和 pdf 加分）
        fmt_priority = FORMAT_PRIORITY.get(book.format.lower(), 10)
        score += max(0, 10 - fmt_priority * 2)

        scored.append((score, book))

    # 按评分排序
    scored.sort(key=lambda x: -x[0])

    # 按格式选出最佳
    recommended: Dict[str, Optional[BookResult]] = {
        "epub": None,
        "pdf": None,
        "mobi": None,
    }

    for score, book in scored:
        fmt = book.format.lower()
        if fmt in recommended and recommended[fmt] is None:
            recommended[fmt] = book

    return recommended


# ============================================================
# 搜索命令
# ============================================================

def build_query(args) -> Tuple[str, Dict[str, str]]:
    """从命令行参数构建搜索查询"""
    parts = []
    query_info = {}

    if hasattr(args, "query") and args.query:
        parts.append(args.query)
        query_info["raw_query"] = args.query
    else:
        if args.title:
            parts.append(args.title)
            query_info["title"] = args.title
        if args.author:
            parts.append(args.author)
            query_info["author"] = args.author
        if args.isbn:
            parts.append(args.isbn)
            query_info["isbn"] = args.isbn
        if args.publisher:
            parts.append(args.publisher)
            query_info["publisher"] = args.publisher

    query_str = " ".join(parts)
    query_info["combined_query"] = query_str
    return query_str, query_info


def cmd_search(args):
    """执行搜索命令"""
    query_str, query_info = build_query(args)

    if not query_str:
        print("错误: 请提供至少一个搜索条件 (--title, --author, --isbn, --query)", file=sys.stderr)
        sys.exit(1)

    if args.verbose:
        print(f"  🔍 搜索: {query_str}", file=sys.stderr)

    # 创建会话（搜索不需要代理）
    proxy_url = None
    if hasattr(args, "proxy") and args.proxy:
        proxy_url = args.proxy

    session = ZLibSession(proxy_url=proxy_url, verbose=args.verbose)

    try:
        results = session.search(query_str, max_results=args.max_results)
    except Exception as e:
        response = SearchResponse(
            status="error",
            query=query_info,
            total_results=0,
            results=[],
            recommended={},
            message=f"搜索出错: {e}",
        )
        if args.json:
            print(json.dumps(response.to_dict(), ensure_ascii=False, indent=2))
        else:
            print(f"❌ {response.message}", file=sys.stderr)
        sys.exit(1)

    if not results:
        response = SearchResponse(
            status="no_results",
            query=query_info,
            total_results=0,
            results=[],
            recommended={},
            message=f"未找到与 '{query_str}' 匹配的图书",
        )
        if args.json:
            print(json.dumps(response.to_dict(), ensure_ascii=False, indent=2))
        else:
            print(f"📭 {response.message}")
        return

    # 推荐
    recommended = recommend_books(
        results,
        title=args.title or "",
        author=args.author or "",
        language=getattr(args, "language", None),
    )

    # 构建消息
    rec_parts = []
    for fmt in ["epub", "pdf", "mobi"]:
        if recommended.get(fmt):
            r = recommended[fmt]
            rec_parts.append(f"{fmt} ({r.filesize}, Q:{r.quality}, {r.language})")
    rec_msg = ", ".join(rec_parts) if rec_parts else "无推荐"

    response = SearchResponse(
        status="success",
        query=query_info,
        total_results=len(results),
        results=results,
        recommended=recommended,
        message=f"找到 {len(results)} 个结果。推荐: {rec_msg}",
    )

    if args.json:
        print(json.dumps(response.to_dict(), ensure_ascii=False, indent=2))
    else:
        # 人类友好输出
        print(f"\n📚 搜索结果: {query_str}")
        print(f"   共 {len(results)} 个结果\n")

        # 推荐
        print("🏆 推荐版本:")
        for fmt in ["epub", "pdf", "mobi"]:
            r = recommended.get(fmt)
            if r:
                print(
                    f"   {fmt.upper():5s}  {r.title[:50]}  |  {r.author[:25]}  |  "
                    f"{r.filesize}  |  {r.language}  |  Q:{r.quality}  | dl={r.download_path}"
                )
            else:
                print(f"   {fmt.upper():5s}  (未找到)")

        print(f"\n{'─' * 120}")
        print(f"{'  #':>4s}  {'[格式]':>7s}  {'大小':>10s}  {'标题':<55s}  {'作者':<30s}  {'语言':<8s}  {'年份':<4s}")
        print(f"{'─' * 120}")

        for i, book in enumerate(results, 1):
            print(book.display_line(i))

        print(f"{'─' * 120}")
        print(f"\n💡 下载提示: zlib_dl.py download --dl-path \"/dl/xxx\" --title \"书名\"")


# ============================================================
# 下载命令
# ============================================================

def sanitize_filename(name: str) -> str:
    """清理文件名"""
    # 替换非法字符
    name = re.sub(r'[<>:"/\\|?*]', "_", name)
    name = re.sub(r"\s+", " ", name)
    return name.strip()[:200]


def cmd_download(args):
    """执行下载命令"""
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    # 代理管理器
    proxy_mgr = ProxyManager(
        proxy_file=getattr(args, "proxy_file", None), verbose=args.verbose
    )

    download_results: List[DownloadResult] = []

    try:
        # 模式 1: 直接指定下载路径
        if args.dl_path:
            dl_paths = args.dl_path if isinstance(args.dl_path, list) else [args.dl_path]
            title = args.title or "unknown"
            author = args.author or "unknown"
            fmt = getattr(args, 'fmt', '') or ''

            for dl_path in dl_paths:
                result = _download_one(
                    dl_path=dl_path,
                    title=title,
                    author=author,
                    fmt=fmt or "",
                    output_dir=output_dir,
                    proxy_mgr=proxy_mgr,
                    verbose=args.verbose,
                )
                download_results.append(result)

        # 模式 2: 搜索后自动选择并下载
        else:
            query_str, query_info = build_query(args)
            if not query_str:
                print(
                    "错误: 请提供搜索条件 (--title, --author) 或直接指定 --dl-path",
                    file=sys.stderr,
                )
                sys.exit(1)

            if args.verbose:
                print(f"  🔍 搜索: {query_str}", file=sys.stderr)

            # 搜索
            proxy_url = proxy_mgr.get_current_proxy_url()
            session = ZLibSession(proxy_url=proxy_url, verbose=args.verbose)
            results = session.search(query_str)

            if not results:
                print(f"📭 未找到结果: {query_str}", file=sys.stderr)
                if args.json:
                    print(json.dumps({"status": "no_results", "message": f"未找到: {query_str}"}, ensure_ascii=False))
                return

            # 推荐
            recommended = recommend_books(
                results,
                title=args.title or "",
                author=args.author or "",
            )

            # 下载推荐的 epub 和 pdf
            for fmt in ["epub", "pdf"]:
                book = recommended.get(fmt)
                if book and book.download_path:
                    result = _download_one(
                        dl_path=book.download_path,
                        page_url=book.page_url,
                        title=book.title,
                        author=book.author,
                        fmt=book.format,
                        output_dir=output_dir,
                        proxy_mgr=proxy_mgr,
                        verbose=args.verbose,
                    )
                    download_results.append(result)

            # 如果没有 epub 或 pdf, 尝试 mobi
            if not recommended.get("epub") and not recommended.get("pdf"):
                book = recommended.get("mobi")
                if book and book.download_path:
                    result = _download_one(
                        dl_path=book.download_path,
                        page_url=book.page_url,
                        title=book.title,
                        author=book.author,
                        fmt=book.format,
                        output_dir=output_dir,
                        proxy_mgr=proxy_mgr,
                        verbose=args.verbose,
                    )
                    download_results.append(result)

    finally:
        proxy_mgr.cleanup()

    # 输出结果
    if args.json:
        print(
            json.dumps(
                {
                    "status": "complete",
                    "downloads": [r.to_dict() for r in download_results],
                    "proxy_stats": {
                        "remaining_proxies": proxy_mgr.remaining_proxies(),
                        "current_proxy": proxy_mgr.get_current_proxy_name(),
                    },
                },
                ensure_ascii=False,
                indent=2,
            )
        )
    else:
        print(f"\n📊 下载完成:")
        for r in download_results:
            icon = "✅" if r.status == "success" else "❌"
            print(f"  {icon} [{r.format}] {r.title} - {r.message}")


def _download_one(
    dl_path: str,
    page_url: str,
    title: str,
    author: str,
    fmt: str,
    output_dir: Path,
    proxy_mgr: ProxyManager,
    verbose: bool,
) -> DownloadResult:
    """下载单个文件，带代理轮询逻辑"""

    # 确定文件扩展名
    if not fmt:
        # 尝试从路径推断
        fmt = "bin"

    # 构建文件名
    filename = sanitize_filename(f"{title} - {author}.{fmt}")
    output_path = str(output_dir / filename)

    # 检查是否已存在
    if os.path.exists(output_path):
        if verbose:
            print(f"  ⏩ 已存在: {filename}", file=sys.stderr)
        return DownloadResult(
            status="skipped",
            title=title,
            author=author,
            format=fmt,
            filesize="",
            filepath=output_path,
            message="文件已存在，跳过",
        )

    # 检查代理轮换
    if proxy_mgr.should_rotate():
        if not proxy_mgr.rotate():
            return DownloadResult(
                status="error",
                title=title,
                author=author,
                format=fmt,
                filesize="",
                filepath="",
                message="所有代理已用完，且当前代理下载次数已达上限",
            )

    proxy_url = proxy_mgr.get_current_proxy_url()
    proxy_name = proxy_mgr.get_current_proxy_name()

    if verbose:
        print(
            f"  ⬇️  下载: {filename} (代理: {proxy_name})", file=sys.stderr
        )

    # 完全创建新 Session，废弃之前的旧 Session 以获取全新的 CDN 下载授权
    session = ZLibSession(proxy_url=proxy_url, verbose=verbose)
    success, message = session.download_file(dl_path, page_url, output_path)

    if success:
        proxy_mgr.record_download()
        filesize = os.path.getsize(output_path)
        size_str = format_size(filesize)
        return DownloadResult(
            status="success",
            title=title,
            author=author,
            format=fmt,
            filesize=size_str,
            filepath=output_path,
            message=f"下载成功 ({size_str})",
            proxy_used=proxy_name,
        )
    else:
        # 如果是限制错误（达到配额）或遇到 503/403 等代理被屏蔽导致的无限验证循环，尝试切换代理重试
        msg_lower = message.lower()
        should_retry = (
            "限" in message or "limit" in msg_lower or 
            "503" in message or "403" in message or "502" in message or
            "timeout" in msg_lower or "请求失败" in message or "html" in msg_lower
        )
        
        if should_retry:
            if proxy_mgr.rotate():
                if verbose:
                    print(
                        f"  🔄 下载受阻 ({message})，切换代理重试: {proxy_mgr.get_current_proxy_name()}",
                        file=sys.stderr,
                    )
                return _download_one(
                    dl_path, page_url, title, author, fmt, output_dir, proxy_mgr, verbose
                )

        return DownloadResult(
            status="error",
            title=title,
            author=author,
            format=fmt,
            filesize="",
            filepath="",
            message=message,
        )

def _format_size(size_bytes: int) -> str:
    """格式化文件大小"""
    for unit in ["B", "KB", "MB", "GB"]:
        if size_bytes < 1024:
            return f"{size_bytes:.1f} {unit}"
        size_bytes /= 1024
    return f"{size_bytes:.1f} TB"


# ============================================================
# CLI 入口
# ============================================================

def main():
    parser = argparse.ArgumentParser(
        prog="zlib_dl",
        description="ZLibrary 电子书搜索与下载工具 (AI-friendly)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
使用示例:
  # 搜索（人类友好）
  %(prog)s search --title "Project Hail Mary" --author "Andy Weir"

  # 搜索（JSON输出，AI友好）
  %(prog)s search --title "Project Hail Mary" --author "Andy Weir" --json

  # 自由搜索
  %(prog)s search --query "我从达尔文那里学到的投资知识 普拉克·普拉萨德"

  # 自动搜索并下载最佳 epub + pdf
  %(prog)s download --title "Project Hail Mary" --author "Andy Weir"

  # 直接下载指定链接
  %(prog)s download --dl-path "/dl/O2pPQwkq2l" --title "Project Hail Mary" --format epub

  # 使用代理下载
  %(prog)s download --title "Project Hail Mary" --author "Andy Weir" --proxy-file proxies.txt
        """,
    )

    # 全局参数
    parser.add_argument("--json", action="store_true", help="JSON 格式输出（AI友好）")
    parser.add_argument("--verbose", "-v", action="store_true", help="详细输出")

    subparsers = parser.add_subparsers(dest="command", help="可用命令")

    # ---- search 子命令 ----
    search_p = subparsers.add_parser("search", help="搜索图书")
    search_p.add_argument("--title", "-t", default="", help="书名")
    search_p.add_argument("--author", "-a", default="", help="作者")
    search_p.add_argument("--isbn", default="", help="ISBN")
    search_p.add_argument("--publisher", default="", help="出版社")
    search_p.add_argument("--query", "-q", default="", help="自由搜索词")
    search_p.add_argument("--language", "-l", default=None, help="语言过滤 (English, Chinese)")
    search_p.add_argument("--max-results", type=int, default=50, help="最大结果数 (默认 50)")
    search_p.add_argument("--proxy", default=None, help="代理 URL (socks5://host:port)")

    # ---- download 子命令 ----
    dl_p = subparsers.add_parser("download", help="下载图书")
    dl_p.add_argument("--title", "-t", default="", help="书名")
    dl_p.add_argument("--author", "-a", default="", help="作者")
    dl_p.add_argument("--isbn", default="", help="ISBN")
    dl_p.add_argument("--publisher", default="", help="出版社")
    dl_p.add_argument("--query", "-q", default="", help="自由搜索词")
    dl_p.add_argument("--dl-path", nargs="*", default=None, help="直接指定下载路径 (/dl/xxx)")
    dl_p.add_argument("--format", dest="fmt", default="", help="指定格式 (epub, pdf)")
    dl_p.add_argument("--output-dir", "-o", default="./downloads", help="下载目录 (默认 ./downloads)")
    dl_p.add_argument("--proxy-file", "-p", default=None, help="代理列表文件")
    dl_p.add_argument("--proxy", default=None, help="单个代理 URL (socks5://host:port)")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(0)

    if args.command == "search":
        cmd_search(args)
    elif args.command == "download":
        cmd_download(args)


if __name__ == "__main__":
    main()
