#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Sphinx autodocにおけるimportエラーを調査するためのユーティリティスクリプト。

各 .py ファイルを subprocess で個別 import することで、
Qt/Tk/matplotlib backend などの副作用が後続ファイルに残らないようにする。

:doc:`debug_sphinx_build_usage`
"""

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:
    """
    モックオブジェクトとして振る舞うダミークラス。

    属性アクセスや関数呼び出しがあってもエラーにならず、常に新しい `DummyObject` を返す。
    これにより、実際のモジュールが存在しなくても参照エラーを防ぐ。
    """

    def __init__(self, name: str = "dummy"):
        """
        DummyObjectインスタンスを初期化する。

        :param name: ダミーオブジェクトの名前。
        :type name: str
        """
        self._name = name

    def __call__(self, *args, **kwargs):
        """
        オブジェクトが関数として呼び出されたときの挙動を定義する。

        常に新しい `DummyObject` を返し、元の名前に関数呼び出しを示す `()` を追加する。

        :param args: 位置引数。
        :param kwargs: キーワード引数。
        :returns: 新しい `DummyObject` インスタンス。
        :rtype: DummyObject
        """
        return DummyObject(f"{self._name}()")

    def __getattr__(self, name: str):
        """
        オブジェクトの属性にアクセスしようとしたときの挙動を定義する。

        存在しない属性が要求された場合、その属性名を持つ `DummyObject` を作成し、返す。
        元の名前とアクセスされた属性名を連結する。

        :param name: アクセスされた属性の名前。
        :type name: str
        :returns: 新しい `DummyObject` インスタンス。
        :rtype: DummyObject
        """
        return DummyObject(f"{self._name}.{name}")

    def __repr__(self):
        """
        オブジェクトの公式な文字列表現を返す。

        :returns: オブジェクトの文字列表現。
        :rtype: str
        """
        return f"<DummyObject {self._name}>"


class DummyModule(types.ModuleType):
    """
    モックモジュールとして振る舞うダミークラス。

    `types.ModuleType` を継承し、未定義の属性にアクセスされた際に `DummyObject` を動的に作成・設定する。
    """

    def __getattr__(self, name: str):
        """
        モジュールの属性にアクセスしようとしたときの挙動を定義する。

        存在しない属性が要求された場合、その属性名を持つ `DummyObject` を作成し、
        モジュールに設定してから返す。

        :param name: アクセスされた属性の名前。
        :type name: str
        :returns: 新しい `DummyObject` インスタンス。
        :rtype: DummyObject
        """
        dummy = DummyObject(f"{self.__name__}.{name}")
        setattr(self, name, dummy)
        return dummy


def install_mock_modules(module_names: list[str]) -> None:
    """
    指定されたモジュール名を `sys.modules` にダミーモジュールとしてインストールする。

    モジュール名が階層的（例: `numpy.linalg`）である場合、親モジュール (`numpy`) も
    ダミーモジュールとして追加される。

    :param module_names: モックとしてインストールするモジュール名のリスト。
    :type module_names: list[str]
    :returns: None
    :rtype: 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):
    """
    指定されたファイルパスからモジュールをロードし、`sys.modules` に追加する。

    `importlib.util` を使用してモジュール仕様を作成し、ロードして実行する。

    :param module_name: ロードするモジュールの名前。
    :type module_name: str
    :param file_path: ロードするモジュールのファイルパス。
    :type file_path: Path
    :returns: ロードされたモジュールオブジェクト。
    :rtype: types.ModuleType
    :raises ImportError: モジュール仕様の作成に失敗した場合。
    """
    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:
    """
    ソースディレクトリとPythonファイルのパスから、一意なモジュール名を生成する。

    ファイルパスをソースディレクトリからの相対パスに変換し、ファイル名をハイフンやスペースを
    アンダースコアに置換して、有効なPython識別子になるように調整する。
    `sphinx_debug.` プレフィックスを付与する。

    :param source_dir: Sphinxのソースディレクトリのパス。
    :type source_dir: Path
    :param py_file: 対象のPythonファイルのパス。
    :type py_file: Path
    :returns: 生成されたモジュール名。
    :rtype: 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]:
    """
    特定の条件に基づいてPythonファイルを処理対象からスキップするかどうかを判定する。

    `conf.py`、ファイル名が `_docstring` で終わるファイル、
    ファイル名が `_数字` で終わるファイルはスキップされる。

    :param py_file: 検査対象のPythonファイルのパス。
    :type py_file: Path
    :returns: スキップするかどうかを示す真偽値と、スキップ理由の文字列のタプル。
    :rtype: 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]:
    """
    セミコロン区切りの文字列からモックモジュール名のリストを解析する。

    空白文字列や空の要素は無視される。

    :param mock_text: セミコロン区切りのモックモジュール名文字列。
    :type mock_text: str
    :returns: 解析されたモックモジュール名のリスト。
    :rtype: 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:
    """
    発生したエラー情報を記録リストに追加する。

    エラーのフェーズ、ファイルパス、例外情報、標準出力、標準エラー出力などを
    辞書形式で記録する。

    :param errors: エラー情報を格納するリスト。
    :type errors: list[dict]
    :param phase: エラーが発生したフェーズ（例: "conf.py", "module"）。
    :type phase: str
    :param file_path: エラーが発生したファイルのパス。
    :type file_path: Path
    :param exc: 発生した例外オブジェクト。
    :type exc: BaseException
    :param stdout: サブプロセスの標準出力の内容。
    :type stdout: str
    :param stderr: サブプロセスの標準エラー出力の内容。
    :type stderr: str
    :returns: None
    :rtype: 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:
    """
    記録されたエラーの概要をコンソールに出力する。

    エラーがない場合はその旨を表示する。

    :param errors: 記録されたエラー情報のリスト。
    :type errors: list[dict]
    :returns: None
    :rtype: 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:
    """
    コマンドライン引数に基づいて `sys.path` にパスを追加する。

    `args.add_cwd` が真ならカレントディレクトリを、`args.add_source` が真なら
    ソースディレクトリを `sys.path` の先頭に追加する。
    すでにパスが存在する場合は追加しない。

    :param args: コマンドライン引数を格納する名前空間オブジェクト。
    :type args: argparse.Namespace
    :param source_dir: Sphinxのソースディレクトリのパス。
    :type source_dir: Path
    :returns: None
    :rtype: 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` から `autodoc_mock_imports` の設定を読み込む。

    `conf.py` が存在しない場合は空のリストを返す。
    `autodoc_mock_imports` がリストまたはタプルでない場合は警告を表示し、空リストを返す。

    :param source_dir: Sphinxのソースディレクトリのパス。
    :type source_dir: Path
    :returns: `autodoc_mock_imports` に設定されたモックモジュール名のリスト。
    :rtype: 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:
    """
    子プロセスとして単一のPythonファイルをインポートする。

    `sys.path` の設定、手動モック、`conf.py` 由来のモックを適用した後、
    指定されたファイルをモジュールとしてロードする。
    この関数は、`--child-import` 引数が指定された場合にメイン関数から呼び出される。

    :param args: コマンドライン引数を格納する名前空間オブジェクト。
    :type args: argparse.Namespace
    :returns: None
    :rtype: 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:
    """
    指定されたPythonファイルを子プロセスでインポート実行する。

    現在のスクリプト自体を `python -m` のように `--child-import` オプションを付けて実行し、
    指定されたファイルのみをインポートさせる。
    子プロセスの標準出力と標準エラー出力をキャプチャする。

    :param args: コマンドライン引数を格納する名前空間オブジェクト。
    :type args: argparse.Namespace
    :param source_dir: Sphinxのソースディレクトリのパス。
    :type source_dir: Path
    :param py_file: インポートするPythonファイルのパス。
    :type py_file: Path
    :returns: サブプロセスの実行結果を含む `subprocess.CompletedProcess` オブジェクト。
    :rtype: 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:
    """
    サブプロセスが非ゼロの終了コードを返した場合に `RuntimeError` を発生させる。

    :param cp: サブプロセスの実行結果を含む `subprocess.CompletedProcess` オブジェクト。
    :type cp: subprocess.CompletedProcess
    :param py_file: サブプロセスで処理されたファイルのパス。
    :type py_file: Path
    :returns: None
    :rtype: None
    :raises RuntimeError: サブプロセスが失敗した場合。
    """
    if cp.returncode != 0:
        raise RuntimeError(
            f"subprocess import failed: returncode={cp.returncode}, file={py_file}"
        )


def main():
    """
    スクリプトのメインエントリポイント。

    コマンドライン引数を解析し、親プロセスとして動作する場合は `source` ディレクトリ内の
    すべてのPythonファイルを子プロセスで順次インポートを試みる。
    子プロセスとして動作する場合は、単一のファイルをインポートする (`--child-import` 指定時)。
    エラーが発生した場合は記録し、最終的に概要を出力する。
    """
    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()