#!/usr/bin/env python3
"""选股看板本地服务 —— 启动先更新一次，之后按间隔自动重跑，页面自动感知刷新。

行为：
  1. 启动 → 立即跑一次 picker + track（控制台打印进度）
  2. 起 HTTP 服务（只绑 127.0.0.1）→ 自动打开浏览器
  3. 后台线程每 --interval 秒重跑一次；跑完写 status，页面轮询到变化即重新渲染

接口：
  GET /api/status   返回 {last_run, last_error, running, next_run_in, interval, runs}

用法：
  python viewer_server.py --root <stock目录> --interval 600
  python viewer_server.py --root . --interval 600 --port 8765 --no-open
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
import threading
import time
import webbrowser
from datetime import datetime, timedelta
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

HERE = Path(__file__).resolve().parent
PICKER = HERE / "picker.py"
TRACK = HERE / "track.py"

STATE = {
    "last_run": None, "last_error": None, "running": False,
    "interval": 600, "next_run_at": None, "next_label": None, "runs": 0,
    "last_picks": 0, "last_duration": None, "mode": "starting",
}
_LOCK = threading.Lock()


CPU_GUARD = 3.5   # 系统 1 分钟负载高于此值时跳过本轮，避免把单核机器压死


def _load1() -> float:
    try:
        return float(open("/proc/loadavg").read().split()[0])
    except Exception:  # noqa: BLE001
        return 0.0


def run_update(root: Path, top: int, pool: int, guard: bool = True) -> None:
    """跑一次选股 + 跟踪。失败不影响已有数据（picker/track 都是跑完才落盘）。"""
    if not _LOCK.acquire(blocking=False):
        return
    if guard and _load1() > CPU_GUARD:
        _LOCK.release()
        print(f"[update] 系统负载 {_load1():.2f} 过高，跳过本轮", flush=True)
        return
    t0 = time.time()
    STATE["running"] = True
    try:
        py = sys.executable
        print(f"[update] 开始 {time.strftime('%H:%M:%S')}", flush=True)
        r1 = subprocess.run(
            [py, str(PICKER), "--out", str(root / "data" / "latest.json"),
             "--top", str(top), "--pool", str(pool)],
            capture_output=True, text=True, timeout=1800)
        if r1.returncode != 0:
            STATE["last_error"] = (r1.stderr or r1.stdout or "").strip()[-400:]
            print(f"[update] 选股失败: {STATE['last_error']}", flush=True)
            return
        r2 = subprocess.run([py, str(TRACK), "--data", str(root / "data")],
                            capture_output=True, text=True, timeout=900)
        if r2.returncode != 0:
            STATE["last_error"] = (r2.stderr or r2.stdout or "").strip()[-400:]
            print(f"[update] 跟踪失败: {STATE['last_error']}", flush=True)
            return
        try:
            n = len(json.loads((root / "data" / "latest.json").read_text("utf-8"))["picks"])
        except Exception:  # noqa: BLE001
            n = 0
        STATE.update(last_run=time.strftime("%Y-%m-%d %H:%M:%S"), last_error=None,
                     runs=STATE["runs"] + 1, last_picks=n,
                     last_duration=round(time.time() - t0, 1))
        print(f"[update] 完成 {time.strftime('%H:%M:%S')} · {n} 只入选 · "
              f"{STATE['last_duration']}s", flush=True)
    except subprocess.TimeoutExpired:
        STATE["last_error"] = "更新超时"
        print("[update] 超时", flush=True)
    except Exception as e:  # noqa: BLE001
        STATE["last_error"] = str(e)[:400]
        print(f"[update] 异常: {e}", flush=True)
    finally:
        STATE["running"] = False
        _LOCK.release()


HOLIDAYS: set[str] = set()   # 由 --root/data/holidays.txt 载入，一行一个 YYYY-MM-DD


def load_holidays(root: Path) -> int:
    f = root / "data" / "holidays.txt"
    if not f.exists():
        return 0
    for line in f.read_text("utf-8").splitlines():
        t = line.strip()
        if t and not t.startswith("#"):
            HOLIDAYS.add(t)
    return len(HOLIDAYS)


TRADE_OPEN = (9, 15)      # 集合竞价开始前，行情开始有参考意义
TRADE_CLOSE = (15, 5)     # 收盘后仍会有零星成交回报，留 5 分钟缓冲
CLOSE_TASK_END = (15, 40)  # 收盘更新窗口的截止时间


def _hm(t: tuple[int, int]) -> int:
    return t[0] * 60 + t[1]


def phase_of(now: datetime) -> str:
    """返回当前阶段：trading / close_task / idle / weekend。"""
    if now.weekday() >= 5 or now.strftime("%Y-%m-%d") in HOLIDAYS:
        return "weekend"
    m = now.hour * 60 + now.minute
    if _hm(TRADE_OPEN) <= m < _hm(TRADE_CLOSE):
        return "trading"
    if _hm(TRADE_CLOSE) <= m < _hm(CLOSE_TASK_END):
        return "close_task"
    return "idle"


def next_window(now: datetime) -> datetime:
    """下一次允许跑的时刻（跳过周末）。"""
    d = now.replace(minute=0, second=0, microsecond=0)
    if phase_of(now) in ("trading", "close_task"):
        return now
    cand = d.replace(hour=TRADE_OPEN[0], minute=TRADE_OPEN[1])
    if now >= cand:
        cand = cand + timedelta(days=1)
        cand = cand.replace(hour=TRADE_OPEN[0], minute=TRADE_OPEN[1])
    while cand.weekday() >= 5 or cand.strftime("%Y-%m-%d") in HOLIDAYS:
        cand += timedelta(days=1)
    return cand


def scheduler(root: Path, top: int, pool: int, interval: int, stop: threading.Event) -> None:
    """只在交易时段按间隔跑；收盘后跑一次；其余时间待机（周末完全不跑）。"""
    close_done: str | None = None      # 今日收盘更新是否已完成
    while not stop.is_set():
        now = datetime.now()
        ph = phase_of(now)
        STATE["mode"] = ph

        if ph == "trading":
            STATE["next_run_at"] = time.time() + interval
            STATE["next_label"] = None
            if stop.wait(interval):
                break
            run_update(root, top, pool)
            continue

        if ph == "close_task" and close_done != now.strftime("%Y-%m-%d"):
            print(f"[sched] 收盘更新 {now:%H:%M}", flush=True)
            run_update(root, top, pool)
            close_done = now.strftime("%Y-%m-%d")
            STATE["next_label"] = None
            continue

        # 待机：算出下一次开盘时刻，每分钟醒一次重新判断（处理跨日与时钟漂移）
        nxt = next_window(now)
        STATE["next_run_at"] = nxt.timestamp()
        STATE["next_label"] = nxt.strftime("%m-%d %H:%M")
        wait = min(60, max(1, int((nxt - now).total_seconds())))
        if stop.wait(wait):
            break
    STATE["next_run_at"] = None
    STATE["mode"] = "stopped"


class Handler(SimpleHTTPRequestHandler):
    def do_GET(self):  # noqa: N802
        # Caddy 的 on_demand TLS 会先问这里「这个域名能不能签证书」
        if self.path.split("?")[0] == "/api/allow":
            from urllib.parse import parse_qs, urlparse
            d = (parse_qs(urlparse(self.path).query).get("domain") or [""])[0]
            print(f"[tls] 允许为 {d or '(空)'} 签发证书", flush=True)
            raw = b"ok"
            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.send_header("Content-Length", str(len(raw)))
            self.end_headers()
            self.wfile.write(raw)
            return
        if self.path.split("?")[0] == "/api/status":
            body = dict(STATE)
            nr = STATE.get("next_run_at")
            body["next_run_in"] = max(0, int(nr - time.time())) if nr else None
            raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Length", str(len(raw)))
            self.end_headers()
            self.wfile.write(raw)
            return
        return super().do_GET()

    def end_headers(self):
        if self.path.endswith(".json") or self.path.startswith("/api/"):
            self.send_header("Cache-Control", "no-store")
        super().end_headers()

    def log_message(self, fmt, *args):  # 静音轮询与数据请求，避免刷屏
        try:
            msg = fmt % args
        except Exception:  # noqa: BLE001
            msg = str(fmt)
        if "/api/status" in msg or "/data/" in msg:
            return
        sys.stderr.write(f"{self.address_string()} - {msg}\n")


def worker(root: Path, top: int, pool: int, interval: int,
           stop: threading.Event, skip_first: bool) -> None:
    """先跑一次更新，再进入定时循环。服务在这条线程之外先起来，页面可即时打开。"""
    if not skip_first:
        run_update(root, top, pool, guard=False)   # 启动那次不受负载保护限制
    scheduler(root, top, pool, interval, stop)


def main(argv=None):
    ap = argparse.ArgumentParser(description="选股看板本地服务")
    ap.add_argument("--root", default=".", help="stock 目录（含 picks.html 与 data/）")
    ap.add_argument("--port", type=int, default=8765)
    ap.add_argument("--interval", type=int, default=600, help="自动更新间隔（秒）")
    ap.add_argument("--top", type=int, default=5)
    ap.add_argument("--pool", type=int, default=120)
    ap.add_argument("--no-open", action="store_true")
    ap.add_argument("--skip-first", action="store_true", help="跳过启动时的第一次更新")
    a = ap.parse_args(argv)

    root = Path(a.root).resolve()
    STATE["interval"] = a.interval
    n_hol = load_holidays(root)
    if n_hol:
        print(f"[sched] 已载入 {n_hol} 个休市日", flush=True)

    url = f"http://127.0.0.1:{a.port}/picks.html"
    handler = partial(Handler, directory=str(root))
    httpd = ThreadingHTTPServer(("127.0.0.1", a.port), handler)

    stop = threading.Event()
    th = threading.Thread(target=worker,
                          args=(root, a.top, a.pool, a.interval, stop, a.skip_first),
                          daemon=True)
    th.start()

    print(f"[serve] {url}  （每 {a.interval} 秒自动更新，Ctrl+C 停止）", flush=True)
    if not a.no_open:
        threading.Timer(0.8, lambda: webbrowser.open(url)).start()
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\n[serve] 已停止", flush=True)
    finally:
        stop.set()
        httpd.server_close()


if __name__ == "__main__":
    sys.exit(main())
