#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ZSGLPT Update-Agent（宿主机运行）

职责：
- 定期检查 Git 远端是否有新版本（写入 data/update/status.json）
- 接收后台写入的 data/update/request.json 请求（check/update）
- 执行 git reset --hard origin/<branch> + docker compose build/up
- 更新前备份数据库 data/app_data.db
- 写入 data/update/result.json 与 data/update/jobs/<job_id>.log

仅使用标准库，便于在宿主机直接运行。
"""

from __future__ import annotations

import argparse
import fnmatch
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Tuple


def ts_str() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def json_load(path: Path) -> Tuple[dict, Optional[str]]:
    try:
        with open(path, "r", encoding="utf-8") as f:
            return dict(json.load(f) or {}), None
    except FileNotFoundError:
        return {}, None
    except Exception as e:
        return {}, f"{type(e).__name__}: {e}"


def json_dump_atomic(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(f"{path.suffix}.tmp.{os.getpid()}.{int(time.time() * 1000)}")
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, path)


def sanitize_job_id(value: object) -> str:
    import re

    text = str(value or "").strip()
    if not text:
        return f"job_{uuid.uuid4().hex[:8]}"
    if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", text):
        return f"job_{uuid.uuid4().hex[:8]}"
    return text


def _as_bool(value: object) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, int):
        return value != 0
    text = str(value or "").strip().lower()
    return text in ("1", "true", "yes", "y", "on")


def _run(cmd: list[str], *, cwd: Path, log_fp, env: Optional[dict] = None, check: bool = True) -> subprocess.CompletedProcess:
    log_fp.write(f"[{ts_str()}] $ {' '.join(cmd)}\n")
    log_fp.flush()
    merged_env = os.environ.copy()
    if env:
        merged_env.update(env)
    return subprocess.run(
        cmd,
        cwd=str(cwd),
        env=merged_env,
        stdout=log_fp,
        stderr=log_fp,
        text=True,
        check=check,
    )


def _git_rev_parse(ref: str, *, cwd: Path) -> str:
    out = subprocess.check_output(["git", "rev-parse", ref], cwd=str(cwd), text=True).strip()
    return out


def _git_has_tracked_changes(*, cwd: Path) -> bool:
    """是否存在 tracked 的未提交修改（含暂存区）。"""
    for cmd in (["git", "diff", "--quiet"], ["git", "diff", "--cached", "--quiet"]):
        proc = subprocess.run(cmd, cwd=str(cwd))
        if proc.returncode == 1:
            return True
        if proc.returncode != 0:
            raise RuntimeError(f"{' '.join(cmd)} failed with code {proc.returncode}")
    return False


def _normalize_prefixes(prefixes: Tuple[str, ...]) -> Tuple[str, ...]:
    normalized = []
    for p in prefixes:
        text = str(p or "").strip()
        if not text:
            continue
        if not text.endswith("/"):
            text += "/"
        normalized.append(text)
    return tuple(normalized)


def _git_has_untracked_changes(*, cwd: Path, ignore_prefixes: Tuple[str, ...]) -> Tuple[bool, int, list[str]]:
    """检查 untracked 文件（尊重 .gitignore），并忽略指定前缀目录。"""
    return _git_has_untracked_changes_v2(cwd=cwd, ignore_prefixes=ignore_prefixes, ignore_globs=())


def _normalize_globs(globs: Tuple[str, ...]) -> Tuple[str, ...]:
    normalized = []
    for g in globs:
        text = str(g or "").strip()
        if not text:
            continue
        normalized.append(text)
    return tuple(normalized)


def _git_has_untracked_changes_v2(
    *, cwd: Path, ignore_prefixes: Tuple[str, ...], ignore_globs: Tuple[str, ...]
) -> Tuple[bool, int, list[str]]:
    """检查 untracked 文件（尊重 .gitignore），并忽略指定前缀目录/通配符。"""
    ignore_prefixes = _normalize_prefixes(ignore_prefixes)
    ignore_globs = _normalize_globs(ignore_globs)
    out = subprocess.check_output(["git", "ls-files", "--others", "--exclude-standard"], cwd=str(cwd), text=True)
    paths = [line.strip() for line in out.splitlines() if line.strip()]

    filtered = []
    for p in paths:
        if ignore_prefixes and any(p.startswith(prefix) for prefix in ignore_prefixes):
            continue
        if ignore_globs and any(fnmatch.fnmatch(p, pattern) for pattern in ignore_globs):
            continue
        filtered.append(p)

    samples = filtered[:20]
    return (len(filtered) > 0), len(filtered), samples


def _git_is_dirty(
    *,
    cwd: Path,
    ignore_untracked_prefixes: Tuple[str, ...] = ("data/",),
    ignore_untracked_globs: Tuple[str, ...] = ("*.bak.*", "*.tmp.*", "*.backup.*"),
) -> dict:
    """
    判断工作区是否“脏”：
    - tracked 变更（含暂存区）一律算脏
    - untracked 文件默认忽略 data/（运行时数据目录，避免后台长期提示）
    """
    tracked_dirty = False
    untracked_dirty = False
    untracked_count = 0
    untracked_samples: list[str] = []
    try:
        tracked_dirty = _git_has_tracked_changes(cwd=cwd)
    except Exception:
        # 若 diff 检测异常，回退到保守策略：认为脏
        tracked_dirty = True

    try:
        untracked_dirty, untracked_count, untracked_samples = _git_has_untracked_changes_v2(
            cwd=cwd, ignore_prefixes=ignore_untracked_prefixes, ignore_globs=ignore_untracked_globs
        )
    except Exception:
        # 若 untracked 检测异常，回退到不影响更新：不计入 dirty
        untracked_dirty = False
        untracked_count = 0
        untracked_samples = []

    return {
        "dirty": bool(tracked_dirty or untracked_dirty),
        "dirty_tracked": bool(tracked_dirty),
        "dirty_untracked": bool(untracked_dirty),
        "dirty_ignore_untracked_prefixes": list(_normalize_prefixes(ignore_untracked_prefixes)),
        "dirty_ignore_untracked_globs": list(_normalize_globs(ignore_untracked_globs)),
        "untracked_count": int(untracked_count),
        "untracked_samples": list(untracked_samples),
    }


def _compose_cmd() -> list[str]:
    # 优先使用 docker compose（v2）
    try:
        subprocess.check_output(["docker", "compose", "version"], stderr=subprocess.STDOUT, text=True)
        return ["docker", "compose"]
    except Exception:
        return ["docker-compose"]


def _http_healthcheck(url: str, *, timeout: float = 5.0) -> Tuple[bool, str]:
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "zsglpt-update-agent/1.0"})
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            code = int(getattr(resp, "status", 200) or 200)
            if 200 <= code < 400:
                return True, f"HTTP {code}"
            return False, f"HTTP {code}"
    except urllib.error.HTTPError as e:
        return False, f"HTTPError {e.code}"
    except Exception as e:
        return False, f"{type(e).__name__}: {e}"


@dataclass
class Paths:
    repo_dir: Path
    data_dir: Path
    update_dir: Path
    status_path: Path
    request_path: Path
    result_path: Path
    jobs_dir: Path


def build_paths(repo_dir: Path, data_dir: Optional[Path] = None) -> Paths:
    repo_dir = repo_dir.resolve()
    data_dir = (data_dir or (repo_dir / "data")).resolve()
    update_dir = data_dir / "update"
    return Paths(
        repo_dir=repo_dir,
        data_dir=data_dir,
        update_dir=update_dir,
        status_path=update_dir / "status.json",
        request_path=update_dir / "request.json",
        result_path=update_dir / "result.json",
        jobs_dir=update_dir / "jobs",
    )


def ensure_dirs(paths: Paths) -> None:
    paths.jobs_dir.mkdir(parents=True, exist_ok=True)


def check_updates(*, paths: Paths, branch: str, log_fp=None) -> dict:
    env = {"GIT_TERMINAL_PROMPT": "0"}
    err = ""
    local = ""
    remote = ""
    dirty_info: dict = {}
    try:
        if log_fp:
            _run(["git", "fetch", "origin", branch], cwd=paths.repo_dir, log_fp=log_fp, env=env)
        else:
            subprocess.run(["git", "fetch", "origin", branch], cwd=str(paths.repo_dir), env={**os.environ, **env}, check=True)
        local = _git_rev_parse("HEAD", cwd=paths.repo_dir)
        remote = _git_rev_parse(f"origin/{branch}", cwd=paths.repo_dir)
        dirty_info = _git_is_dirty(cwd=paths.repo_dir, ignore_untracked_prefixes=("data/",))
    except Exception as e:
        err = f"{type(e).__name__}: {e}"

    update_available = bool(local and remote and local != remote) if not err else False
    return {
        "branch": branch,
        "checked_at": ts_str(),
        "local_commit": local,
        "remote_commit": remote,
        "update_available": update_available,
        **(dirty_info or {"dirty": False}),
        "error": err,
    }


def backup_db(*, paths: Paths, log_fp, keep: int = 20) -> str:
    db_path = paths.data_dir / "app_data.db"
    backups_dir = paths.data_dir / "backups"
    backups_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_path = backups_dir / f"app_data.db.{stamp}.bak"

    if db_path.exists():
        log_fp.write(f"[{ts_str()}] backup db: {db_path} -> {backup_path}\n")
        log_fp.flush()
        shutil.copy2(db_path, backup_path)
    else:
        log_fp.write(f"[{ts_str()}] backup skipped: db not found: {db_path}\n")
        log_fp.flush()

    # 简单保留策略：按文件名排序，保留最近 keep 个
    try:
        items = sorted([p for p in backups_dir.glob("app_data.db.*.bak") if p.is_file()], key=lambda p: p.name)
        if len(items) > keep:
            for p in items[: len(items) - keep]:
                try:
                    p.unlink()
                except Exception:
                    pass
    except Exception:
        pass

    return str(backup_path)


def write_result(paths: Paths, data: dict) -> None:
    json_dump_atomic(paths.result_path, data)


def consume_request(paths: Paths) -> Tuple[dict, Optional[str]]:
    data, err = json_load(paths.request_path)
    if err:
        # 避免解析失败导致死循环：将坏文件移走
        try:
            bad_name = f"request.bad.{datetime.now().strftime('%Y%m%d_%H%M%S')}.{uuid.uuid4().hex[:6]}.json"
            bad_path = paths.update_dir / bad_name
            paths.request_path.rename(bad_path)
        except Exception:
            try:
                paths.request_path.unlink(missing_ok=True)  # type: ignore[arg-type]
            except Exception:
                pass
        return {}, err
    if not data:
        return {}, None
    try:
        paths.request_path.unlink(missing_ok=True)  # type: ignore[arg-type]
    except Exception:
        try:
            os.remove(paths.request_path)
        except Exception:
            pass
    return data, None


def handle_update_job(
    *,
    paths: Paths,
    branch: str,
    health_url: str,
    job_id: str,
    requested_by: str,
    build_no_cache: bool = False,
    build_pull: bool = False,
) -> None:
    ensure_dirs(paths)
    log_path = paths.jobs_dir / f"{job_id}.log"
    with open(log_path, "a", encoding="utf-8") as log_fp:
        log_fp.write(f"[{ts_str()}] job start: {job_id}, branch={branch}, by={requested_by}\n")
        log_fp.flush()

        result: Dict[str, object] = {
            "job_id": job_id,
            "action": "update",
            "status": "running",
            "stage": "start",
            "message": "",
            "started_at": ts_str(),
            "finished_at": None,
            "duration_seconds": None,
            "requested_by": requested_by,
            "branch": branch,
            "build_no_cache": bool(build_no_cache),
            "build_pull": bool(build_pull),
            "from_commit": None,
            "to_commit": None,
            "backup_db": None,
            "health_url": health_url,
            "health_ok": None,
            "health_message": None,
            "error": "",
        }
        write_result(paths, result)

        start_ts = time.time()
        try:
            result["stage"] = "backup"
            result["message"] = "备份数据库"
            write_result(paths, result)
            result["backup_db"] = backup_db(paths=paths, log_fp=log_fp)

            result["stage"] = "git_fetch"
            result["message"] = "拉取远端代码"
            write_result(paths, result)
            _run(["git", "fetch", "origin", branch], cwd=paths.repo_dir, log_fp=log_fp, env={"GIT_TERMINAL_PROMPT": "0"})

            from_commit = _git_rev_parse("HEAD", cwd=paths.repo_dir)
            result["from_commit"] = from_commit

            result["stage"] = "git_reset"
            result["message"] = f"切换到 origin/{branch}"
            write_result(paths, result)
            _run(["git", "reset", "--hard", f"origin/{branch}"], cwd=paths.repo_dir, log_fp=log_fp, env={"GIT_TERMINAL_PROMPT": "0"})

            to_commit = _git_rev_parse("HEAD", cwd=paths.repo_dir)
            result["to_commit"] = to_commit

            compose = _compose_cmd()
            result["stage"] = "docker_build"
            result["message"] = "构建容器镜像"
            write_result(paths, result)
            build_no_cache = bool(result.get("build_no_cache") is True)
            build_pull = bool(result.get("build_pull") is True)

            build_cmd = [*compose, "build"]
            if build_pull:
                build_cmd.append("--pull")
            if build_no_cache:
                build_cmd.append("--no-cache")
            try:
                _run(build_cmd, cwd=paths.repo_dir, log_fp=log_fp)
            except subprocess.CalledProcessError as e:
                if (not build_no_cache) and (e.returncode != 0):
                    log_fp.write(f"[{ts_str()}] build failed, retry with --no-cache\n")
                    log_fp.flush()
                    build_no_cache = True
                    result["build_no_cache"] = True
                    write_result(paths, result)
                    retry_cmd = [*compose, "build"]
                    if build_pull:
                        retry_cmd.append("--pull")
                    retry_cmd.append("--no-cache")
                    _run(retry_cmd, cwd=paths.repo_dir, log_fp=log_fp)
                else:
                    raise

            result["stage"] = "docker_up"
            result["message"] = "重建并启动服务"
            write_result(paths, result)
            _run([*compose, "up", "-d", "--force-recreate"], cwd=paths.repo_dir, log_fp=log_fp)

            result["stage"] = "health_check"
            result["message"] = "健康检查"
            write_result(paths, result)

            ok = False
            health_msg = ""
            deadline = time.time() + 180
            while time.time() < deadline:
                ok, health_msg = _http_healthcheck(health_url, timeout=5.0)
                if ok:
                    break
                time.sleep(3)

            result["health_ok"] = ok
            result["health_message"] = health_msg
            if not ok:
                raise RuntimeError(f"healthcheck failed: {health_msg}")

            result["status"] = "success"
            result["stage"] = "done"
            result["message"] = "更新完成"
        except Exception as e:
            result["status"] = "failed"
            result["error"] = f"{type(e).__name__}: {e}"
            result["stage"] = result.get("stage") or "failed"
            result["message"] = "更新失败"
            log_fp.write(f"[{ts_str()}] ERROR: {result['error']}\n")
            log_fp.flush()
        finally:
            result["finished_at"] = ts_str()
            result["duration_seconds"] = int(time.time() - start_ts)
            write_result(paths, result)

            # 更新 status（成功/失败都尽量写一份最新状态）
            try:
                status = check_updates(paths=paths, branch=branch, log_fp=log_fp)
                json_dump_atomic(paths.status_path, status)
            except Exception:
                pass

            log_fp.write(f"[{ts_str()}] job end: {job_id}\n")
            log_fp.flush()


def handle_check_job(*, paths: Paths, branch: str, job_id: str, requested_by: str) -> None:
    ensure_dirs(paths)
    log_path = paths.jobs_dir / f"{job_id}.log"
    with open(log_path, "a", encoding="utf-8") as log_fp:
        log_fp.write(f"[{ts_str()}] job start: {job_id}, action=check, branch={branch}, by={requested_by}\n")
        log_fp.flush()
        status = check_updates(paths=paths, branch=branch, log_fp=log_fp)
        json_dump_atomic(paths.status_path, status)
        log_fp.write(f"[{ts_str()}] job end: {job_id}\n")
        log_fp.flush()


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description="ZSGLPT Update-Agent (host)")
    parser.add_argument("--repo-dir", default=".", help="部署仓库目录（包含 docker-compose.yml）")
    parser.add_argument("--data-dir", default="", help="数据目录（默认 <repo>/data）")
    parser.add_argument("--branch", default="master", help="允许更新的分支名（默认 master）")
    parser.add_argument("--health-url", default="http://127.0.0.1:51232/", help="更新后健康检查URL")
    parser.add_argument("--check-interval-seconds", type=int, default=300, help="自动检查更新间隔（秒）")
    parser.add_argument("--poll-seconds", type=int, default=5, help="轮询 request.json 的间隔（秒）")
    args = parser.parse_args(argv)

    repo_dir = Path(args.repo_dir).resolve()
    if not (repo_dir / "docker-compose.yml").exists():
        print(f"[fatal] docker-compose.yml not found in {repo_dir}", file=sys.stderr)
        return 2
    if not (repo_dir / ".git").exists():
        print(f"[fatal] .git not found in {repo_dir} (need git repo)", file=sys.stderr)
        return 2

    data_dir = Path(args.data_dir).resolve() if args.data_dir else None
    paths = build_paths(repo_dir, data_dir=data_dir)
    ensure_dirs(paths)

    last_check_ts = 0.0
    check_interval = max(30, int(args.check_interval_seconds))
    poll_seconds = max(2, int(args.poll_seconds))
    branch = str(args.branch or "master").strip()
    health_url = str(args.health_url or "").strip()

    # 启动时先写一次状态，便于后台立即看到
    try:
        status = check_updates(paths=paths, branch=branch)
        json_dump_atomic(paths.status_path, status)
        last_check_ts = time.time()
    except Exception:
        pass

    while True:
        try:
            # 1) 优先处理 request
            req, err = consume_request(paths)
            if err:
                # request 文件损坏：写入 result 便于后台看到
                write_result(
                    paths,
                    {
                        "job_id": f"badreq_{uuid.uuid4().hex[:8]}",
                        "action": "unknown",
                        "status": "failed",
                        "stage": "parse_request",
                        "message": "request.json 解析失败",
                        "error": err,
                        "started_at": ts_str(),
                        "finished_at": ts_str(),
                    },
                )
            elif req:
                action = str(req.get("action") or "").strip().lower()
                job_id = sanitize_job_id(req.get("job_id"))
                requested_by = str(req.get("requested_by") or "")

                # 只允许固定分支，避免被注入/误操作
                if action not in ("check", "update"):
                    write_result(
                        paths,
                        {
                            "job_id": job_id,
                            "action": action,
                            "status": "failed",
                            "stage": "validate",
                            "message": "不支持的 action",
                            "error": f"unsupported action: {action}",
                            "started_at": ts_str(),
                            "finished_at": ts_str(),
                        },
                    )
                elif action == "check":
                    handle_check_job(paths=paths, branch=branch, job_id=job_id, requested_by=requested_by)
                else:
                    build_no_cache = _as_bool(req.get("build_no_cache") or req.get("no_cache") or False)
                    build_pull = _as_bool(req.get("build_pull") or req.get("pull") or False)
                    handle_update_job(
                        paths=paths,
                        branch=branch,
                        health_url=health_url,
                        job_id=job_id,
                        requested_by=requested_by,
                        build_no_cache=build_no_cache,
                        build_pull=build_pull,
                    )

                last_check_ts = time.time()

            # 2) 周期性 check
            now = time.time()
            if now - last_check_ts >= check_interval:
                try:
                    status = check_updates(paths=paths, branch=branch)
                    json_dump_atomic(paths.status_path, status)
                except Exception:
                    pass
                last_check_ts = now

            time.sleep(poll_seconds)
        except KeyboardInterrupt:
            return 0
        except Exception:
            time.sleep(2)


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
