#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Sphinxで生成された *_api.html を走査し、autodoc/API取り込みに失敗して本文が空っぽに近いHTMLを検出するスクリプト。

概要:
    Sphinxで生成されたAPIドキュメントHTMLファイル（通常 `*_api.html`）を分析し、
    Sphinxのautodoc機能がAPI要素を適切に抽出し、本文コンテンツを生成できなかったページを特定します。
    これにより、ドキュメント生成プロセスの問題を早期に発見するのに役立ちます。

詳細説明:
    スクリプトは指定されたディレクトリ以下のHTMLファイルを走査し、各ファイルのメインコンテンツ領域を抽出します。
    抽出されたコンテンツからHTMLタグを除去してプレーンテキストを生成し、
    そのテキストの長さや、典型的なSphinx autodocマーカーの存在をチェックします。
    本文が極端に短い、またはAPIマーカーが見つからない場合、そのページはAPI取り込みに失敗したと判断され、
    そのファイルパスと失敗理由が出力されます。

例:
    python check_sphinx_api_html.py
    python check_sphinx_api_html.py --root _build/html
    python check_sphinx_api_html.py --root _build/html --files "*_api.html"
    python check_sphinx_api_html.py --outfile failed_api_files.txt

関連リンク:
    :doc:`check_sphinx_api_html_usage`
"""

import argparse
import re
import sys
from pathlib import Path
from html import unescape


def read_text(path: Path) -> str:
    """
    HTMLファイルを文字コード推定つきで読む。

    複数の文字コード（utf-8, cp932, shift_jis, latin-1）を試行し、
    デコードに成功した場合はそのテキストを返します。
    すべての試行が失敗した場合は、バイト列を `utf-8` でエラーを置き換えてデコードします。

    :param path: Path HTMLファイルのパス。
    :returns: 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 strip_html(html: str) -> str:
    """
    HTMLタグをざっくり除去して本文テキスト化する。

    <script>, <style>, <nav>, <footer>, <header> タグとその内容を除去し、
    その後すべてのHTMLタグを除去します。HTMLエンティティをデコードし、
    複数の空白を単一の空白に変換して、前後の空白を除去します。

    :param html: str 処理対象のHTML文字列。
    :returns: str HTMLタグが除去され、整形されたテキスト。
    """
    html = re.sub(r"(?is)<script.*?</script>", " ", html)
    html = re.sub(r"(?is)<style.*?</style>", " ", html)
    html = re.sub(r"(?is)<nav.*?</nav>", " ", html)
    html = re.sub(r"(?is)<footer.*?</footer>", " ", html)
    html = re.sub(r"(?is)<header.*?</header>", " ", html)
    text = re.sub(r"(?is)<[^>]+>", " ", html)
    text = unescape(text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def extract_main_html(html: str) -> str:
    """
    Sphinx ReadTheDocs系HTMLから本文領域っぽい部分を抜く。

    複数の正規表現パターンを順に試し、`role="main"` や `class="document"` などの要素を含む
    HTMLの主要コンテンツ領域を抽出します。
    いずれのパターンにもマッチしない場合は、元のHTML全体を返します。

    :param html: str 処理対象のHTML文字列。
    :returns: str 抽出された本文領域のHTML、または元のHTML全体。
    """
    patterns = [
        r'(?is)<div[^>]+role=["\']main["\'][^>]*>(.*?)</div>\s*</div>\s*</section>',
        r'(?is)<div[^>]+class=["\'][^"\']*document[^"\']*["\'][^>]*>(.*?)</div>\s*</section>',
        r'(?is)<section[^>]*>(.*?)</section>',
        r'(?is)<main[^>]*>(.*?)</main>',
        r'(?is)<article[^>]*>(.*?)</article>',
    ]

    for pat in patterns:
        m = re.search(pat, html)
        if m:
            return m.group(1)

    return html


def check_api_html(path: Path, min_text_len: int = 300) -> tuple[bool, list[str]]:
    """
    指定されたHTMLファイルがSphinx autodocによるAPIドキュメントとして適切に生成されているかをチェックする。

    ファイルを読み込み、メインのHTMLコンテンツを抽出し、そこからテキストを整形します。
    Sphinx autodocの典型的なマーカーが存在するか、本文のテキスト長が短いか、
    あるいはタイトルのみのページのように見えるかなどを基準に、API取り込みの失敗を判定します。

    :param path: Path チェック対象のHTMLファイルのパス。
    :param min_text_len: int 本文テキストの最小許容長さ。これより短い場合は失敗と見なされる可能性がある。
    :returns: tuple[bool, list[str]]
        `failed` (bool): 失敗の疑いがある場合は `True`、そうでない場合は `False`。
        `reasons` (list[str]): 失敗と判断された理由のリスト。
    """
    html = read_text(path)
    main_html = extract_main_html(html)
    main_text = strip_html(main_html)

    reasons = []

    # Sphinx autodocの典型的なAPI要素
    api_markers = [
        "py function",
        "py class",
        "py method",
        "py attribute",
        "py data",
        "sig-name",
        "sig-prename",
        "descname",
        "descclassname",
        "dl class=\"py",
        "dl class='py",
    ]

    marker_count = sum(1 for s in api_markers if s in html)

    # Python API名らしい signature 行
    signature_like = re.findall(
        r"<dt[^>]*class=[\"'][^\"']*sig[^\"']*[\"'][^>]*>",
        html,
        flags=re.IGNORECASE,
    )

    # 失敗ページでよくある「タイトルだけ」の判定
    has_program_spec_title = "プログラム仕様" in main_text or "API" in main_text
    text_len = len(main_text)

    if marker_count == 0 and len(signature_like) == 0:
        reasons.append("no autodoc API markers")

    if text_len < min_text_len:
        reasons.append(f"main text too short: {text_len} < {min_text_len}")

    # タイトルだけで終わっている感じの補助判定
    if has_program_spec_title and marker_count == 0 and text_len < 1000:
        reasons.append("looks like title-only API page")

    failed = len(reasons) > 0
    return failed, reasons


def find_files(root: Path, pattern: str) -> list[Path]:
    """
    指定されたルートディレクトリ以下からワイルドカードパターンに一致するファイルを再帰的に検索する。

    `Path.rglob()` を使用して、ルートディレクトリ以下のすべてのファイルを再帰的に探索し、
    指定されたワイルドカードパターン（例: `*_api.html`）に一致し、かつファイルであるパスを収集します。
    結果はパスのリストとしてソートされて返されます。

    :param root: Path 検索を開始するルートディレクトリのパス。
    :param pattern: str 検索するファイルのワイルドカードパターン。
    :returns: list[Path] パターンに一致したファイルのパスのリスト。
    """
    return sorted(p for p in root.rglob(pattern) if p.is_file())


def main() -> int:
    """
    スクリプトのメインエントリポイント。SphinxのAPIドキュメントの健全性チェックを実行する。

    コマンドライン引数を解析し、指定されたルートディレクトリ内のファイルに対してAPIドキュメントのチェックを行います。
    失敗したファイルをリストアップし、指定された出力ファイルにそれらのパスを書き込みます。

    :returns: int 正常終了した場合は0、エラーが発生した場合は1。
    """
    parser = argparse.ArgumentParser(
        description="Check Sphinx *_api.html files and list pages where autodoc output looks empty."
    )
    parser.add_argument(
        "--root",
        type=str,
        default="./build/html",
        help="search root directory. default: .",
    )
    parser.add_argument(
        "--files",
        type=str,
        default="*_api.html",
        help='wildcard pattern for target files. default: "*_api.html"',
    )
    parser.add_argument(
        "--outfile",
        type=str,
        default="failed_api_html.txt",
        help="output file path. default: failed_api_html.txt",
    )
    parser.add_argument(
        "--exclude",
        action="append",
        default=[],
        help="exclude path keyword. Can be used multiple times.",
    )
    parser.add_argument(
        "--min-text-len",
        type=int,
        default=300,
        help="minimum main text length. default: 300",
    )
    parser.add_argument(
        "--show-ok",
        type=int,
        default=0,
        choices=[0, 1],
        help="show OK files too. default: 0",
    )

    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()

    failed_files: list[tuple[Path, list[str]]] = []

    for path in files:
        rel_str = str(path.relative_to(root)).replace("\\", "/")

        if any(x in rel_str for x in args.exclude):
            if args.show_ok:
                print(f"[SKIP]   {rel_str}")
            continue

        failed, reasons = check_api_html(path, min_text_len=args.min_text_len)
        rel = path.relative_to(root)

        if failed:
            failed_files.append((path, reasons))
            print(f"[FAILED] {rel}")
            for r in reasons:
                print(f"         - {r}")
        elif args.show_ok:
            print(f"[OK]     {rel}")

    print()
    print(f"failed  : {len(failed_files)} / {len(files)}")

    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")

    print(f"output  : {outfile.resolve()}")

    return 0


if __name__ == "__main__":
    sys.exit(main())