#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
check_sphinx_api_rst.py

Sphinx の *_api.rst を再帰走査し、autodoc で問題になりやすい記述を検出する。
--fix を指定した場合、モジュール名のハイフンやWindowsパス区切りを自動修正する。

チェック内容:
  1. .. automodule:: に指定されたモジュール名に '-' が含まれていないか
  2. .. automodule:: に指定されたモジュール名に '\\' や '/' が含まれていないか
  3. 対応する .py ファイルに argv / sys.argv を使っているのに
     if __name__ == "__main__": ガードが無い可能性がないか

--fix の自動修正内容:
  - automodule の module-name 中の '\\' と '/' を '.' に置換
  - automodule の module-name 中の '-' を '_' に置換
  - 対応する .py ファイル名の '-' を '_' に置換
  - 対応する *_api.rst ファイル名の '-' を '_' に置換
  - 同じディレクトリ内の関連しそうな usage/examples/index rst/md 参照の '-' を '_' に置換

例:
    python check_sphinx_api_rst.py
    python check_sphinx_api_rst.py --root ./source
    python check_sphinx_api_rst.py --fix
    python check_sphinx_api_rst.py --fix --dry-run
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path


AUTOMODULE_RE = re.compile(
    r"(^\s*\.\.\s+automodule::\s+)([^\s]+)(\s*$)",
    flags=re.MULTILINE,
)

MAIN_GUARD_RE = re.compile(
    r"if\s+__name__\s*==\s*['\"]__main__['\"]\s*:",
    flags=re.MULTILINE,
)

ARGV_RE = re.compile(
    r"\b(?:sys\s*\.\s*)?argv\b"
)


def read_text(path: Path) -> str:
    """テキストファイルを文字コード推定つきで読む。"""
    for enc in ("utf-8", "cp932", "shift_jis", "latin-1"):
        try:
            return path.read_text(encoding=enc)
        except UnicodeDecodeError:
            continue
    return path.read_bytes().decode("utf-8", errors="replace")


def write_text(path: Path, text: str, dry_run: bool = False) -> None:
    """UTF-8でテキストを書き込む。"""
    if dry_run:
        return
    path.write_text(text, encoding="utf-8")


def find_files(root: Path, pattern: str) -> list[Path]:
    """root以下からワイルドカードに一致するファイルを再帰検索。"""
    return sorted(p for p in root.rglob(pattern) if p.is_file())


def extract_automodule_names(rst_text: str) -> list[str]:
    """RST本文から automodule のモジュール名を抽出する。"""
    return [m.group(2).strip() for m in AUTOMODULE_RE.finditer(rst_text)]


def safe_module_name(module_name: str) -> str:
    """Python import可能な形に補正する。"""
    module_name = module_name.replace("\\", ".")
    module_name = module_name.replace("/", ".")
    module_name = module_name.replace("-", "_")
    return module_name


def module_name_to_py_path(root: Path, module_name: str) -> Path:
    """
    automodule のモジュール名から対応する .py ファイル候補を作る。

    例:
        regression.adaptive_gaussian_ridge
        -> root/regression/adaptive_gaussian_ridge.py
    """
    parts = module_name.split(".")
    return root.joinpath(*parts).with_suffix(".py")


def fallback_py_path_from_rst(rst_path: Path) -> Path:
    """
    *_api.rst から同じディレクトリの .py ファイル候補を推定する。

    例:
        regression/foo_api.rst -> regression/foo.py
    """
    stem = rst_path.stem
    if stem.endswith("_api"):
        stem = stem[:-4]
    return rst_path.with_name(stem + ".py")


def has_unprotected_argv(py_text: str) -> bool:
    """
    argv / sys.argv が存在し、かつ __main__ ガードが無い場合 True。

    厳密な制御フロー解析ではなく、Sphinx autodoc で危険な典型パターンの検出用。
    """
    result = bool(MAIN_GUARD_RE.search(py_text))
    if result: return False
    
    result = bool(ARGV_RE.search(py_text))
    if result:
#        print("py_text:", py_text)
        return True

    return False


def rename_file(src: Path, dst: Path, actions: list[str], dry_run: bool = False) -> Path:
    """ファイルをリネームする。既にdstがある場合はsrcのままにする。"""
    if src == dst:
        return src

    if not src.exists():
        return src

    if dst.exists():
        actions.append(f"SKIP rename because destination exists: {src} -> {dst}")
        return src

    actions.append(f"RENAME {src} -> {dst}")
    if not dry_run:
        src.rename(dst)
    return dst


def replace_in_file(path: Path, old: str, new: str, actions: list[str], dry_run: bool = False) -> None:
    """ファイル内の文字列を置換する。"""
    if not path.is_file():
        return

    text = read_text(path)
    if old not in text:
        return

    new_text = text.replace(old, new)
    actions.append(f"REPLACE in {path}: {old} -> {new}")
    write_text(path, new_text, dry_run=dry_run)


def replace_automodule_name(
    rst_path: Path,
    old_module_name: str,
    new_module_name: str,
    actions: list[str],
    dry_run: bool = False,
) -> None:
    """
    automodule directive のモジュール名だけを安全に置換する。

    通常の text.replace でも動くが、ここでは automodule 行に限定する。
    """
    if not rst_path.is_file():
        return

    text = read_text(rst_path)

    def repl(m: re.Match) -> str:
        prefix, name, suffix = m.group(1), m.group(2), m.group(3)
        if name == old_module_name:
            return prefix + new_module_name + suffix
        return m.group(0)

    new_text = AUTOMODULE_RE.sub(repl, text)

    if new_text != text:
        actions.append(f"REPLACE automodule in {rst_path}: {old_module_name} -> {new_module_name}")
        write_text(rst_path, new_text, dry_run=dry_run)


def fix_module_name(
    root: Path,
    rst_path: Path,
    module_name: str,
    actions: list[str],
    dry_run: bool = False,
) -> tuple[Path, str]:
    """
    automodule のモジュール名を safe_module_name に補正する。

    - '\\' と '/' は '.' に置換
    - '-' は '_' に置換
    - '-' を含む実ファイル名はリネームする

    戻り値:
        current_rst_path, fixed_module_name
    """
    fixed_module_name = safe_module_name(module_name)
    if fixed_module_name == module_name:
        return rst_path, module_name

    old_py = module_name_to_py_path(root, module_name)
    new_py = module_name_to_py_path(root, fixed_module_name)

    # path separator が混じる場合、old_py は実在パスとして解決できないことがある。
    # その場合は rst ファイル名から同名 .py を推定する。
    if not old_py.is_file():
        fallback_old_py = fallback_py_path_from_rst(rst_path)
        fallback_new_py = fallback_old_py.with_name(fallback_old_py.name.replace("-", "_"))
        if fallback_old_py.is_file():
            old_py = fallback_old_py
            new_py = fallback_new_py

    # .py ファイル名の '-' は '_' へリネームする。
    # '\' -> '.' の修正だけなら、通常 .py のリネームは不要。
    if "-" in old_py.name:
        rename_file(old_py, new_py, actions, dry_run=dry_run)

    # rst 内の automodule 名を必ず修正する。
    replace_automodule_name(
        rst_path,
        module_name,
        fixed_module_name,
        actions,
        dry_run=dry_run,
    )

    # rst ファイル名の '-' は '_' へリネームする。
    new_rst_path = rst_path.with_name(rst_path.name.replace("-", "_"))
    actual_rst_path = rst_path
    if "-" in rst_path.name:
        actual_rst_path = rename_file(rst_path, new_rst_path, actions, dry_run=dry_run)

    # 同じディレクトリ内の関連ドキュメント参照も軽く直す。
    old_stem = rst_path.stem.replace("_api", "")
    new_stem = old_stem.replace("-", "_")
    if old_stem != new_stem:
        target_dir = actual_rst_path.parent if not dry_run else rst_path.parent

        for ext in ("*.rst", "*.md"):
            for p in target_dir.glob(ext):
                replace_in_file(p, old_stem, new_stem, actions, dry_run=dry_run)
                replace_in_file(p, old_stem + "_api", new_stem + "_api", actions, dry_run=dry_run)
                replace_in_file(p, old_stem + "_usage", new_stem + "_usage", actions, dry_run=dry_run)
                replace_in_file(p, old_stem + "_examples", new_stem + "_examples", actions, dry_run=dry_run)
                replace_in_file(p, old_stem + "_index", new_stem + "_index", actions, dry_run=dry_run)

        for suffix in ("_usage", "_examples", "_index", "_api"):
            old_file = target_dir / f"{old_stem}{suffix}.rst"
            new_file = target_dir / f"{new_stem}{suffix}.rst"
            rename_file(old_file, new_file, actions, dry_run=dry_run)

            old_file = target_dir / f"{old_stem}{suffix}.md"
            new_file = target_dir / f"{new_stem}{suffix}.md"
            rename_file(old_file, new_file, actions, dry_run=dry_run)

    return actual_rst_path, fixed_module_name


def check_one_rst(
    root: Path,
    rst_path: Path,
    fix: bool = False,
    dry_run: bool = False,
) -> tuple[bool, list[str], list[str]]:
    """
    1つの *_api.rst をチェックする。

    戻り値:
        failed: 問題ありなら True
        reasons: 理由リスト
        actions: --fixで実行または予定した修正内容
    """
    rst_text = read_text(rst_path)
    module_names = extract_automodule_names(rst_text)
    reasons: list[str] = []
    actions: list[str] = []

    if not module_names:
        reasons.append("no automodule directive found")
        return True, reasons, actions

    current_rst_path = rst_path

    for module_name0 in module_names:
        module_name = module_name0
        fixed_module_name = safe_module_name(module_name)

        if fixed_module_name != module_name:
            if "-" in module_name:
                reasons.append(f"invalid module name contains '-': {module_name}")
            if "\\" in module_name or "/" in module_name:
                reasons.append(f"invalid module name contains path separator: {module_name}")

            if fix:
                current_rst_path, module_name = fix_module_name(
                    root,
                    current_rst_path,
                    module_name,
                    actions,
                    dry_run=dry_run,
                )
            else:
                module_name = fixed_module_name

        py_path = module_name_to_py_path(root, module_name)

        if not py_path.is_file():
            fallback = fallback_py_path_from_rst(current_rst_path)
            if fallback.is_file():
                py_path = fallback
                reasons.append(
                    f"module path not found, fallback py file used: {py_path.relative_to(root)}"
                )
            else:
                reasons.append(
                    f"corresponding py file not found for module: {module_name}"
                )
                continue

        py_text = read_text(py_path)

        if has_unprotected_argv(py_text):
            reasons.append(
                f"argv is used but __main__ guard was not found: {py_path.relative_to(root)}"
            )

    # fix後に、修正対象だったハイフン/セパレータ理由は再評価して消す。
    if fix and actions:
        new_text = read_text(current_rst_path) if current_rst_path.exists() else rst_text
        remaining_modules = extract_automodule_names(new_text)
        remaining_bad = [
            m for m in remaining_modules
            if "-" in m or "\\" in m or "/" in m
        ]

        reasons = [
            r for r in reasons
            if not r.startswith("invalid module name contains '-'")
            and not r.startswith("invalid module name contains path separator")
        ]

        for m in remaining_bad:
            reasons.append(f"invalid module name still contains invalid character: {m}")

    failed = len(reasons) > 0
    return failed, reasons, actions


def main() -> int:
    parser = argparse.ArgumentParser(
        description=(
            "Check Sphinx *_api.rst files for invalid automodule names and "
            "argv usage without __main__ guard in corresponding .py files."
        )
    )
    parser.add_argument(
        "--root",
        type=str,
        default="./source",
        help='Sphinx source root directory. default: "./source"',
    )
    parser.add_argument(
        "--files",
        type=str,
        default="*_api.rst",
        help='wildcard pattern for target RST files. default: "*_api.rst"',
    )
    parser.add_argument(
        "--outfile",
        type=str,
        default="failed_api_rst.txt",
        help='output file path. default: "failed_api_rst.txt"',
    )
    parser.add_argument(
        "--exclude",
        action="append",
        default=[],
        help="exclude path keyword. Can be used multiple times.",
    )
    parser.add_argument(
        "--show-ok",
        type=int,
        default=0,
        choices=[0, 1],
        help="show OK files too. default: 0",
    )
    parser.add_argument(
        "--fix",
        action="store_true",
        help="automatically fix '-' and path separators in automodule names when safe.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="show planned fixes without modifying files. Use with --fix.",
    )

    args = parser.parse_args()

    root = Path(args.root).resolve()
    outfile = Path(args.outfile)

    if not root.exists():
        print(f"ERROR: root directory not found: {root}")
        return 1

    files = find_files(root, args.files)

    print(f"root    : {root}")
    print(f"pattern : {args.files}")
    print(f"found   : {len(files)} files")
    print(f"fix     : {args.fix}")
    print(f"dry-run : {args.dry_run}")
    print()

    failed_files: list[tuple[Path, list[str]]] = []
    action_log: list[str] = []
    checked_count = 0
    skipped_count = 0

    for path in files:
        if not path.exists():
            # 直前の --fix でリネーム済みの場合など
            continue

        rel_str = str(path.relative_to(root)).replace("\\", "/")

        if any(x in rel_str for x in args.exclude):
            skipped_count += 1
            if args.show_ok:
                print(f"[SKIP]   {rel_str}")
            continue

        checked_count += 1
        failed, reasons, actions = check_one_rst(
            root,
            path,
            fix=args.fix,
            dry_run=args.dry_run,
        )
        action_log.extend(actions)

        if actions:
            print(f"[FIX]    {rel_str}")
            for a in actions:
                print(f"         - {a}")

        if failed:
            failed_files.append((path, reasons))
            print(f"[FAILED] {rel_str}")
            for r in reasons:
                print(f"         - {r}")
        elif args.show_ok and not actions:
            print(f"[OK]     {rel_str}")

    print()
    print(f"checked : {checked_count}")
    print(f"skipped : {skipped_count}")
    print(f"failed  : {len(failed_files)} / {checked_count}")
    print(f"actions : {len(action_log)}")

    with outfile.open("w", encoding="utf-8") as f:
        for path, reasons in failed_files:
            try:
                rel = path.relative_to(root)
            except ValueError:
                rel = path
            f.write(str(rel).replace("\\", "/"))
            f.write("\n")
            for r in reasons:
                f.write(f"  - {r}\n")

        if action_log:
            f.write("\n[ACTIONS]\n")
            for a in action_log:
                f.write(f"- {a}\n")

    print(f"output  : {outfile.resolve()}")

    return 1 if failed_files else 0


if __name__ == "__main__":
    sys.exit(main())
