#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Sphinx autodocにおけるimportエラーを調査するためのユーティリティスクリプト。

このスクリプトは、Sphinxのビルドプロセスで発生する可能性のあるPythonモジュールのimportエラーをデバッグするために設計されています。
特に、`autodoc_mock_imports`の設定が正しく機能しているか、または他の原因でimportエラーが発生しているかを特定するのに役立ちます。

目的:
- `./source/conf.py` をインポートし、`autodoc_mock_imports` の設定を読み込む。
- 読み取った `autodoc_mock_imports` に基づいて、ダミーモジュールを `sys.modules` に登録する。
- 指定された `source` ディレクトリ配下のすべての `.py` ファイルを再帰的にインポートする。
- インポート中に発生したエラーを記録し、必要に応じて処理を継続する。

仕様:
- スクリプト実行時、`--source` 引数で指定されたディレクトリ (デフォルトは `source`) をSphinxのソースディレクトリとして扱います。
- `conf.py` は他のファイルよりも先に個別にインポートされ、その設定が適用されます。
- `conf.py` 以外の `.py` ファイルは、`source` ディレクトリからの相対パスに基づいて一意な疑似モジュール名でインポートされます。
- ファイル名が `_数字列` で終わるファイル (例: `sample_1.py`, `test_2024.py`) はスキップされます。
- デフォルトでは、インポート中に例外が発生した場合、トレースバックを表示してスクリプトは終了します。
- `--keep-going` オプションを指定すると、エラーが発生しても処理を最後まで継続し、最後にすべての失敗一覧を表示します。
- `--mock` オプションを使用すると、`conf.py` をインポートする前に手動でモジュールをモックできます。
- `sys.path` にカレントディレクトリやソースディレクトリを追加するオプションも提供されます。

:doc:`debug_sphinx-build_usage`
"""

from __future__ import annotations

import argparse
import importlib.util
import re
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: `DummyObject` の文字列表現。
        :rtype: str
        """
        return f"<DummyObject {self._name}>"


class DummyModule(types.ModuleType):
    """
    属性アクセス時に `DummyObject` を返すダミーモジュール。

    `sys.modules` に登録することで、存在しないモジュールがimportされようとした際に、
    実際には何もしないダミーのモジュールとして機能させます。
    これにより、モジュール内の属性アクセスや関数呼び出しがすべて `DummyObject` に
    よって処理され、`AttributeError` や `ImportError` を回避できます。
    """

    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` にダミーモジュールとして登録する。

    モジュール名が `pkg.subpkg` のように階層的である場合、`pkg` と `pkg.subpkg` の両方を
    ダミーモジュールとして登録し、ネストされたimportをサポートします。
    既に登録されているモジュールは上書きされません。

    :param module_names: モックするモジュール名のリスト。
    :type module_names: list[str]
    :returns: 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):
    """
    任意の .py ファイルを `module_name` という名前でインポートする。

    `importlib.util` を使用してファイルからモジュールをロードし、
    `sys.modules` に登録します。これにより、通常の `import` 文と同様に
    モジュールをロードし、そのオブジェクトにアクセスできるようになります。

    :param module_name: インポートするモジュールの名前。`sys.modules` に登録される名前となります。
    :type module_name: str
    :param file_path: インポート対象の .py ファイルのパス。
    :type file_path: Path
    :returns: ロードされたモジュールオブジェクト。
    :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:
    """
    `source_dir` 配下の相対パスから、一意な疑似モジュール名を生成する。

    ファイルパスをPythonの識別子として有効な形式に変換し、モジュール名の衝突を避けるために
    `sphinx_debug.` というプレフィックスを付与します。
    ファイル名やディレクトリ名に含まれるハイフン (`-`) やスペース (` `) はアンダースコア (`_`) に変換され、
    無効な文字を含む識別子にはさらにプレフィックスが追加されます。

    :param source_dir: Sphinxのソースディレクトリのパス。このパスからの相対パスがモジュール名の基になります。
    :type source_dir: Path
    :param py_file: 疑似モジュール名を生成する対象の .py ファイルのパス。
    :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]:
    """
    特定の条件に基づいてファイルをスキップするかどうかを判定する。

    以下の条件に合致する場合、ファイルはスキップされます。
    - ファイル名が `conf.py` である場合 (このファイルは個別に先に処理されるため)。
    - ファイルのステム (拡張子を除いた部分) が `_数字列` で終わる場合。
      例: `sample_1.py`, `test_2024.py`

    :param py_file: 判定対象のファイルパス。
    :type py_file: Path
    :returns: スキップするかどうかの真偽値と、スキップ理由の文字列のタプル。
    :rtype: tuple[bool, str]
    """
    if py_file.name == "conf.py":
        return True, "conf.py は先に個別 import 済み"

    if re.search(r"_\d+$", py_file.stem):
        return True, "stem が _数字列 で終わる"

    return False, ""


def parse_mock_arg(mock_text: str) -> list[str]:
    """
    コマンドライン引数 `--mock` で渡された文字列をモジュール名のリストに変換する。

    入力文字列はセミコロン (`;`) で区切られたモジュール名を含むと想定されます。
    各モジュール名から前後の空白が除去され、空の文字列は無視されます。
    例: `"whisper;torch;numba"` は `["whisper", "torch", "numba"]` に変換されます。

    :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) -> None:
    """
    発生したエラーの詳細をエラーリストに記録する。

    エラー情報には、発生フェーズ、ファイルパス、エラータイプ、メッセージ、完全なトレースバックが含まれます。
    これは `--keep-going` オプションが指定された場合に、複数のエラーをまとめて報告するために使用されます。

    :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
    :returns: 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,
        }
    )


def print_error_summary(errors: list[dict]) -> None:
    """
    記録されたエラーの要約を表示する。

    エラーがない場合はその旨を表示し、ある場合は各エラーのファイルパス、エラータイプ、
    エラーメッセージを一覧形式でコンソールに出力します。

    :param errors: 記録されたエラー情報のリスト。
    :type errors: list[dict]
    :returns: 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 main():
    """
    スクリプトのメイン処理を実行する。

    コマンドライン引数を解析し、Sphinxのソースディレクトリ内のPythonファイルを順次インポートします。
    `conf.py` をロードして `autodoc_mock_imports` を取得し、それらのモジュールをモックします。
    その後、ソースディレクトリ内の残りの `.py` ファイルをインポートし、
    `--keep-going` オプションに応じてエラー処理を行います。
    最終的に、記録されたエラーの要約を表示し、エラーがあれば非ゼロの終了コードで終了します。

    :returns: None
    """
    parser = argparse.ArgumentParser(
        description="Sphinx import エラー調査用: conf.py と source配下の *.py を順に 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 向け)",
    )
    args = parser.parse_args()

    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)

    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)

    print(f"[INFO] source_dir = {source_dir}")
    print(f"[INFO] conf.py    = {conf_py}")

    manual_mocks = parse_mock_arg(args.mock)
    if manual_mocks:
        install_mock_modules(manual_mocks)
        print(f"[INFO] manual mock modules = {manual_mocks}")

    conf_mod = None

    print(f"[IMPORT] {conf_py}")
    try:
        conf_mod = load_module_from_file("sphinx_debug.conf", conf_py)
    except Exception as e:
        print("\n[ERROR] conf.py の import に失敗しました\n", file=sys.stderr)
        traceback.print_exc()
        record_error(errors, "conf.py", conf_py, e)
        if not args.keep_going:
            print_error_summary(errors)
            sys.exit(1)

    if conf_mod is not None:
        conf_mocks = getattr(conf_mod, "autodoc_mock_imports", [])
        if conf_mocks:
            if not isinstance(conf_mocks, (list, tuple)):
                print(
                    f"[WARN] autodoc_mock_imports が list/tuple ではありません: {type(conf_mocks).__name__}",
                    file=sys.stderr,
                )
            else:
                conf_mocks = [str(x).strip() for x in conf_mocks if str(x).strip()]
                install_mock_modules(conf_mocks)
                print(f"[INFO] autodoc_mock_imports = {conf_mocks}")

    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

        module_name = make_module_name(source_dir, py_file)
        print(f"[IMPORT] {py_file}")
        try:
            load_module_from_file(module_name, py_file)
        except Exception as e:
            print(f"\n[ERROR] import に失敗しました: {py_file}\n", file=sys.stderr)
            traceback.print_exc()
            record_error(errors, "module", py_file, e)
            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']} ---")
            print(err["traceback"])

    if errors:
        sys.exit(1)

    print("\n[OK] すべての対象 .py ファイルを import できました")


if __name__ == "__main__":
    main()