#!/usr/bin/env python3
"""
check_sphinx_titles.py

指定ディレクトリ以下を再帰検索し、Sphinx文書としてタイトル形式が不正な
Markdown (.md) / reStructuredText (.rst) ファイルを一覧表示する。

既定の判定:
- Markdown:
    先頭行がレベル1 ATX見出し "# タイトル" であること
- reStructuredText:
    先頭行がタイトル本文、2行目が同一記号の下線であること
    例:
        Document title
        ==============

終了コード:
- 0: 不正ファイルなし
- 1: 不正ファイルあり
- 2: 引数・ディレクトリなどの実行エラー
"""

from __future__ import annotations

import argparse
import fnmatch
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence


DEFAULT_PATTERNS = "*.md;*.rst"
RST_ADORNMENT_CHARS = r'=~-^"\'`:+*#<>_.'


@dataclass(frozen=True)
class ValidationResult:
    path: Path
    valid: bool
    reason: str = ""


def parse_patterns(pattern_text: str) -> list[str]:
    """セミコロンまたはカンマ区切りのglobパターンを分解する。"""
    patterns = [
        item.strip()
        for item in re.split(r"[;,]", pattern_text)
        if item.strip()
    ]
    if not patterns:
        raise ValueError("ファイルパターンが空です。")
    return patterns


def find_files(root: Path, patterns: Sequence[str]) -> Iterable[Path]:
    """root以下から、いずれかのパターンに一致するファイルを再帰検索する。"""
    for path in root.rglob("*"):
        if not path.is_file():
            continue
        if any(fnmatch.fnmatch(path.name, pattern) for pattern in patterns):
            yield path


def read_text_lines(path: Path, encoding: str) -> tuple[list[str] | None, str | None]:
    """
    テキストを読み込む。
    utf-8指定時はUTF-8 BOMも自然に除去できるよう utf-8-sig を用いる。
    """
    actual_encoding = "utf-8-sig" if encoding.lower().replace("_", "-") == "utf-8" else encoding
    try:
        text = path.read_text(encoding=actual_encoding)
    except UnicodeDecodeError as exc:
        return None, f"文字コードエラー ({encoding}): {exc}"
    except OSError as exc:
        return None, f"読み込みエラー: {exc}"

    return text.splitlines(), None


def validate_markdown(lines: Sequence[str]) -> tuple[bool, str]:
    """Markdownの先頭行が '# タイトル' 形式か確認する。"""
    if not lines:
        return False, "空ファイルです"

    first = lines[0]
    if not first.strip():
        return False, "先頭行が空行です"

    # "# title" を要求する。"## title" や "#title" は不正。
    match = re.fullmatch(r"#\s+(.+?)\s*#*\s*", first)
    if not match:
        return False, '先頭行がMarkdownのレベル1タイトル "# タイトル" ではありません'

    title = match.group(1).strip()
    if not title:
        return False, "タイトル本文が空です"

    return True, ""


def validate_rst(lines: Sequence[str]) -> tuple[bool, str]:
    """reStructuredTextの先頭2行がタイトル＋下線形式か確認する。"""
    if not lines:
        return False, "空ファイルです"
    if not lines[0].strip():
        return False, "先頭行が空行です"
    if len(lines) < 2:
        return False, "タイトル下線となる2行目がありません"

    title = lines[0].rstrip()
    underline = lines[1].strip()

    if not underline:
        return False, "2行目のタイトル下線が空です"

    allowed = re.escape(RST_ADORNMENT_CHARS)
    if not re.fullmatch(rf"([{allowed}])\1{{2,}}", underline):
        return False, "2行目が同一記号3文字以上のreStructuredTextタイトル下線ではありません"

    # 全角文字を含む場合の表示幅は環境依存なので、極端に短い場合だけ不正とする。
    if len(underline) < max(3, len(title.strip()) // 2):
        return False, "タイトル下線がタイトルに対して短すぎます"

    return True, ""


def validate_file(path: Path, encoding: str) -> ValidationResult:
    """拡張子に応じてファイルを検査する。"""
    lines, error = read_text_lines(path, encoding)
    if error is not None or lines is None:
        return ValidationResult(path, False, error or "不明な読み込みエラー")

    suffix = path.suffix.lower()
    if suffix == ".md":
        valid, reason = validate_markdown(lines)
    elif suffix == ".rst":
        valid, reason = validate_rst(lines)
    else:
        return ValidationResult(path, False, f"未対応の拡張子です: {suffix}")

    return ValidationResult(path, valid, reason)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "ディレクトリを再帰検索し、先頭タイトル形式が不正な "
            "Markdown/reStructuredTextファイルを一覧表示します。"
        )
    )
    parser.add_argument(
        "directory",
        nargs="?",
        default=".",
        help="検索対象ディレクトリ（デフォルト: .）",
    )
    parser.add_argument(
        "-p",
        "--patterns",
        default=DEFAULT_PATTERNS,
        help='検索パターン。";" または "," 区切り（デフォルト: "*.md;*.rst"）',
    )
    parser.add_argument(
        "--encoding",
        default="utf-8",
        help="入力文字コード（デフォルト: utf-8）",
    )
    parser.add_argument(
        "--show-valid",
        action="store_true",
        help="正常なファイルも表示する",
    )
    parser.add_argument(
        "--absolute",
        action="store_true",
        help="絶対パスで表示する",
    )
    return parser


def display_path(path: Path, root: Path, absolute: bool) -> str:
    if absolute:
        return str(path.resolve())
    try:
        return str(path.relative_to(root))
    except ValueError:
        return str(path)


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    root = Path(args.directory).expanduser()
    if not root.exists():
        print(f"エラー: ディレクトリが存在しません: {root}", file=sys.stderr)
        return 2
    if not root.is_dir():
        print(f"エラー: ディレクトリではありません: {root}", file=sys.stderr)
        return 2

    try:
        patterns = parse_patterns(args.patterns)
    except ValueError as exc:
        print(f"エラー: {exc}", file=sys.stderr)
        return 2

    files = sorted(find_files(root, patterns), key=lambda p: str(p).lower())
    invalid_results: list[ValidationResult] = []

    for path in files:
        result = validate_file(path, args.encoding)
        shown_path = display_path(path, root, args.absolute)

        if result.valid:
            if args.show_valid:
                print(f"[OK]      {shown_path}")
        else:
            invalid_results.append(result)
            print(f"[INVALID] {shown_path}")
            print(f"          理由: {result.reason}")

    print()
    print(f"検索ファイル数: {len(files)}")
    print(f"不正ファイル数: {len(invalid_results)}")

    if invalid_results:
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
