"""TIASshot のヘッドレスセルフテストのランナー（/verify・verifier エージェント用）.

手順（すべてローカルのみ．実機カメラ・ネットワーク上のサービスには触れない）:
  1. ランナーがビルドする構成（Debug|x64）の出力 bin/x64/Debug・obj/x64/Debug を削除する
     （汚れた bin による依存漏れの見逃しを防ぐ．ENV_04「クリーン環境での検証」）．
     Release 等ほかの構成の出力（人間のビルド成果物）には触れない
     --full のときは packages も退避して nuget restore からやり直す（依存更新時）
  2. Debug|x64 をビルドする（SELFTEST フラグ入り）
  3. 既存テスト（NUnit・x64）を実行する（--skip-tests で省略）
  4. TIASshot.exe --selftest を実行し，終了コードと結果ファイルで判定する

出力: .verify/selftest/run-<日時>/ に build.log・test.log・selftest-result.txt・summary.json
終了コード: 0=全て合格，1=不合格あり，2=前提条件を満たさない（ツール未導入等）

使い方（リポジトリルートで実行）:
  python scripts/verify/run_selftest.py            # 通常（Debug 出力削除 → ビルド → テスト → セルフテスト）
  python scripts/verify/run_selftest.py --full     # 依存更新時（packages 退避 → nuget restore から）
  python scripts/verify/run_selftest.py --no-clean # Debug 出力を消さない（開発中の素早い再確認用．判定には使わない）
"""

from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
SLN = REPO / "TIASshot.sln"
EXE = REPO / "TIASshot" / "bin" / "x64" / "Debug" / "TIASshot.exe"
TEST_DLL = REPO / "TIASshot.Tests" / "bin" / "x64" / "Debug" / "TIASshot.Tests.dll"
# クリーン対象はランナーがビルドする構成（Debug|x64）の出力だけ．bin 全体を消すと人間の Release 出力まで消える
CLEAN_DIRS = [REPO / p / d / "x64" / "Debug" for p in ("TIASshot", "TIASshot.Tests") for d in ("bin", "obj")]
SELFTEST_TIMEOUT_SEC = 420   # セルフテスト内部のウォッチドッグ（5 分）より長くする
BUILD_TIMEOUT_SEC = 1200

EXIT_OK = 0
EXIT_FAIL = 1
EXIT_PRECONDITION = 2


class PreconditionError(Exception):
    """前提条件を満たさない（verifier は「検証不能」と報告する）."""


def find_vs() -> Path:
    vswhere = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / \
        "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
    if not vswhere.exists():
        raise PreconditionError(f"vswhere.exe が見つかりません: {vswhere}（Visual Studio / Build Tools を導入してください）")
    out = subprocess.run([str(vswhere), "-latest", "-products", "*", "-property", "installationPath"],
                         capture_output=True, text=True, check=False)
    path = out.stdout.strip().splitlines()[0] if out.stdout.strip() else ""
    if not path:
        raise PreconditionError("vswhere が Visual Studio / Build Tools を検出できません")
    return Path(path)


def find_nuget() -> str:
    found = shutil.which("nuget")
    if found:
        return found
    local = REPO / "nuget.exe"
    if local.exists():
        return str(local)
    raise PreconditionError("nuget.exe が見つかりません（setup.ps1 を実行するか PATH に追加してください）")


def run_logged(cmd: list[str], log: Path, timeout: int) -> int:
    """コマンドを実行し，標準出力・標準エラーをログへ書く．戻り値は終了コード."""
    with log.open("w", encoding="utf-8", errors="replace") as f:
        f.write("> " + " ".join(cmd) + "\n\n")
        f.flush()
        proc = subprocess.run(cmd, cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                              timeout=timeout, check=False)
        f.write(decode_output(proc.stdout))
        return proc.returncode


def decode_output(data: bytes) -> str:
    """ツールの出力を復号する（MSBuild / vstest はリダイレクト時 UTF-8．古い環境の cp932 にも備える）."""
    try:
        return data.decode("utf-8")
    except UnicodeDecodeError:
        return data.decode("cp932", errors="replace")


def clean_build_outputs() -> None:
    for d in CLEAN_DIRS:
        if d.exists():
            try:
                shutil.rmtree(d)
            except OSError as ex:
                raise PreconditionError(
                    f"{d} を削除できません（TIASshot.exe や Visual Studio が使用中の可能性）: {ex}") from ex


def restore_packages_clean(run_dir: Path, nuget: str) -> tuple[bool, str]:
    """packages を退避して nuget restore からやり直す（clone 直後と同じ状態を再現する）."""
    packages = REPO / "packages"
    backup = REPO / f"packages.bak-{run_dir.name}"
    if packages.exists():
        try:
            # 改名は原子的．失敗時は packages が元のまま残る（中途半端な状態にならない）
            packages.rename(backup)
        except OSError as ex:
            raise PreconditionError(
                f"packages を退避できません（Visual Studio 等が使用中の可能性．packages は変更していません）: {ex}") from ex
    code = run_logged([nuget, "restore", str(SLN)], run_dir / "restore.log", BUILD_TIMEOUT_SEC)
    if code != 0:
        # 復元に失敗したら退避した packages を戻す（人間の作業環境を壊さない）
        if packages.exists():
            shutil.rmtree(packages, ignore_errors=True)
        if backup.exists():
            backup.rename(packages)
        return False, f"nuget restore 失敗（exit {code}）．packages は元に戻しました"
    if backup.exists():
        shutil.rmtree(backup, ignore_errors=True)
    return True, "nuget restore 成功（packages を再取得）"


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--full", action="store_true", help="packages を退避して nuget restore からやり直す（依存更新時）")
    parser.add_argument("--no-clean", action="store_true", help="Debug 出力（bin/obj の x64/Debug）を削除しない（開発時の短縮用．判定には使わない）")
    parser.add_argument("--skip-tests", action="store_true", help="既存テスト（vstest）を実行しない（開発時の短縮用．判定には使わない）")
    args = parser.parse_args()
    # verifier（Git Bash 経由）が読めるよう，本ランナーの出力は UTF-8 に固定する
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")

    stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    run_dir = REPO / ".verify" / "selftest" / f"run-{stamp}"
    run_dir.mkdir(parents=True, exist_ok=True)
    summary: dict = {"run_dir": str(run_dir), "steps": [], "result": None}

    def step(name: str, ok: bool, detail: str) -> None:
        summary["steps"].append({"name": name, "ok": ok, "detail": detail})
        print(f"[{'OK' if ok else 'NG'}] {name}: {detail}", flush=True)

    exit_code = EXIT_OK
    try:
        vs = find_vs()
        msbuild = vs / "MSBuild" / "Current" / "Bin" / "MSBuild.exe"
        vstest = vs / "Common7" / "IDE" / "CommonExtensions" / "Microsoft" / "TestWindow" / "vstest.console.exe"
        if not msbuild.exists():
            raise PreconditionError(f"MSBuild.exe が見つかりません: {msbuild}")

        if not args.no_clean:
            clean_build_outputs()
            step("clean", True, "Debug 出力（bin/obj の x64/Debug）を削除")

        if args.full:
            ok, detail = restore_packages_clean(run_dir, find_nuget())
            step("restore", ok, detail)
            if not ok:
                raise PreconditionError(detail)

        code = run_logged([str(msbuild), str(SLN), "/t:Build", "/p:Configuration=Debug", "/p:Platform=x64",
                           "/nologo", "/v:minimal"], run_dir / "build.log", BUILD_TIMEOUT_SEC)
        step("build", code == 0, f"Debug|x64 ビルド exit {code}（build.log）")
        if code != 0:
            exit_code = EXIT_FAIL
            raise RuntimeError("ビルド失敗")

        if not args.skip_tests:
            if not vstest.exists():
                raise PreconditionError(f"vstest.console.exe が見つかりません: {vstest}")
            code = run_logged([str(vstest), str(TEST_DLL), "/Platform:x64"], run_dir / "test.log", BUILD_TIMEOUT_SEC)
            step("tests", code == 0, f"既存テスト exit {code}（test.log）")
            if code != 0:
                exit_code = EXIT_FAIL

        result_file = run_dir / "selftest-result.txt"
        try:
            proc = subprocess.run([str(EXE), "--selftest", "--selftest-out", str(result_file)], cwd=REPO,
                                  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                  timeout=SELFTEST_TIMEOUT_SEC, check=False)
            code = proc.returncode
        except subprocess.TimeoutExpired:
            code = None
        text = result_file.read_text(encoding="utf-8-sig", errors="replace") if result_file.exists() else ""
        heads = [ln for ln in text.splitlines() if ln.startswith("[") or ln.startswith("RESULT:")]
        summary["selftest_exit_code"] = code
        summary["selftest_lines"] = heads
        step("selftest", code == 0, f"exit {code}（selftest-result.txt）")
        for ln in heads:
            print(f"    {ln}", flush=True)
        if code != 0:
            exit_code = EXIT_FAIL
    except PreconditionError as ex:
        step("precondition", False, str(ex))
        exit_code = EXIT_PRECONDITION
    except RuntimeError as ex:
        step("abort", False, str(ex))
        exit_code = EXIT_FAIL
    except subprocess.TimeoutExpired as ex:
        step("timeout", False, f"{ex.cmd} が {ex.timeout} 秒で終わりません")
        exit_code = EXIT_FAIL

    summary["result"] = {EXIT_OK: "PASS", EXIT_FAIL: "FAIL", EXIT_PRECONDITION: "PRECONDITION"}[exit_code]
    (run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"RESULT: {summary['result']}  (出力: {run_dir})", flush=True)
    return exit_code


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