#!/usr/bin/env python3
"""Build a standalone Paper Atlas program that needs no Python installation.

Usage
-----
    python build.py                 # build the executable for this machine
    python build.py --zip           # also create a shareable .zip
    python build.py --check         # only report what the build would do
    python build.py --clean         # remove build/ and dist/ first

What it does
------------
1. Ensures PyInstaller is available (installs it into ``.venv`` if missing).
2. Runs PyInstaller with ``packaging/paper_atlas.spec``.
3. Reports the produced program and its size, and optionally packages it.

Important: the produced program only runs on the **same family of systems** it
was built on.  Build on Windows to get ``PaperAtlas.exe``, build on Ubuntu to
get the Linux binary (build on the oldest Ubuntu you must support for maximum
glibc compatibility).  ``.github/workflows/build.yml`` does both automatically.
"""
from __future__ import annotations

import argparse
import os
import platform
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parent
try:
    from atlas.core.config import APP_VERSION as PRODUCT_VERSION
except Exception:  # noqa: BLE001 - build must work from a partial checkout
    PRODUCT_VERSION = "1.5.0"
DIST = ROOT / "dist"
BUILD = ROOT / "build"
SPEC = ROOT / "packaging" / "paper_atlas.spec"
IS_WINDOWS = os.name == "nt"
EXE_NAME = "PaperAtlas.exe" if IS_WINDOWS else "PaperAtlas"
READ_ME = "使用说明-README.txt"

PYINSTALLER_REQUIREMENT = "pyinstaller>=6.3,<7"


def say(message: str = "") -> None:
    print(message, flush=True)


def step(message: str) -> None:
    say(f"  > {message}")


def ok(message: str) -> None:
    say(f"  + {message}")


def warn(message: str) -> None:
    say(f"  ! {message}")


def fail(message: str) -> None:
    say(f"  x {message}")


def venv_python() -> Path:
    return ROOT / (".venv/Scripts/python.exe" if IS_WINDOWS else ".venv/bin/python")


def host_python() -> str:
    """Prefer the project virtualenv, fall back to the running interpreter."""
    candidate = venv_python()
    return str(candidate) if candidate.is_file() else sys.executable


def pyinstaller_available(python: str) -> bool:
    result = subprocess.run([python, "-c", "import PyInstaller; print(PyInstaller.__version__)"],
                            capture_output=True, text=True)
    if result.returncode == 0:
        ok(f"PyInstaller {result.stdout.strip()} found in {python}")
        return True
    return False


def ensure_pyinstaller(python: str, allow_install: bool) -> bool:
    if pyinstaller_available(python):
        return True
    if not allow_install:
        fail("PyInstaller is missing and --no-install was given.")
        return False
    step(f"Installing {PYINSTALLER_REQUIREMENT} (one-off, stays in .venv) ...")
    result = subprocess.run([python, "-m", "pip", "install", "--upgrade", PYINSTALLER_REQUIREMENT])
    if result.returncode != 0:
        fail("Could not install PyInstaller. Check your network / proxy, then retry.")
        return False
    return pyinstaller_available(python)


def report_plan() -> None:
    say("Build plan")
    say(f"    host system     : {platform.platform()}")
    say(f"    python          : {sys.version.split()[0]}  ({sys.executable})")
    say(f"    project         : {ROOT}")
    say(f"    spec file       : {SPEC}")
    say(f"    output          : dist/{EXE_NAME}")
    say(f"    bundles data/   : templates/, static/")
    say(f"    data at runtime : <folder of the program>/data  (falls back to the user folder)")
    say("")
    say("    note: a Windows .exe must be built on Windows, a Linux binary on Linux.")


def run_pyinstaller(python: str, clean: bool) -> bool:
    if clean:
        for target in (BUILD, DIST):
            shutil.rmtree(target, ignore_errors=True)
    step("Running PyInstaller (this can take a few minutes) ...")
    result = subprocess.run(
        [python, "-m", "PyInstaller", "--clean", "--noconfirm", "--distpath", str(DIST),
         "--workpath", str(BUILD), str(SPEC)],
        cwd=str(ROOT),
    )
    return result.returncode == 0


def write_readme(target_dir: Path) -> Path:
    text = f"""Paper Atlas - arXiv + Literature Review v{PRODUCT_VERSION}(免安装版)
=================================================

这是已经打包好的独立程序,不需要安装 Python,也不需要联网安装任何依赖。

Windows
-------
1. 双击  {EXE_NAME}
2. 稍等几秒,会自动打开浏览器(地址 http://127.0.0.1:8765)
3. 关闭那个黑色命令行窗口即可停止服务

Ubuntu / Linux
--------------
1. 打开终端,进入本文件夹
2. 赋予执行权限(只需一次):  chmod +x {EXE_NAME}
3. 运行:  ./{EXE_NAME}
   (也可以双击文件夹里的 start-paper-atlas.sh)

第一次使用
----------
打开网页后进入「设置 → 智能检索模型」:
  · DeepSeek 云端 API:填入 API Key(需要联网)
  · Ollama 本地模型:先在终端执行 ollama pull qwen2.5:7b,然后在面板里选择该模型
不配置也可以直接用普通关键词检索。

数据保存在哪里
--------------
本程序所在文件夹下的 data\\ 目录:
  · data\\config.json   你的设置(含 API Key)
  · data\\pdfs\\        下载的 PDF
想换电脑,把整个文件夹拷过去即可;想彻底卸载,删掉文件夹即可。

常用命令
--------
  {EXE_NAME} --check          查看环境体检报告
  {EXE_NAME} --port 9000      换端口
  {EXE_NAME} --no-open        不自动打开浏览器
  {EXE_NAME} --prepare-only   只创建数据目录,不启动

注意事项
--------
· 本程序为当前平台构建(Windows 版只能在 Windows 运行,Linux 版同理)。
· Linux 版建议在目标系统或更旧的发行版上构建,以获得最好的兼容性。
· 若端口被占用,用 --port 换一个;若浏览器没弹出来,手动访问提示里的地址。
· 杀毒软件可能对 PyInstaller 打包的程序误报,必要时加入白名单。
"""
    target = target_dir / READ_ME
    target.write_text(text, encoding="utf-8")
    return target


def make_zip() -> Path | None:
    exe = DIST / EXE_NAME
    if not exe.is_file():
        fail("No executable to package.")
        return None
    write_readme(DIST)
    archive = ROOT / f"PaperAtlas-{platform.system().lower()}-{platform.machine()}.zip"
    step(f"Packaging {archive.name} ...")
    with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
        bundle.write(exe, f"PaperAtlas/{EXE_NAME}")
        bundle.write(DIST / READ_ME, f"PaperAtlas/{READ_ME}")
    ok(f"Created {archive} ({archive.stat().st_size / 1048576:.1f} MB)")
    return archive


def main() -> int:
    parser = argparse.ArgumentParser(description="Build a standalone Paper Atlas program.")
    parser.add_argument("--zip", action="store_true", help="also create a shareable .zip")
    parser.add_argument("--clean", action="store_true", help="remove build/ and dist/ first")
    parser.add_argument("--check", action="store_true", help="only report the build plan")
    parser.add_argument("--no-install", action="store_true", help="never install PyInstaller")
    args = parser.parse_args()

    say()
    say("Paper Atlas - standalone build")
    say()
    report_plan()
    if args.check:
        return 0

    if not SPEC.is_file():
        fail(f"Spec file not found: {SPEC}")
        return 1

    python = host_python()
    say("")
    if not ensure_pyinstaller(python, allow_install=not args.no_install):
        return 1

    say("")
    if not run_pyinstaller(python, args.clean):
        fail("PyInstaller failed - see the output above.")
        return 1

    exe = DIST / EXE_NAME
    if not exe.is_file():
        fail(f"Build finished but {exe} is missing.")
        return 1

    try:
        size = exe.stat().st_size / 1048576
    except OSError:
        size = 0.0
    say("")
    ok(f"Built: {exe}  ({size:.1f} MB)")
    ok(f"Platform: {platform.platform()}")
    say("")
    say(f"    Try it:  {exe}" + ("" if IS_WINDOWS else " --check"))
    say("    Data (settings + PDFs) will live in a 'data' folder next to the program.")
    if args.zip:
        say("")
        make_zip()
    say("")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())