#!/usr/bin/env python3
"""
概要:
    Sphinxのusage.mdファイルからコンパクトなプログラムインデックスを作成します。
詳細説明:
    各入力ドキュメントから最初のレベル2見出しの本文を抽出します。
    プログラムのパスは、最初の見出し1にconvert2md.pyのようなファイル名が含まれている場合はそこから取得し、
    それ以外の場合はusageファイルの相対パスを安全なフォールバックとして使用します。
"""

from __future__ import annotations

import argparse
import fnmatch
import glob
import html
import os
import re
import sys
from pathlib import Path


USAGE_GLOB = "*_usage.md"
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$", re.MULTILINE)
# Sphinx/MyST's generated heading-anchor link, e.g. [](... "Link to this heading")
SPHINX_ANCHOR_RE = re.compile(r"\s*\[\]\([^)]*(?:\"Link to this heading\")?\)")
FILENAME_RE = re.compile(r"`([^`/\\]+\.(?:py|pl|sh|bat|ps1|exe))`|\b([^\s`/\\]+\.(?:py|pl|sh|bat|ps1|exe))\b", re.I)
LAUNCHER_PROGRAM_RE = re.compile(r"^\s*([^\s/\\]+\.py)\s*$", re.IGNORECASE)
LAUNCHER_CONTEXT_RE = re.compile(r"^\s{2,}(.+?)\s*$")


def clean_heading(text: str) -> str:
    """
    概要:
        見出しからSphinxのパーマリンク装飾を削除します。
    引数:
        :param text: 処理対象の見出しテキスト
        :type text: str
    戻り値:
        :returns: 装飾が削除されたテキスト
        :rtype: str
    """
    return SPHINX_ANCHOR_RE.sub("", text).strip()


def first_h1_filename(markdown: str) -> str | None:
    """
    概要:
        最初のレベル1見出しからプログラムのファイル名を抽出して返します。
    引数:
        :param markdown: 解析対象のマークダウンテキスト
        :type markdown: str
    戻り値:
        :returns: 抽出されたファイル名文字列、見つからない場合はNone
        :rtype: str | None
    """
    for match in HEADING_RE.finditer(markdown):
        if len(match.group(1)) != 1:
            continue
        found = FILENAME_RE.search(clean_heading(match.group(2)))
        if found:
            return next(part for part in found.groups() if part is not None)
        return None
    return None


def first_level2_body(markdown: str) -> str | None:
    """
    概要:
        最初のレベル2見出しのセクションを抽出し、そのサブセクションも保持して返します。
    引数:
        :param markdown: 解析対象のマークダウンテキスト
        :type markdown: str
    戻り値:
        :returns: 抽出されたセクションのテキスト、見つからない場合はNone
        :rtype: str | None
    """
    headings = list(HEADING_RE.finditer(markdown))
    start = next((i for i, item in enumerate(headings) if len(item.group(1)) == 2), None)
    if start is None:
        return None

    section_start = headings[start].end()
    section_end = len(markdown)
    for item in headings[start + 1 :]:
        # ``###`` etc. belong to this section; another ``#`` or ``##`` does not.
        if len(item.group(1)) <= 2:
            section_end = item.start()
            break
    return markdown[section_start:section_end].strip()


def program_label(usage_path: Path, markdown: str, display_root: Path) -> str:
    """
    概要:
        display_rootを基準とした表示用のプログラムパスを構築します。
    引数:
        :param usage_path: usageファイルのパス
        :type usage_path: pathlib.Path
        :param markdown: 解析対象のマークダウンテキスト
        :type markdown: str
        :param display_root: 相対パスの基準となるディレクトリ
        :type display_root: pathlib.Path
    戻り値:
        :returns: 構築されたプログラムパスの文字列表現
        :rtype: str
    """
    filename = first_h1_filename(markdown)
    if filename:
        candidate = usage_path.parent / filename
    else:
        candidate = usage_path
    return Path(os.path.relpath(candidate, start=display_root)).as_posix()


def inline_html(text: str) -> str:
    """
    概要:
        依存関係なしに説明文中で使用されるインラインのマークダウンをHTMLに変換します。
    引数:
        :param text: 変換対象のテキスト
        :type text: str
    戻り値:
        :returns: HTMLに変換されたテキスト
        :rtype: str
    """
    escaped = html.escape(text)
    escaped = re.sub(r"`([^`]+)`", r"<code>\1</code>", escaped)
    escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
    return escaped


def markdown_body_to_html(markdown: str) -> str:
    """
    概要:
        一般的なマークダウンの説明文を自己完結型の小さなHTMLフラグメントに変換します。
    引数:
        :param markdown: 変換対象のマークダウンテキスト
        :type markdown: str
    戻り値:
        :returns: 変換されたHTML文字列
        :rtype: str
    """
    output: list[str] = []
    paragraph: list[str] = []
    list_tag: str | None = None

    def flush_paragraph() -> None:
        """
        概要:
            蓄積された段落の内容をHTMLとして出力リストに追加し、段落をクリアします。
        """
        if paragraph:
            rendered_lines = "<br>\n".join(inline_html(line) for line in paragraph)
            output.append(f"<p>{rendered_lines}</p>")
            paragraph.clear()

    def close_list() -> None:
        """
        概要:
            開いているリストタグがあれば閉じます。
        """
        nonlocal list_tag
        if list_tag:
            output.append(f"</{list_tag}>")
            list_tag = None

    for line in markdown.splitlines():
        heading = re.match(r"^(#{3,6})\s+(.*)$", line)
        unordered = re.match(r"^\s*[-*+]\s+(.*)$", line)
        ordered = re.match(r"^\s*\d+[.)]\s+(.*)$", line)
        if heading:
            flush_paragraph()
            close_list()
            level = len(heading.group(1))
            output.append(f"<h{level}>{inline_html(clean_heading(heading.group(2)))}</h{level}>")
        elif unordered or ordered:
            flush_paragraph()
            tag = "ul" if unordered else "ol"
            if list_tag != tag:
                close_list()
                output.append(f"<{tag}>")
                list_tag = tag
            output.append(f"<li>{inline_html((unordered or ordered).group(1))}</li>")
        elif not line.strip():
            flush_paragraph()
            close_list()
        else:
            close_list()
            paragraph.append(line.strip())
    flush_paragraph()
    close_list()
    return "\n".join(output)


def render_markdown(entries: list[tuple[str, str, list[str]]]) -> str:
    """
    概要:
        プログラムのエントリリストからマークダウンを生成して返します。
    引数:
        :param entries: ラベル、本文、ランチャーエントリのリストを格納したタプルのリスト
        :type entries: list
    戻り値:
        :returns: 生成されたマークダウン文字列
        :rtype: str
    """
    blocks = ["# Programs"]
    for label, body, launcher_entries in entries:
        blocks.extend(("", "---", "", f"## {label}", "", body))
        if launcher_entries:
            blocks.extend(("", "### Launcher実装", ""))
            blocks.extend(f"- {entry}" for entry in launcher_entries)
    return "\n".join(blocks).rstrip() + "\n"


def split_summary_line(markdown: str) -> tuple[str, str]:
    """
    概要:
        セクションの最初の空でない行を要約として取得し、残りのテキストと分割します。
    引数:
        :param markdown: 分割対象のマークダウンテキスト
        :type markdown: str
    戻り値:
        :returns: 要約文字列と残りのテキスト文字列のタプル
        :rtype: tuple
    """
    lines = markdown.splitlines()
    for index, line in enumerate(lines):
        if line.strip():
            return line.strip(), "\n".join(lines[index + 1 :]).strip()
    return "", ""


def render_html(entries: list[tuple[str, str, list[str]]]) -> str:
    """
    概要:
        マークダウンのプログラムエントリを折りたたみ可能なHTMLブロックとしてレンダリングします。
    引数:
        :param entries: ラベル、本文、ランチャーエントリのリストを格納したタプルのリスト
        :type entries: list
    戻り値:
        :returns: レンダリングされたHTML文字列
        :rtype: str
    """
    blocks = [
        "<!doctype html>", "<html lang=\"ja\">", "<head>",
        "  <meta charset=\"utf-8\">", "  <title>Programs</title>",
        "  <style>",
        "    body { max-width: 1000px; margin: 2rem auto; padding: 0 1rem; font-family: sans-serif; }",
        "    #keyword { width: min(32rem, 100%); padding: .45rem .6rem; font-size: 1rem; }",
        "    #search-status { margin-left: .5rem; color: #555; }",
        "    details { margin: .7rem 0; padding: .45rem .7rem; border: 1px solid #ccc; border-radius: .3rem; }",
        "    summary { cursor: pointer; font-weight: bold; }",
        "  </style>", "</head>", "<body>",
        "<h1>Programs</h1>",
        "<label for=\"keyword\">キーワード検索:</label>",
        "<input id=\"keyword\" type=\"search\" placeholder=\"プログラム名・説明を検索\" autocomplete=\"off\">",
        "<span id=\"search-status\"></span>",
    ]
    for label, body, launcher_entries in entries:
        summary, remainder = split_summary_line(body)
        blocks.extend((
            "<section class=\"program\">",
            "<hr>",
            f"<h3>{html.escape(label)}</h3>",
            "<details>",
            f"  <summary>{inline_html(summary)}</summary>",
            markdown_body_to_html(remainder),
            "</details>",
        ))
        if launcher_entries:
            blocks.extend((
                "<details>",
                "  <summary>Launcher実装</summary>",
                *[f"  <div>{html.escape(entry)}</div>" for entry in launcher_entries],
                "</details>",
            ))
        blocks.append("</section>")
    blocks.extend((
        "<script>",
        "const keyword = document.getElementById('keyword');",
        "const status = document.getElementById('search-status');",
        "const items = [...document.querySelectorAll('.program')];",
        "keyword.addEventListener('input', () => {",
        "  const terms = keyword.value.trim().toLocaleLowerCase().split(/\\s+/).filter(Boolean);",
        "  let visible = 0;",
        "  for (const item of items) {",
        "    const text = item.textContent.toLocaleLowerCase();",
        "    const matched = terms.every(term => text.includes(term));",
        "    item.hidden = !matched;",
        "    if (matched) {",
        "      visible += 1;",
        "      if (terms.length) item.querySelector('details').open = true;",
        "    }",
        "  }",
        "  status.textContent = terms.length ? `${visible} 件` : '';",
        "});",
        "</script>", "</body>", "</html>",
    ))
    return "\n".join(blocks) + "\n"


def launcher_log_paths(pattern_specs: list[str]) -> list[Path]:
    """
    概要:
        カレントディレクトリからセミコロン区切りのログファイルのワイルドカードを展開します。
    引数:
        :param pattern_specs: パターン指定文字列のリスト
        :type pattern_specs: list
    戻り値:
        :returns: 解決されたファイルパスのリスト
        :rtype: list
    """
    paths: dict[Path, None] = {}
    for spec in pattern_specs:
        for pattern in spec.split(";"):
            pattern = pattern.strip()
            if not pattern:
                continue
            for found in glob.glob(pattern):
                path = Path(found)
                if path.is_file():
                    paths[path.resolve()] = None
    return sorted(paths, key=lambda path: str(path).lower())


def read_launcher_logs(log_paths: list[Path]) -> dict[str, list[str]]:
    """
    概要:
        ランチャーのログを読み込み、Pythonのベース名と表示行のリストの辞書として返します。
    引数:
        :param log_paths: ログファイルのパスのリスト
        :type log_paths: list
    戻り値:
        :returns: プログラム名をキー、表示用文字列リストを値とする辞書
        :rtype: dict
    """
    mapping: dict[str, list[str]] = {}
    for log_path in log_paths:
        try:
            lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
        except OSError as exc:
            print(f"Warning: cannot read Launcher log {log_path}: {exc}", file=sys.stderr)
            continue

        in_program_references = False
        current_program: str | None = None
        for line in lines:
            if line.startswith("Python program references"):
                in_program_references = True
                current_program = None
                continue
            if not in_program_references:
                continue

            program_match = LAUNCHER_PROGRAM_RE.match(line)
            if program_match:
                current_program = program_match.group(1)
                mapping.setdefault(current_program, [])
                continue

            context_match = LAUNCHER_CONTEXT_RE.match(line)
            if current_program and context_match:
                display = f"{log_path.name}: {context_match.group(1)}"
                if display not in mapping[current_program]:
                    mapping[current_program].append(display)
    return mapping


def build_index(root_dir: Path, display_root: Path, program_patterns: list[str], output_format: str,
                launcher_references: dict[str, list[str]]) -> tuple[str, int, list[str]]:
    """
    概要:
        対象ディレクトリ内のマークダウンファイルを再帰的に検索してプログラムインデックスを構築します。
    引数:
        :param root_dir: 検索対象のルートディレクトリ
        :type root_dir: pathlib.Path
        :param display_root: 表示用の相対パスの基準となるディレクトリ
        :type display_root: pathlib.Path
        :param program_patterns: 対象プログラムを絞り込むためのパターンリスト
        :type program_patterns: list
        :param output_format: 出力フォーマット名、htmlまたはmarkdown
        :type output_format: str
        :param launcher_references: ランチャーの参照情報の辞書
        :type launcher_references: dict
    戻り値:
        :returns: 生成された出力文字列、処理されたエントリ数、警告メッセージリストのタプル
        :rtype: tuple
    """
    entries: list[tuple[str, str, list[str]]] = []
    warnings: list[str] = []

    usage_paths = sorted(path for path in root_dir.rglob(USAGE_GLOB) if path.is_file())
    for usage_path in usage_paths:
        try:
            markdown = usage_path.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            warnings.append(f"skip (not UTF-8): {usage_path}")
            continue
        body = first_level2_body(markdown)
        if not body:
            warnings.append(f"skip (no ## section): {usage_path}")
            continue
        label = program_label(usage_path, markdown, display_root)
        if not any(fnmatch.fnmatchcase(label, pattern) for pattern in program_patterns):
            continue
        launcher_entries = launcher_references.get(Path(label).name, [])
        entries.append((label, body, launcher_entries))

    output = render_html(entries) if output_format == "html" else render_markdown(entries)
    return output, len(entries), warnings


def main() -> int:
    """
    概要:
        コマンドライン引数を解析し、プログラムインデックスの構築処理を実行します。
    戻り値:
        :returns: 終了コード
        :rtype: int
    """
    parser = argparse.ArgumentParser(
        description="Create a Programs index from recursively searched Markdown files."
    )
    parser.add_argument("root_dir", type=Path, help="directory to search recursively")
    parser.add_argument(
        "root_dir_output",
        type=Path,
        nargs="?",
        default=None,
        help="base directory for displayed relative program paths (default: root_dir)",
    )
    parser.add_argument("-o", "--output", type=Path, default=None, help="output file (default: stdout)")
    parser.add_argument("-p", "--pattern", action="append", default=None, metavar="GLOB", help="program-path wildcard(s), separated by ; and/or specified repeatedly (default: *)")
    parser.add_argument("--script_list", action="append", default=None, metavar="GLOB", help="Launcher_extract_python.py log-file wildcard(s), separated by ; and/or specified repeatedly")
    parser.add_argument("-f", "--format", choices=("auto", "markdown", "html"), default="auto", help="output format (default: infer from --output suffix)")
    args = parser.parse_args()

    root_dir = args.root_dir.resolve()
    if not root_dir.is_dir():
        parser.error(f"root_dir is not a directory: {root_dir}")
    display_root = (args.root_dir_output if args.root_dir_output is not None else root_dir).resolve()

    output_format = args.format
    if output_format == "auto":
        output_format = "html" if args.output and args.output.suffix.lower() in {".html", ".htm"} else "markdown"
    pattern_specs = args.pattern or ["*"]
    patterns = [pattern.strip() for spec in pattern_specs for pattern in spec.split(";") if pattern.strip()]
    launcher_logs = launcher_log_paths(args.script_list or [])
    launcher_references = read_launcher_logs(launcher_logs)
    output, count, warnings = build_index(root_dir, display_root, patterns, output_format, launcher_references)
    if args.output:
        args.output.write_text(output, encoding="utf-8")
    else:
        sys.stdout.write(output)
    for warning in warnings:
        print(f"Warning: {warning}", file=sys.stderr)
    print(f"Collected {count} program description(s).", file=sys.stderr)
    if launcher_logs:
        print(f"Read {len(launcher_logs)} Launcher log file(s).", file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())