#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Sphinx autodocにおけるimportエラーを調査するためのユーティリティスクリプト。

各 .py ファイルを subprocess で個別 import することで、
Qt/Tk/matplotlib backend などの副作用が後続ファイルに残らないようにする。
"""

from __future__ import annotations

import argparse
import importlib.util
import re
import subprocess
import sys
import traceback
import types
from pathlib import Path


class DummyObject:
    def __init__(self, name: str = "dummy"):
        self._name = name

    def __call__(self, *args, **kwargs):
        return DummyObject(f"{self._name}()")

    def __getattr__(self, name: str):
        return DummyObject(f"{self._name}.{name}")

    def __repr__(self):
        return f"<DummyObject {self._name}>"


class DummyModule(types.ModuleType):
    def __getattr__(self, name: str):
        dummy = DummyObject(f"{self.__name__}.{name}")
        setattr(self, name, dummy)
        return dummy


def install_mock_modules(module_names: list[str]) -> None:
    for name in module_names:
        name = name.strip()
        if not name:
            continue

        parts = name.split(".")
        for i in range(1, len(parts) + 1):
            subname = ".".join(parts[:i])
            if subname not in sys.modules:
                sys.modules[subname] = DummyModule(subname)


def load_module_from_file(module_name: str, file_path: Path):
    spec = importlib.util.spec_from_file_location(module_name, str(file_path))
    if spec is None or spec.loader is None:
        raise ImportError(f"spec を作成できませんでした: {file_path}")

    module = importlib.util.module_from_spec(spec)
    sys.modules[module_name] = module
    spec.loader.exec_module(module)
    return module


def make_module_name(source_dir: Path, py_file: Path) -> str:
    rel = py_file.relative_to(source_dir).with_suffix("")
    parts = []

    for p in rel.parts:
        p2 = p.replace("-", "_").replace(" ", "_")
        if not p2.isidentifier():
            p2 = "_" + "".join(
                ch if (ch.isalnum() or ch == "_") else "_" for ch in p2
            )
        parts.append(p2)

    return "sphinx_debug." + ".".join(parts)


def should_skip(py_file: Path) -> tuple[bool, str]:
    if py_file.name == "conf.py":
        return True, "conf.py は先に個別 import 済み"

    if py_file.stem.endswith("_docstring"):
        return True, "stem が _docstring で終わる"

    if re.search(r"_\d+$", py_file.stem):
        return True, "stem が _日付 で終わる"

    return False, ""


def parse_mock_arg(mock_text: str) -> list[str]:
    if not mock_text:
        return []
    return [x.strip() for x in mock_text.split(";") if x.strip()]


def record_error(
    errors: list[dict],
    phase: str,
    file_path: Path,
    exc: BaseException,
    stdout: str = "",
    stderr: str = "",
) -> None:
    tb_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
    errors.append(
        {
            "phase": phase,
            "file": str(file_path),
            "error_type": type(exc).__name__,
            "message": str(exc),
            "traceback": tb_text,
            "stdout": stdout,
            "stderr": stderr,
        }
    )


def print_error_summary(errors: list[dict]) -> None:
    print("\n" + "=" * 80)
    print("[SUMMARY] import 失敗一覧")
    print("=" * 80)

    if not errors:
        print("[SUMMARY] エラーはありません")
        return

    for i, err in enumerate(errors, 1):
        print(f"{i}. [{err['phase']}] {err['file']}")
        print(f"   {err['error_type']}: {err['message']}")

    print("-" * 80)
    print(f"合計 {len(errors)} 件の import エラーがありました")


def add_sys_paths(args, source_dir: Path) -> None:
    if args.add_cwd:
        cwd = str(Path.cwd().resolve())
        if cwd not in sys.path:
            sys.path.insert(0, cwd)

    if args.add_source:
        sdir = str(source_dir)
        if sdir not in sys.path:
            sys.path.insert(0, sdir)


def load_conf_mocks(source_dir: Path) -> list[str]:
    conf_py = source_dir / "conf.py"
    if not conf_py.is_file():
        return []

    conf_mod = load_module_from_file("sphinx_debug.conf", conf_py)
    conf_mocks = getattr(conf_mod, "autodoc_mock_imports", [])

    if not isinstance(conf_mocks, (list, tuple)):
        print(
            f"[WARN] autodoc_mock_imports が list/tuple ではありません: "
            f"{type(conf_mocks).__name__}",
            file=sys.stderr,
        )
        return []

    return [str(x).strip() for x in conf_mocks if str(x).strip()]


def child_import_one_file(args) -> None:
    source_dir = Path(args.source).resolve()
    py_file = Path(args.single_file).resolve()

    add_sys_paths(args, source_dir)

    manual_mocks = parse_mock_arg(args.mock)
    if manual_mocks:
        install_mock_modules(manual_mocks)

    if py_file.name == "conf.py":
        module_name = "sphinx_debug.conf"
        load_module_from_file(module_name, py_file)
        return

    conf_mocks = load_conf_mocks(source_dir)
    if conf_mocks:
        install_mock_modules(conf_mocks)

    module_name = make_module_name(source_dir, py_file)
    load_module_from_file(module_name, py_file)


def run_import_in_subprocess(
    args,
    source_dir: Path,
    py_file: Path,
) -> subprocess.CompletedProcess:
    cmd = [
        sys.executable,
        str(Path(__file__).resolve()),
        "--source",
        str(source_dir),
        "--single-file",
        str(py_file),
        "--child-import",
    ]

    if args.add_cwd:
        cmd.append("--add-cwd")

    if args.add_source:
        cmd.append("--add-source")

    if args.mock:
        cmd.extend(["--mock", args.mock])

    return subprocess.run(
        cmd,
        text=True,
        capture_output=True,
    )


def raise_if_subprocess_failed(cp: subprocess.CompletedProcess, py_file: Path) -> None:
    if cp.returncode != 0:
        raise RuntimeError(
            f"subprocess import failed: returncode={cp.returncode}, file={py_file}"
        )


def main():
    parser = argparse.ArgumentParser(
        description="Sphinx import エラー調査用: source配下の *.py を subprocess で個別 import する"
    )
    parser.add_argument(
        "--source",
        default="source",
        help="Sphinx source ディレクトリ (default: source)",
    )
    parser.add_argument(
        "--add-cwd",
        action="store_true",
        help="カレントディレクトリを sys.path 先頭に追加する",
    )
    parser.add_argument(
        "--add-source",
        action="store_true",
        help="source ディレクトリを sys.path 先頭に追加する",
    )
    parser.add_argument(
        "--mock",
        default="",
        help='conf.py import 前に手動でモックするモジュールを ; 区切りで指定 '
        '(例: "whisper;torch;numba")',
    )
    parser.add_argument(
        "--show-skip",
        action="store_true",
        help="スキップしたファイルも表示する",
    )
    parser.add_argument(
        "--keep-going",
        action="store_true",
        help="エラーが出ても終了せず、最後まで続行して失敗一覧を表示する",
    )
    parser.add_argument(
        "--show-traceback-summary",
        action="store_true",
        help="最後の失敗一覧で traceback も全文表示する (--keep-going 向け)",
    )
    parser.add_argument(
        "--show-child-output",
        action="store_true",
        help="子プロセスの stdout/stderr を成功時にも表示する",
    )

    # subprocess 内部用
    parser.add_argument(
        "--single-file",
        default="",
        help=argparse.SUPPRESS,
    )
    parser.add_argument(
        "--child-import",
        action="store_true",
        help=argparse.SUPPRESS,
    )

    args = parser.parse_args()

    if args.child_import:
        child_import_one_file(args)
        return

    source_dir = Path(args.source).resolve()
    conf_py = source_dir / "conf.py"
    errors: list[dict] = []

    if not source_dir.is_dir():
        print(f"ERROR: source ディレクトリが存在しません: {source_dir}", file=sys.stderr)
        sys.exit(1)

    if not conf_py.is_file():
        print(f"ERROR: conf.py が存在しません: {conf_py}", file=sys.stderr)
        sys.exit(1)

    print(f"[INFO] source_dir = {source_dir}")
    print(f"[INFO] conf.py    = {conf_py}")
    print("[INFO] import mode = subprocess per file")

    manual_mocks = parse_mock_arg(args.mock)
    if manual_mocks:
        print(f"[INFO] manual mock modules = {manual_mocks}")

    print(f"[IMPORT] {conf_py}")
    try:
        cp = run_import_in_subprocess(args, source_dir, conf_py)

        if args.show_child_output:
            if cp.stdout:
                print(cp.stdout, end="")
            if cp.stderr:
                print(cp.stderr, end="", file=sys.stderr)

        raise_if_subprocess_failed(cp, conf_py)

        # 親プロセス側でも autodoc_mock_imports の表示だけ行う。
        # ここで conf.py を親に import すると副作用が残るため、
        # 直接 import はせず、簡易的に子プロセスで成功確認だけにする。
        print("[OK] conf.py import succeeded")

    except Exception as e:
        print("\n[ERROR] conf.py の import に失敗しました\n", file=sys.stderr)

        if "cp" in locals():
            if cp.stdout:
                print(cp.stdout, end="")
            if cp.stderr:
                print(cp.stderr, end="", file=sys.stderr)

        record_error(
            errors,
            "conf.py",
            conf_py,
            e,
            stdout=cp.stdout if "cp" in locals() else "",
            stderr=cp.stderr if "cp" in locals() else "",
        )

        if not args.keep_going:
            print_error_summary(errors)
            sys.exit(1)

    for py_file in sorted(source_dir.rglob("*.py")):
        skip, reason = should_skip(py_file)
        if skip:
            if args.show_skip:
                print(f"[SKIP]   {py_file} ({reason})")
            continue

        print(f"[IMPORT] {py_file}")
        try:
            cp = run_import_in_subprocess(args, source_dir, py_file)

            if args.show_child_output:
                if cp.stdout:
                    print(cp.stdout, end="")
                if cp.stderr:
                    print(cp.stderr, end="", file=sys.stderr)

            raise_if_subprocess_failed(cp, py_file)

        except Exception as e:
            print(f"\n[ERROR] import に失敗しました: {py_file}\n", file=sys.stderr)

            if "cp" in locals():
                if cp.stdout:
                    print(cp.stdout, end="")
                if cp.stderr:
                    print(cp.stderr, end="", file=sys.stderr)

            record_error(
                errors,
                "module",
                py_file,
                e,
                stdout=cp.stdout if "cp" in locals() else "",
                stderr=cp.stderr if "cp" in locals() else "",
            )

            if not args.keep_going:
                print_error_summary(errors)
                sys.exit(1)

    print_error_summary(errors)

    if args.keep_going and args.show_traceback_summary and errors:
        print("\n" + "=" * 80)
        print("[SUMMARY] traceback 詳細")
        print("=" * 80)
        for i, err in enumerate(errors, 1):
            print(f"\n--- {i}. [{err['phase']}] {err['file']} ---")

            if err.get("stdout"):
                print("[child stdout]")
                print(err["stdout"])

            if err.get("stderr"):
                print("[child stderr]")
                print(err["stderr"])

            print("[parent traceback]")
            print(err["traceback"])

    if errors:
        sys.exit(1)

    print("\n[OK] すべての対象 .py ファイルを import できました")


if __name__ == "__main__":
    main()