#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""批量压测结果 xlsx 实时同步至 GitCode 仓(Alice_zhou/issue)

多机实时汇聚:任何机器(本地压测机 / 远程压测机 / 其他执行机)每完成一个场景压测更新本地
out_res/批量压测结果.xlsx 后,立即将结果合并推送到 https://gitcode.com/Alice_zhou/issue 仓,
仓内路径默认根目录 `批量压测结果.xlsx`(config/issue_tracker.json 的 batch_result_repo_path 可配)。
不论压测任务由哪台机器拉起,仓内始终只有一份汇聚后的最新结果表。

合并语义(多机安全,不丢任何机器的数据):
  - 行键 = 接口名 + 测试场景(与本地 upsert 语义一致)
  - 同步时先 GET 远端 xlsx → 以远端为基底,将本机行 upsert 合并进去(键冲突时本机行优先,
    本机行是刚压测完的最新结果)
  - 远端存在而本机没有的行(其他机器写入的数据)原样保留,禁止整表覆盖
  - PUT 携带远端文件 sha(乐观锁);多机并发推送冲突(409/sha 失配)时自动重拉远端重新
    合并重试(最多 --retries 次),保证最后推送者包含全部机器的行
  - 远端文件不存在(首次同步)时 POST 创建

API(已实测验证,同 scripts/upload_to_issue_repo.py):
  - GET  {api_base}/repos/{owner}/{repo}/contents/{path}?ref={branch}
  - POST {api_base}/repos/{owner}/{repo}/contents/{path}   JSON {content(base64), message, branch}
  - PUT  {api_base}/repos/{owner}/{repo}/contents/{path}   JSON {content, message, branch, sha}
  - 认证:PRIVATE-TOKEN 头(config/issue_tracker.json access_token,支持 ${GITCODE_TOKEN})

用法:
  python scripts/sync_batch_results_git.py                       # 同步本地默认 xlsx(手动补推)
  python scripts/sync_batch_results_git.py --xlsx out_res/批量压测结果.xlsx --json
  python scripts/sync_batch_results_git.py --repo_path results/批量压测结果.xlsx   # 自定义仓内路径

在 scripts/update_batch_results.py 中自动调用:每次本地表格更新成功后实时推送到 GitCode 仓
(--no_git_sync 可跳过本次;config/issue_tracker.json 的 batch_result_git_sync=false 可全局关闭;
环境变量 BATCH_RESULT_GIT_SYNC=0/1 优先级介于两者之间)。
"""
import argparse
import base64
import io
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

try:
    import openpyxl
except ImportError:
    openpyxl = None

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")

DEFAULT_CONFIG = "config/issue_tracker.json"
DEFAULT_XLSX = os.path.join("out_res", "批量压测结果.xlsx")
DEFAULT_REPO_PATH = "批量压测结果.xlsx"
MAX_FILE_BYTES = 50 * 1024 * 1024  # GitCode 单文件 50MB 上限

HEADERS = [
    "接口名",
    "测试场景",
    "压测总条数",
    "实际执行成功条数",
    "实际执行失败条数",
    "执行失败原因",
    "是否存在bug",
    "是否存在aicore",
    "执行卡号",
    "更新时间",
    "是否分析完成",
    "问题单",
]


def log(tag, msg):
    print("[{}] {}".format(tag, msg))


class GitCodeContentsClient:
    """GitCode 仓文件操作客户端(复用 issue_tracker.json 认证与 issue 附件仓配置)。"""

    def __init__(self, config_path=DEFAULT_CONFIG):
        if not os.path.isfile(config_path):
            raise RuntimeError("配置文件不存在: {}(参考 config/issue_tracker.example.json)".format(config_path))
        cfg = json.load(open(config_path, encoding="utf-8"))
        self.api_base = cfg.get("api_base", "https://gitcode.com/api/v5").rstrip("/")
        self.token = cfg.get("access_token", "")
        if self.token.startswith("${") and self.token.endswith("}"):
            self.token = os.environ.get(self.token[2:-1], "")
        if not self.token:
            raise RuntimeError("access_token 未配置(config/issue_tracker.json 或环境变量)")
        self.repo_owner = cfg.get("issue_repo_owner", "Alice_zhou")
        self.repo_name = cfg.get("issue_repo_name", "issue")
        self.branch = cfg.get("issue_repo_branch", "main")
        self.repo_path = cfg.get("batch_result_repo_path", DEFAULT_REPO_PATH)

    def _url(self, path):
        quoted = urllib.parse.quote(path.strip("/"), safe="/")
        return "{}/repos/{}/{}/contents/{}".format(
            self.api_base, self.repo_owner, self.repo_name, quoted)

    def _request(self, path, method="GET", data=None):
        body = json.dumps(data).encode("utf-8") if data else None
        req = urllib.request.Request(
            self._url(path) + ("?ref=" + urllib.parse.quote(self.branch) if method == "GET" else ""),
            data=body, method=method,
            headers={"PRIVATE-TOKEN": self.token, "Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=120) as resp:
                return resp.status, json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            try:
                return exc.code, json.loads(exc.read().decode("utf-8"))
            except Exception:
                return exc.code, {}
        except Exception as exc:
            return -1, {"error_message": str(exc)}

    def get_file(self, path):
        return self._request(path, "GET")

    def create_file(self, path, content_b64, message):
        return self._request(path, "POST", {
            "content": content_b64, "message": message, "branch": self.branch})

    def update_file(self, path, content_b64, message, sha):
        return self._request(path, "PUT", {
            "content": content_b64, "message": message, "branch": self.branch, "sha": sha})


def _cell_str(value):
    return str(value).strip() if value is not None else ""


def _row_key(ws, row):
    return (_cell_str(ws.cell(row=row, column=1).value),
            _cell_str(ws.cell(row=row, column=2).value))


def merge_local_into_remote(remote_bytes, local_xlsx):
    """以远端 xlsx 为基底合并本机行(行键=接口名+测试场景,本机行优先),返回合并后 xlsx 字节。

    远端存在而本机没有的行原样保留(其他机器的数据不被覆盖);本机行覆盖远端同键行
    (本机行是本次刚压测完的最新结果)。远端解析失败时抛异常(禁止用本机整表覆盖远端,
    防止丢其他机器数据)。
    """
    if openpyxl is None:
        raise RuntimeError("openpyxl 未安装,无法合并(pip install openpyxl)")
    remote_wb = openpyxl.load_workbook(io.BytesIO(remote_bytes))
    remote_ws = remote_wb.active
    local_wb = openpyxl.load_workbook(local_xlsx)
    local_ws = local_wb.active

    # 远端表头对齐(缺失则补齐,不破坏已有数据行)
    if remote_ws.max_row < 1 or _cell_str(remote_ws.cell(row=1, column=1).value) != HEADERS[0]:
        if remote_ws.max_row >= 1 and _cell_str(remote_ws.cell(row=1, column=1).value) == "":
            header_row = 1
        else:
            header_row = remote_ws.max_row + 1
        for col_idx, name in enumerate(HEADERS, start=1):
            remote_ws.cell(row=header_row, column=col_idx, value=name)

    # 远端行键 → 行号映射
    remote_map = {}
    for row in range(2, remote_ws.max_row + 1):
        key = _row_key(remote_ws, row)
        if key[0]:
            remote_map[key] = row

    merged, appended = 0, 0
    for row in range(2, local_ws.max_row + 1):
        key = _row_key(local_ws, row)
        if not key[0]:
            continue
        if key in remote_map:
            target = remote_map[key]
            merged += 1
        else:
            target = remote_ws.max_row + 1
            appended += 1
        for col_idx in range(1, len(HEADERS) + 1):
            value = local_ws.cell(row=row, column=col_idx).value
            cell = remote_ws.cell(row=target, column=col_idx, value=value)
            if col_idx == 12 and value and _cell_str(value).startswith("http"):
                first_url = _cell_str(value).split("\n")[0]
                if first_url.startswith("http"):
                    cell.hyperlink = first_url

    buf = io.BytesIO()
    remote_wb.save(buf)
    return buf.getvalue(), merged, appended


def sync_local_xlsx(local_xlsx=DEFAULT_XLSX, repo_path=None, config_path=DEFAULT_CONFIG,
                    commit_msg=None, retries=3):
    """将本地结果表合并推送到 GitCode 仓(Alice_zhou/issue)。

    返回 (ok, url, detail):ok=True 时 url 为下载/页面链接,detail 为可读描述;
    ok=False 时 url 为空、detail 为失败原因。
    """
    if not os.path.isfile(local_xlsx):
        return False, "", "本地文件不存在: {}".format(local_xlsx)
    size = os.path.getsize(local_xlsx)
    if size > MAX_FILE_BYTES:
        return False, "", "文件超过 {}MB 上限".format(MAX_FILE_BYTES // (1024 * 1024))

    client = GitCodeContentsClient(config_path)
    path = repo_path or client.repo_path
    host = socket.gethostname()
    if not commit_msg:
        commit_msg = "results: sync {} from {}".format(os.path.basename(local_xlsx), host)

    last_err = ""
    for attempt in range(1, retries + 1):
        code, info = client.get_file(path)
        if code == 200:
            sha = info.get("sha", "")
            content_b64 = info.get("content", "") or ""
            remote_bytes = base64.b64decode(content_b64) if content_b64 else b""
            if openpyxl is None:
                # 无 openpyxl 时退化为整表覆盖(单机场景),多机合并不可用
                payload_b64 = base64.b64encode(open(local_xlsx, "rb").read()).decode("ascii")
                merged = appended = -1
            else:
                try:
                    merged_bytes, merged, appended = merge_local_into_remote(remote_bytes, local_xlsx)
                except Exception as exc:
                    return False, "", "远端文件合并失败(禁止覆盖远端,已中止): {}".format(exc)
                payload_b64 = base64.b64encode(merged_bytes).decode("ascii")
            code2, resp = client.update_file(path, payload_b64, commit_msg, sha)
            if code2 in (200, 201):
                dl = info.get("download_url", "")
                html = info.get("html_url", "")
                detail = "merge(remote+local) merged={} appended={} host={}".format(
                    merged, appended, host)
                return True, dl or html, detail
            last_err = "PUT 失败 [{}]: {}".format(code2, json.dumps(resp, ensure_ascii=False)[:200])
            if code2 in (409, 400, 422):
                # 并发冲突(sha 失配/他人先推):重拉远端重新合并后重试
                time.sleep(min(1.0 * attempt, 3.0))
                continue
        elif code == 404:
            payload_b64 = base64.b64encode(open(local_xlsx, "rb").read()).decode("ascii")
            code2, resp = client.create_file(path, payload_b64, commit_msg)
            if code2 in (200, 201):
                code3, info2 = client.get_file(path)
                dl = info2.get("download_url", "") if code3 == 200 else ""
                html = info2.get("html_url", "") if code3 == 200 else ""
                return True, dl or html, "create(new) host={}".format(host)
            last_err = "POST 失败 [{}]: {}".format(code2, json.dumps(resp, ensure_ascii=False)[:200])
            time.sleep(min(1.0 * attempt, 3.0))
        else:
            last_err = "GET 失败 [{}]: {}".format(code, json.dumps(info, ensure_ascii=False)[:200])
            time.sleep(min(1.0 * attempt, 3.0))
    return False, "", "重试 {} 次仍失败: {}".format(retries, last_err)


def git_sync_enabled(config_path=DEFAULT_CONFIG):
    """读取同步开关:环境变量 BATCH_RESULT_GIT_SYNC > config batch_result_git_sync(默认 true)。"""
    env = os.environ.get("BATCH_RESULT_GIT_SYNC", "").strip()
    if env:
        return env not in ("0", "false", "off", "no"), "env BATCH_RESULT_GIT_SYNC={}".format(env)
    if os.path.isfile(config_path):
        try:
            cfg = json.load(open(config_path, encoding="utf-8"))
            enabled = cfg.get("batch_result_git_sync", True)
            return bool(enabled), "config batch_result_git_sync={}".format(enabled)
        except Exception:
            return True, "config 解析失败,按默认启用"
    return True, "默认启用(无配置文件)"


def main():
    parser = argparse.ArgumentParser(
        description="批量压测结果 xlsx 实时同步至 GitCode 仓(Alice_zhou/issue,多机合并推送)")
    parser.add_argument("--xlsx", default=DEFAULT_XLSX, help="本地 xlsx 路径,默认 out_res/批量压测结果.xlsx")
    parser.add_argument("--repo_path", default=None,
                        help="仓内路径,默认取 config 的 batch_result_repo_path(缺省根目录 批量压测结果.xlsx)")
    parser.add_argument("--config", default=DEFAULT_CONFIG, help="配置文件路径")
    parser.add_argument("--retries", type=int, default=3, help="并发冲突重试次数,默认 3")
    parser.add_argument("--message", default=None, help="commit message(默认自动生成,含主机名)")
    parser.add_argument("--json", action="store_true", help="以 JSON 输出结果")
    args = parser.parse_args()

    ok, url, detail = sync_local_xlsx(args.xlsx, args.repo_path, args.config,
                                      args.message, args.retries)
    if args.json:
        print(json.dumps({"ok": ok, "url": url, "detail": detail, "xlsx": args.xlsx},
                         ensure_ascii=False))
    else:
        if ok:
            log("GIT_SYNC", "成功 → {} ({})".format(url, detail))
        else:
            log("GIT_SYNC", "失败: {}".format(detail))
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())