#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Sphinx autodoc における import エラーと API ドキュメント参照の不整合を
調査するためのユーティリティスクリプト。

この版では、source 配下の全 *.py ではなく、デフォルトでは *_api.rst / *_api.md
を起点に対象 Python ファイルを抽出する。

確認内容
--------
1. *_api.rst / *_api.md を再帰検索する。
2. autodoc 系 directive からライブラリパスを抽出する。
   例: ``.. automodule:: package.module``
3. literalinclude / Markdown link / 生パスから ``*.py`` 参照を抽出する。
4. ライブラリパスについて、以下を確認する。
   - module path としての文法
   - 対応する ``.py`` ファイルの存在
   - 親パッケージディレクトリの ``__init__.py`` の存在
   - ``-`` など、Sphinx/Python import に不向きなファイル名でないこと
5. ``.py`` のファイルサイズを確認し、100 bytes 以下を ``too small`` として報告する。
6. 抽出できた対象 ``.py`` を、元スクリプトと同様に subprocess で個別 import する。

重要な方針
----------
``package.module`` に対して通常確認するのは、
``package/module.py`` と、その親である ``package/__init__.py`` である。
``package/module/__init__.py`` は、``module.py`` と同名ディレクトリを仮定するため、
通常の ``.py`` module の存在確認エラー候補には出さない。

ただし ``.. automodule:: package`` のように package 自体を指す場合は、
``package.py`` がなければ、実在する ``package/__init__.py`` を import 対象として採用する。
この場合も、存在しない package ディレクトリをエラー候補として大量表示しない。
"""

from __future__ import annotations

import argparse
import importlib.util
import re
import keyword
import subprocess
import sys
import traceback
import types
from dataclasses import dataclass
from pathlib import Path


AUTODOC_DIRECTIVES = {
    "automodule",
    "autoclass",
    "autofunction",
    "autodata",
    "autoattribute",
    "autoexception",
    "autodecorator",
}

DEFAULT_MIN_PY_SIZE_BYTES = 100


@dataclass(frozen=True)
class ApiReference:
    """*_api.rst/md から抽出した参照情報。"""

    api_file: Path
    kind: str              # "module" or "pyfile"
    value: str             # module path or py file path text
    line_no: int
    directive: str = ""


@dataclass(frozen=True)
class ModuleResolution:
    """autodoc の module path を実ファイルに解決した結果。"""

    module_path: str
    py_file: Path
    import_root: Path
    is_package_init: bool = False


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:
    """
    元スクリプトと同じく、実ファイルを安全な仮 module 名に変換する。

    実際の Sphinx import 名そのものではなく、ファイル実行時の import エラーを
    subprocess 内で検出するための名前である。
    """

    try:
        rel = py_file.resolve().relative_to(source_dir.resolve()).with_suffix("")
    except ValueError:
        rel = py_file.resolve().with_suffix("")

    parts = []
    for p in rel.parts:
        # Windows ドライブ文字や区切り由来の不正文字も潰す
        p2 = p.replace(":", "_").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.name == "__init__.py":
        return True, "__init__.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 = "",
    detail: 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,
            "detail": detail,
        }
    )


def record_validation_error(
    errors: list[dict],
    phase: str,
    api_file: Path,
    message: str,
    line_no: int | None = None,
    value: str = "",
) -> None:
    loc = f"{api_file}"
    if line_no is not None:
        loc += f":{line_no}"
    errors.append(
        {
            "phase": phase,
            "file": loc,
            "error_type": "ValidationError",
            "message": message,
            "traceback": "",
            "stdout": "",
            "stderr": "",
            "detail": value,
        }
    )


def print_error_summary(errors: list[dict]) -> None:
    print("\n" + "=" * 80)
    print("[SUMMARY] 失敗一覧")
    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']}")
        if err.get("detail"):
            print(f"   detail: {err['detail']}")

    print("-" * 80)
    print(f"合計 {len(errors)} 件のエラーがありました")


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.resolve())
        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 strip_inline_comment(text: str) -> str:
    """directive 引数から行末コメントや余分な空白を軽く落とす。"""

    text = text.strip()
    if " #" in text:
        text = text.split(" #", 1)[0].rstrip()
    return text


def split_autodoc_target(target: str, directive: str) -> tuple[str, str]:
    """
    autodoc の target から、import 対象 module と member 名を分ける。

    autoclass / autofunction などは ``package.module.member`` の形式が多いため、
    ファイル存在確認では最後の要素を member とみなして module 側を使う。
    automodule は target 全体を module として扱う。
    """

    target = strip_inline_comment(target)
    target = target.lstrip("~")

    if directive == "automodule":
        return target, ""

    if "." not in target:
        return target, ""

    module_path, member = target.rsplit(".", 1)
    return module_path, member


def validate_module_path(module_path: str) -> tuple[bool, str]:
    """
    Python module path として妥当か確認する。

    例:
        Quantum.QD3D  -> OK
        Quantum.3DQD  -> NG
        Quantum.my-mod -> NG
        Quantum.class -> NG
    """

    if not module_path:
        return False, "empty module path"

    parts = module_path.split(".")

    for part in parts:
        if not part:
            return False, f"empty component in {module_path!r}"

        if not part.isidentifier():
            return False, (
                f"{part!r} is not a valid Python identifier "
                "(数字始まり、'-'、空白などは不可)"
            )

        if keyword.iskeyword(part):
            return False, f"{part!r} is a Python keyword"

    return True, ""

def has_sphinx_unsafe_py_name(py_file: Path) -> bool:
    """
    Sphinx autodoc で module として扱う場合に問題になりやすい名前を検出する。

    Python import 可能な識別子でない stem は基本的に危険。
    特に '-' は module 名として使えない。
    """

    stem = py_file.stem
    return "-" in stem or not stem.isidentifier()


def iter_api_files(source_dir: Path) -> list[Path]:
    patterns = ("*_api.rst", "*_api.md")
    files: list[Path] = []
    for pat in patterns:
        files.extend(source_dir.rglob(pat))
    return sorted(set(files))


def extract_api_references(api_file: Path) -> list[ApiReference]:
    """
    *_api.rst/md から Python module path と *.py path を抽出する。

    対応例
    ------
    RST:
        .. automodule:: foo.bar
        .. autoclass:: foo.bar.Baz
        .. literalinclude:: ../foo/bar.py

    MyST Markdown:
        ```{automodule} foo.bar
        ```{literalinclude} ../foo/bar.py

    Markdown link / bare path:
        [bar.py](../foo/bar.py)
        ../foo/bar.py
    """

    refs: list[ApiReference] = []
    text = api_file.read_text(encoding="utf-8", errors="replace")

    rst_auto_re = re.compile(
        r"^\s*\.\.\s+(?P<directive>auto(?:module|class|function|data|attribute|exception|decorator))::\s+(?P<target>\S+)"
    )
    myst_auto_re = re.compile(
        r"^\s*`{3,}\{(?P<directive>auto(?:module|class|function|data|attribute|exception|decorator))\}\s+(?P<target>\S+)"
    )
    rst_literal_re = re.compile(
        r"^\s*\.\.\s+literalinclude::\s+(?P<path>\S+?\.py)\b"
    )
    myst_literal_re = re.compile(
        r"^\s*`{3,}\{literalinclude\}\s+(?P<path>\S+?\.py)\b"
    )
    md_link_py_re = re.compile(r"\[[^\]]*?\.py\]\((?P<path>[^)]+?\.py)\)")
    bare_py_re = re.compile(r"(?P<path>(?:[A-Za-z]:)?[^\s`'\"<>|]+?\.py)\b")

    for line_no, line in enumerate(text.splitlines(), 1):
        for rx in (rst_auto_re, myst_auto_re):
            m = rx.search(line)
            if m:
                directive = m.group("directive")
                target = m.group("target")
                module_path, _member = split_autodoc_target(target, directive)
                refs.append(
                    ApiReference(
                        api_file=api_file,
                        kind="module",
                        value=module_path,
                        line_no=line_no,
                        directive=directive,
                    )
                )

        for rx in (rst_literal_re, myst_literal_re):
            m = rx.search(line)
            if m:
                refs.append(
                    ApiReference(
                        api_file=api_file,
                        kind="pyfile",
                        value=m.group("path"),
                        line_no=line_no,
                        directive="literalinclude",
                    )
                )

        for m in md_link_py_re.finditer(line):
            refs.append(
                ApiReference(
                    api_file=api_file,
                    kind="pyfile",
                    value=m.group("path"),
                    line_no=line_no,
                    directive="markdown-link",
                )
            )

        # literalinclude / markdown link 以外の生パスも拾う。
        # directive 行は上で拾っているので、重複しにくいように option 行は除外する。
        if "::" not in line and not line.lstrip().startswith(":"):
            for m in bare_py_re.finditer(line):
                refs.append(
                    ApiReference(
                        api_file=api_file,
                        kind="pyfile",
                        value=m.group("path"),
                        line_no=line_no,
                        directive="bare-path",
                    )
                )

    # 同じファイル内で同一行・同一値が複数回拾われた場合を除去
    seen: set[tuple[str, str, int, str]] = set()
    uniq: list[ApiReference] = []
    for ref in refs:
        key = (ref.kind, ref.value, ref.line_no, ref.directive)
        if key not in seen:
            uniq.append(ref)
            seen.add(key)
    return uniq


def resolve_py_reference(api_file: Path, path_text: str) -> Path:
    """
    ドキュメント内の *.py 参照を実ファイルに解決する。

    相対パスは API ファイルの場所基準で解決する。
    先頭の ``<...>`` や末尾の句読点は軽く除去する。
    """

    cleaned = path_text.strip().strip("<>`\"'")
    cleaned = cleaned.rstrip(".,;:")
    path = Path(cleaned)

    if path.is_absolute():
        return path.resolve()

    return (api_file.parent / path).resolve()


def candidate_import_roots(source_dir: Path, api_file: Path) -> list[Path]:
    """
    autodoc module path をファイルに対応づけるときの候補 root を返す。

    Sphinx プロジェクトでは conf.py で source_dir だけでなく、各カテゴリディレクトリ
    などを sys.path に追加していることがあるため、API ファイルのあるディレクトリも
    候補に入れる。

    例
    --
    source/XRD/xrd_pymatgen_api.rst に ``.. automodule:: jsap_crystal.xrd_pymatgen``
    がある場合、以下を順に見る。

    - source/jsap_crystal/xrd_pymatgen.py
    - source/XRD/jsap_crystal/xrd_pymatgen.py
    """

    roots: list[Path] = []
    for root in (source_dir.resolve(), api_file.parent.resolve()):
        if root not in roots:
            roots.append(root)
    return roots


def module_to_py_file(import_root: Path, module_path: str) -> Path:
    """module path に対応する .py ファイル候補を返す。"""

    parts = module_path.split(".")
    rel = Path(*parts)
    return (import_root / rel).with_suffix(".py")


def module_to_package_init(import_root: Path, module_path: str) -> Path:
    """
    module path が package 自体を指す場合の __init__.py を返す。

    注意: この候補は「実在する場合だけ」採用する。存在しない
    ``module_name/__init__.py`` を通常のエラー候補には出さない。
    """

    parts = module_path.split(".")
    rel = Path(*parts)
    return import_root / rel / "__init__.py"


def resolve_module_reference(
    source_dir: Path,
    ref: ApiReference,
) -> tuple[ModuleResolution | None, list[Path]]:
    """
    autodoc module path を実ファイルに解決する。

    戻り値の第2要素は、エラー表示用の .py 候補一覧。
    同名ディレクトリ package の __init__.py は、存在しない限りここには含めない。
    """

    module_path = ref.value
    py_candidates: list[Path] = []

    # まず通常の .py module だけを見る。
    for root in candidate_import_roots(source_dir, ref.api_file):
        py_candidate = module_to_py_file(root, module_path).resolve()
        if py_candidate not in py_candidates:
            py_candidates.append(py_candidate)
        if py_candidate.is_file():
            return ModuleResolution(
                module_path=module_path,
                py_file=py_candidate,
                import_root=root.resolve(),
                is_package_init=False,
            ), py_candidates

    # .py が存在しない場合だけ、package 自体の automodule として実在 __init__.py を採用する。
    # 存在しない __init__.py は大量のノイズになるので、エラー候補には出さない。
    for root in candidate_import_roots(source_dir, ref.api_file):
        init_candidate = module_to_package_init(root, module_path).resolve()
        if init_candidate.is_file():
            return ModuleResolution(
                module_path=module_path,
                py_file=init_candidate,
                import_root=root.resolve(),
                is_package_init=True,
            ), py_candidates

    return None, py_candidates


def iter_required_package_inits(
    import_root: Path,
    module_path: str,
    py_file: Path,
) -> list[Path]:
    """
    module_path を import するために必要な親 package の __init__.py を返す。

    例
    --
    import_root/source, module_path=jsap_crystal.xrd_pymatgen,
    py_file=source/jsap_crystal/xrd_pymatgen.py
        -> source/jsap_crystal/__init__.py

    ``xrd_pymatgen.py`` に対して
    ``source/jsap_crystal/xrd_pymatgen/__init__.py`` は要求しない。
    """

    import_root = import_root.resolve()
    parts = module_path.split(".")

    # package/__init__.py 自体を import する場合は、その package 自身ではなく、
    # 親 package だけが必須。
    if py_file.name == "__init__.py":
        package_parts = parts[:-1]
    else:
        package_parts = parts[:-1]

    inits: list[Path] = []
    cur = import_root
    for part in package_parts:
        cur = cur / part
        inits.append(cur / "__init__.py")
    return inits


def validate_required_package_inits(
    resolution: ModuleResolution,
    errors: list[dict],
    ref: ApiReference,
) -> None:
    """対象 module の親 package ディレクトリに __init__.py があるか確認する。"""

    missing = [
        p
        for p in iter_required_package_inits(
            resolution.import_root,
            resolution.module_path,
            resolution.py_file,
        )
        if not p.is_file()
    ]
    if missing:
        record_validation_error(
            errors,
            "api-package-init",
            ref.api_file,
            "ライブラリパスの親ディレクトリに __init__.py が存在しません",
            ref.line_no,
            f"{resolution.module_path} -> {resolution.py_file}; missing: "
            + ", ".join(str(p) for p in missing),
        )


def validate_py_file_size(
    py_file: Path,
    errors: list[dict],
    *,
    min_size_bytes: int = DEFAULT_MIN_PY_SIZE_BYTES,
    phase: str = "py-size",
    api_file: Path | None = None,
    line_no: int | None = None,
    ref_value: str = "",
) -> None:
    """*.py のファイルサイズを確認し、小さすぎる場合は validation error にする。

    min_size_bytes が 100 のときは、100 bytes 以下を too small として報告する。
    0 以下を指定するとサイズチェックを無効化する。
    """

    if min_size_bytes <= 0:
        return

    try:
        size = py_file.stat().st_size
    except OSError as exc:
        errors.append(
            {
                "phase": phase,
                "file": str(api_file or py_file),
                "error_type": type(exc).__name__,
                "message": "ファイルサイズ確認に失敗しました",
                "traceback": "".join(
                    traceback.format_exception(type(exc), exc, exc.__traceback__)
                ),
                "stdout": "",
                "stderr": "",
                "detail": str(py_file),
            }
        )
        return

    if size <= min_size_bytes:
        location_file = api_file if api_file is not None else py_file
        detail_items = [
            f"{py_file}",
            f"size={size} bytes",
            f"threshold<={min_size_bytes} bytes",
        ]
        if ref_value:
            detail_items.insert(0, f"ref={ref_value}")

        record_validation_error(
            errors,
            phase,
            location_file,
            ".py ファイルサイズが小さすぎます (too small)",
            line_no,
            "; ".join(detail_items),
        )


def validate_api_references(
    source_dir: Path,
    api_files: list[Path],
    errors: list[dict],
    show_refs: bool = False,
    min_py_size_bytes: int = DEFAULT_MIN_PY_SIZE_BYTES,
) -> list[Path]:
    """
    API ドキュメントの参照を検査し、import 対象 py ファイルの一覧を返す。
    """

    target_py_files: list[Path] = []

    if not api_files:
        record_validation_error(
            errors,
            "api-search",
            source_dir,
            "*_api.rst / *_api.md が見つかりません",
        )
        return []

    print(f"[INFO] API files = {len(api_files)}")

    for api_file in api_files:
        refs = extract_api_references(api_file)
        if show_refs:
            print(f"[API] {api_file} ({len(refs)} refs)")

        for ref in refs:
            if show_refs:
                print(
                    f"      L{ref.line_no}: {ref.kind:6s} "
                    f"{ref.directive:14s} {ref.value}"
                )

            if ref.kind == "module":
                module_path = ref.value
                ok, reason = validate_module_path(module_path)
                if not ok:
                    record_validation_error(
                        errors,
                        "api-module-path",
                        ref.api_file,
                        "ライブラリパスが Python module path として不正です",
                        ref.line_no,
                        f"{module_path}; reason: {reason}",
                    )
                    continue
    
                resolution, py_candidates = resolve_module_reference(source_dir, ref)
                if resolution is None:
                    record_validation_error(
                        errors,
                        "api-module-file",
                        ref.api_file,
                        "ライブラリパスに対応する .py が存在しません",
                        ref.line_no,
                        f"{module_path} -> " + ", ".join(str(p) for p in py_candidates),
                    )
                    continue

                validate_required_package_inits(resolution, errors, ref)

                if has_sphinx_unsafe_py_name(resolution.py_file):
                    record_validation_error(
                        errors,
                        "api-py-name",
                        ref.api_file,
                        "Sphinx autodoc / Python import に不向きな .py ファイル名です"
                        "（'-' などは使えません）",
                        ref.line_no,
                        str(resolution.py_file),
                    )

                validate_py_file_size(
                    resolution.py_file,
                    errors,
                    min_size_bytes=min_py_size_bytes,
                    phase="api-py-size",
                    api_file=ref.api_file,
                    line_no=ref.line_no,
                    ref_value=module_path,
                )
                target_py_files.append(resolution.py_file)

            elif ref.kind == "pyfile":
                py_file = resolve_py_reference(ref.api_file, ref.value)

                if not py_file.is_file():
                    record_validation_error(
                        errors,
                        "api-py-file",
                        ref.api_file,
                        ".py ファイル参照が存在しません",
                        ref.line_no,
                        f"{ref.value} -> {py_file}",
                    )
                    continue

                if has_sphinx_unsafe_py_name(py_file):
                    record_validation_error(
                        errors,
                        "api-py-name",
                        ref.api_file,
                        "Sphinx autodoc / Python import に不向きな .py ファイル名です"
                        "（'-' などは使えません）",
                        ref.line_no,
                        str(py_file),
                    )

                validate_py_file_size(
                    py_file,
                    errors,
                    min_size_bytes=min_py_size_bytes,
                    phase="api-py-size",
                    api_file=ref.api_file,
                    line_no=ref.line_no,
                    ref_value=ref.value,
                )
                target_py_files.append(py_file)

    # conf.py は別扱い。__init__.py は元コードの方針に合わせて import チェック対象から外す。
    uniq: list[Path] = []
    seen: set[Path] = set()
    for py_file in sorted(p.resolve() for p in target_py_files):
        if py_file in seen:
            continue
        seen.add(py_file)
        skip, reason = should_skip(py_file)
        if skip:
            if show_refs:
                print(f"[SKIP]   {py_file} ({reason})")
            continue
        uniq.append(py_file)

    return uniq


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 import_conf_py(args, source_dir: Path, conf_py: Path, errors: list[dict]) -> None:
    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)

        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)


def import_py_files(args, source_dir: Path, py_files: list[Path], errors: list[dict]) -> None:
    print(f"[INFO] import target .py files = {len(py_files)}")

    for py_file in py_files:
        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)


def main():
    parser = argparse.ArgumentParser(
        description=(
            "Sphinx import エラー調査用: *_api.rst/md から対象 .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 を成功時にも表示する",
    )
    parser.add_argument(
        "--show-api-refs",
        action="store_true",
        help="*_api.rst/md から抽出した参照を表示する",
    )
    parser.add_argument(
        "--all-py",
        action="store_true",
        help="従来通り source 配下の全 *.py を import チェック対象にする",
    )
    parser.add_argument(
        "--min-py-size",
        type=int,
        default=DEFAULT_MIN_PY_SIZE_BYTES,
        help=(
            "*.py の最小ファイルサイズ閾値。指定値以下なら too small として報告する "
            f"(default: {DEFAULT_MIN_PY_SIZE_BYTES}, 0以下で無効)"
        ),
    )

    # 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}")

    import_conf_py(args, source_dir, conf_py, errors)

    if args.all_py:
        print("[INFO] target mode = all *.py")
        py_files = sorted(source_dir.rglob("*.py"))
        for py_file in py_files:
            if py_file.name == "conf.py":
                continue
            validate_py_file_size(
                py_file.resolve(),
                errors,
                min_size_bytes=args.min_py_size,
                phase="py-size",
            )
    else:
        print("[INFO] target mode = *_api.rst/md references")
        api_files = iter_api_files(source_dir)
        py_files = validate_api_references(
            source_dir,
            api_files,
            errors,
            show_refs=args.show_api_refs,
            min_py_size_bytes=args.min_py_size,
        )

        if errors:
            print(
                "[INFO] API validation errors were found; "
                "import check will continue for resolvable .py files"
            )

    import_py_files(args, source_dir, py_files, errors)

    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"])

            if err.get("traceback"):
                print("[parent traceback]")
                print(err["traceback"])

    if errors:
        sys.exit(1)

    print("\n[OK] すべての対象 .py ファイルを import できました")


if __name__ == "__main__":
    main()
