#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
概要:
    指定した作業ディレクトリでコマンドを実行し、実行結果をMarkdownレポートとして出力するツールです。
詳細説明:
    コマンド実行前後のファイル差分、標準出力、標準エラー出力、終了コードなどを収集し、
    さらに生成された画像やログファイル内の警告・エラーを検出して、
    詳細なレポートを生成します。Pandocが利用可能な場合は、HTMLレポートも作成できます。
主な機能:
    - コマンド実行前後のファイルシステムの変更を追跡します。
    - 実行されたコマンドの標準出力と標準エラー出力を記録します。
    - ログファイル内から特定のパターン（エラー、警告）を検索します。
    - 生成された画像をレポートに埋め込みます。
    - Markdown形式またはHTML形式で実行レポートを出力します。
"""

from __future__ import annotations

import argparse
import fnmatch
import os
import platform
import re
import shlex
import subprocess
import sys
import traceback
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable


@dataclass
class FileInfo:
    """
    概要:
        ファイル情報を格納するデータクラス。
    詳細説明:
        ファイルの相対パス、サイズ、最終更新時刻（ナノ秒単位）、
        およびISO形式の最終更新時刻を保持します。
    属性:
        :param relpath: 作業ディレクトリからの相対パス。
        :type relpath: str
        :param size: ファイルのサイズ（バイト単位）。
        :type size: int
        :param mtime_ns: ファイルの最終更新時刻（ナノ秒単位）。
        :type mtime_ns: int
        :param mtime_iso: ファイルの最終更新時刻のISO 8601形式文字列。
        :type mtime_iso: str
    """
    relpath: str
    size: int
    mtime_ns: int
    mtime_iso: str


@dataclass
class RunResult:
    """
    概要:
        コマンド実行結果を格納するデータクラス。
    詳細説明:
        実行されたコマンド、終了コード、標準出力、標準エラー出力、
        開始時刻、終了時刻、およびタイムアウトしたかどうかを保持します。
    属性:
        :param command: 実行されたコマンド文字列。
        :type command: str
        :param returncode: コマンドの終了コード。
        :type returncode: int
        :param stdout: コマンドの標準出力。
        :type stdout: str
        :param stderr: コマンドの標準エラー出力。
        :type stderr: str
        :param started_at: コマンド実行開始時刻のISO 8601形式文字列。
        :type started_at: str
        :param finished_at: コマンド実行終了時刻のISO 8601形式文字列。
        :type finished_at: str
        :param timed_out: コマンドがタイムアウトしたかどうかを示す真偽値。
        :type timed_out: bool
    """
    command: str
    returncode: int
    stdout: str
    stderr: str
    started_at: str
    finished_at: str
    timed_out: bool = False


@dataclass
class LogHit:
    """
    概要:
        ログファイル内で検出された警告またはエラーの情報を格納するデータクラス。
    詳細説明:
        検出された行の相対パス、行番号、種類（ERRORまたはWARNING）、および該当行の内容を保持します。
    属性:
        :param relpath: ファイルの相対パス、または標準エラー出力を示す __stderr__ 。
        :type relpath: str
        :param lineno: 検出された行の行番号（1から始まる）。
        :type lineno: int
        :param kind: 検出されたヒットの種類（ERRORまたはWARNING）。
        :type kind: str
        :param line: 検出された行の内容。
        :type line: str
    """
    relpath: str
    lineno: int
    kind: str
    line: str


IMAGE_EXTS = {
    ".png",
    ".jpg",
    ".jpeg",
    ".gif",
    ".svg",
    ".webp",
}

LOG_EXTS_DEFAULT = ".log,.out,.txt"

DEFAULT_IGNORE = (
    ".git,__pycache__,venv,.venv,.mypy_cache,.pytest_cache,"
    "*.pyc,*.pyo,*.tmp,*.bak,*.swp"
)


def parse_csv_list(text: str) -> list[str]:
    """
    概要:
        カンマ区切りの文字列をリストに分割します。
    詳細説明:
        空の文字列や空白のみの要素は除外されます。
    引数:
        :param text: カンマ区切りの文字列。
        :type text: str
    戻り値:
        :returns: 分割された文字列のリスト。
        :rtype: list[str]
    """
    if not text:
        return []
    return [x.strip() for x in text.split(",") if x.strip()]


def now_iso() -> str:
    """
    概要:
        現在のローカル時刻をISO 8601形式の文字列で返します。
    詳細説明:
        タイムゾーン情報を含み、秒単位までの精度です。
    戻り値:
        :returns: 現在時刻のISO 8601形式文字列。
        :rtype: str
    """
    return datetime.now().astimezone().isoformat(timespec="seconds")


def file_mtime_iso(path: Path) -> str:
    """
    概要:
        ファイルの最終更新時刻をISO 8601形式の文字列で返します。
    詳細説明:
        指定されたファイルの最終更新時刻を取得し、ローカルタイムゾーンに変換してISO 8601形式でフォーマットします。
    引数:
        :param path: 処理するファイルのパスオブジェクト。
        :type path: Path
    戻り値:
        :returns: ファイル最終更新時刻のISO 8601形式文字列。
        :rtype: str
    """
    return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds")


def normalize_relpath(path: Path) -> str:
    """
    概要:
        パスを正規化された相対パス文字列として返します。
    詳細説明:
        Pathオブジェクトを、スラッシュ区切りのポータブルな文字列形式に変換します。
    引数:
        :param path: 正規化するパスオブジェクト。
        :type path: Path
    戻り値:
        :returns: スラッシュ区切りの相対パス文字列。
        :rtype: str
    """
    return path.as_posix()


def is_ignored(relpath: str, patterns: Iterable[str]) -> bool:
    """
    概要:
        指定された相対パスが無視パターンに一致するかどうかを判定します。
    詳細説明:
        relpath はスラッシュ区切りの相対パスです。
        パターンはファイル名（basename）にも、パス全体にも、パスの各部分にも適用されます。
        いずれかのパターンに一致すれば無視されます。
    引数:
        :param relpath: 検査対象の相対パス。
        :type relpath: str
        :param patterns: 無視するパターン文字列のイテラブル。fnmatch 形式を使用します。
        :type patterns: Iterable[str]
    戻り値:
        :returns: パスが無視されるべき場合はTrue、そうでない場合はFalse。
        :rtype: bool
    """
    parts = relpath.split("/")
    basename = parts[-1]

    for pat in patterns:
        pat = pat.strip()
        if not pat:
            continue

        if fnmatch.fnmatch(relpath, pat):
            return True

        if fnmatch.fnmatch(basename, pat):
            return True

        for part in parts:
            if fnmatch.fnmatch(part, pat):
                return True

    return False


def snapshot_files(workdir: Path, ignore_patterns: list[str]) -> dict[str, FileInfo]:
    """
    概要:
        指定された作業ディレクトリ内のファイルのスナップショットを作成します。
    詳細説明:
        workdir 内のすべてのファイル（無視パターンに一致しないもの）について、
        FileInfo オブジェクトを作成し、相対パスをキーとする辞書として返します。
        ディレクトリはスキップされます。
    引数:
        :param workdir: スナップショットを作成する作業ディレクトリのパス。
        :type workdir: Path
        :param ignore_patterns: 無視するファイルパスのパターンリスト。
        :type ignore_patterns: list[str]
    戻り値:
        :returns: 相対パスをキーとし、FileInfoオブジェクトを値とする辞書。
        :rtype: dict[str, FileInfo]
    """
    files: dict[str, FileInfo] = {}

    for path in workdir.rglob("*"):
        try:
            rel = normalize_relpath(path.relative_to(workdir))
        except ValueError:
            continue

        if is_ignored(rel, ignore_patterns):
            continue

        if path.is_dir():
            continue

        if not path.is_file():
            continue

        try:
            st = path.stat()
        except OSError:
            continue

        files[rel] = FileInfo(
            relpath=rel,
            size=st.st_size,
            mtime_ns=st.st_mtime_ns,
            mtime_iso=file_mtime_iso(path),
        )

    return dict(sorted(files.items(), key=lambda kv: kv[0].lower()))


def diff_snapshots(
    before: dict[str, FileInfo],
    after: dict[str, FileInfo],
) -> tuple[list[FileInfo], list[FileInfo], list[FileInfo]]:
    """
    概要:
        2つのファイルスナップショット間の差分を計算します。
    詳細説明:
        実行前と実行後のファイルスナップショットを比較し、
        新しく作成されたファイル、変更されたファイル、削除されたファイルを特定します。
        ファイルの変更はサイズまたは最終更新時刻の変更によって検出されます。
    引数:
        :param before: 実行前のファイルスナップショット辞書。
        :type before: dict[str, FileInfo]
        :param after: 実行後のファイルスナップショット辞書。
        :type after: dict[str, FileInfo]
    戻り値:
        :returns: 作成されたファイル、変更されたファイル、削除されたファイルのリストからなるタプル。
        :rtype: tuple[list[FileInfo], list[FileInfo], list[FileInfo]]
    """
    before_keys = set(before)
    after_keys = set(after)

    created = [after[k] for k in sorted(after_keys - before_keys)]
    deleted = [before[k] for k in sorted(before_keys - after_keys)]

    modified: list[FileInfo] = []
    for key in sorted(before_keys & after_keys):
        b = before[key]
        a = after[key]
        if b.size != a.size or b.mtime_ns != a.mtime_ns:
            modified.append(a)

    return created, modified, deleted


def human_size(n: int) -> str:
    """
    概要:
        バイト数を人間が読みやすい形式に変換します。
    詳細説明:
        バイト数をKB, MB, GB, TBなどの適切な単位に変換し、小数点以下2桁で表示します。
        単位がBの場合は整数で表示します。
    引数:
        :param n: バイト数。
        :type n: int
    戻り値:
        :returns: 人間が読みやすい形式のサイズ文字列。
        :rtype: str
    """
    units = ["B", "KB", "MB", "GB", "TB"]
    x = float(n)
    for unit in units:
        if x < 1024.0 or unit == units[-1]:
            if unit == "B":
                return f"{int(x)} {unit}"
            return f"{x:.2f} {unit}"
        x /= 1024.0
    return f"{n} B"


def md_escape(text: str) -> str:
    """
    概要:
        Markdown特殊文字をエスケープします。
    詳細説明:
        パイプ文字（|）をエスケープし、改行をスペースに置換します。
        これにより、表内のテキストが正しく表示されるようにします。
    引数:
        :param text: エスケープする文字列。
        :type text: str
    戻り値:
        :returns: エスケープされた文字列。
        :rtype: str
    """
    return text.replace("|", r"\|").replace("\n", " ")


def md_path_link(relpath: str, label: str | None = None) -> str:
    """
    概要:
        Markdown形式の相対パスリンクを生成します。
    詳細説明:
        Pandoc対応のMarkdownで相対パスをリンクとして表示するための形式を生成します。
        空白を含むファイル名にも安全な <...> 形式を使用します。
    引数:
        :param relpath: リンク先の相対パス。
        :type relpath: str
        :param label: リンクの表示ラベル。Noneの場合、relpathがラベルとして使用されます。
        :type label: str | None
    戻り値:
        :returns: Markdown形式のリンク文字列。
        :rtype: str
    """
    label = label or relpath
    href = relpath.replace("\\", "/")
    return f"[{md_escape(label)}](<{href}>)"


def md_image(relpath: str) -> str:
    """
    概要:
        Markdown形式の画像埋め込み文字列を生成します。
    詳細説明:
        Pandoc対応のMarkdownで画像を埋め込むための形式を生成します。
        相対パスを画像ソースとして指定します。
    引数:
        :param relpath: 画像ファイルの相対パス。
        :type relpath: str
    戻り値:
        :returns: Markdown形式の画像埋め込み文字列。
        :rtype: str
    """
    href = relpath.replace("\\", "/")
    return f"![{md_escape(relpath)}](<{href}>)"


def make_file_table(files: list[FileInfo], title: str, max_items: int) -> str:
    """
    概要:
        ファイル情報のMarkdownテーブルを生成します。
    詳細説明:
        指定されたファイル情報のリストから、ファイル名、サイズ、更新時刻を含むMarkdownテーブルを作成します。
        表示するアイテム数には上限があり、超過した場合は省略表示されます。
    引数:
        :param files: FileInfoオブジェクトのリスト。
        :type files: list[FileInfo]
        :param title: テーブルのセクションタイトル。
        :type title: str
        :param max_items: テーブルに表示する最大アイテム数。
        :type max_items: int
    戻り値:
        :returns: Markdown形式のファイルテーブル文字列。
        :rtype: str
    """
    lines: list[str] = []
    lines.append(f"### {title}")
    lines.append("")

    if not files:
        lines.append("該当なし。")
        lines.append("")
        return "\n".join(lines)

    lines.append("| File | Size | Modified time |")
    lines.append("|---|---:|---|")

    for info in files[:max_items]:
        lines.append(
            f"| {md_path_link(info.relpath)} | {human_size(info.size)} | {info.mtime_iso} |"
        )

    if len(files) > max_items:
        lines.append(f"| ... | ... | {len(files) - max_items} files omitted |")

    lines.append("")
    return "\n".join(lines)


def render_tree(files: dict[str, FileInfo], max_items: int) -> str:
    """
    概要:
        ファイルリストから簡易的なディレクトリツリー表示を生成します。
    詳細説明:
        ファイルパスのリストを基に、Markdownレポート向けの簡易的なツリー構造をテキストで表現します。
        厳密な tree コマンドの出力ではなく、視覚的な階層を示すためのものです。
        表示するアイテム数には上限があり、超過した場合は省略表示されます。
    引数:
        :param files: ファイル情報の辞書。キーは相対パス。
        :type files: dict[str, FileInfo]
        :param max_items: ツリーに表示する最大アイテム数。
        :type max_items: int
    戻り値:
        :returns: 簡易ツリー形式の文字列。
        :rtype: str
    """
    paths = list(files.keys())
    omitted = max(0, len(paths) - max_items)
    paths = paths[:max_items]

    lines: list[str] = ["."]
    for rel in paths:
        depth = rel.count("/")
        indent = "    " * depth
        name = rel.split("/")[-1]
        lines.append(f"{indent}├── {name}")

    if omitted:
        lines.append(f"    ... {omitted} files omitted")

    return "\n".join(lines)


def choose_fence(text: str) -> str:
    """
    概要:
        Markdownのコードブロックのフェンス文字を選択します。
    詳細説明:
        指定されたテキスト内に三重バッククォートがある場合、四重バッククォートをフェンスとして選択します。
        これにより、テキストがコードブロック内で正しく表示されるようにします。
    引数:
        :param text: 検査するテキスト。
        :type text: str
    戻り値:
        :returns: 適切なMarkdownフェンス文字列（三重または四重バッククォート）。
        :rtype: str
    """
    if "```" not in text:
        return "```"
    return "````"


def fenced_block(text: str, lang: str = "text", max_chars: int = 20000) -> str:
    """
    概要:
        Markdownのフェンス付きコードブロックを生成します。
    詳細説明:
        テキストを指定された言語と文字数制限でフェンス付きコードブロックとしてフォーマットします。
        テキストが空の場合は「(no output)」と表示し、最大文字数を超えた場合は切り捨てて省略メッセージを追加します。
        適切なフェンス文字は choose_fence 関数によって選択されます。
    引数:
        :param text: コードブロックとして表示するテキスト。
        :type text: str
        :param lang: コードブロックの言語ヒント。デフォルトは text です。
        :type lang: str
        :param max_chars: 表示する最大文字数。
        :type max_chars: int
    戻り値:
        :returns: Markdown形式のフェンス付きコードブロック文字列。
        :rtype: str
    """
    if text is None:
        text = ""

    if text == "":
        text = "(no output)"

    truncated = False
    if len(text) > max_chars:
        text = text[:max_chars]
        truncated = True

    fence = choose_fence(text)
    if truncated:
        text += "\n\n... truncated ..."

    return f"{fence}{lang}\n{text.rstrip()}\n{fence}"


def run_command(
    command: str,
    workdir: Path,
    shell: bool,
    timeout_sec: float,
    encoding: str,
) -> RunResult:
    """
    概要:
        指定されたコマンドを実行し、その結果を RunResult オブジェクトとして返します。
    詳細説明:
        subprocess.run を使用してコマンドを実行し、標準出力、標準エラー出力、
        終了コード、実行時間、タイムアウト情報などを収集します。
        タイムアウトが発生した場合や、その他の実行時エラーが発生した場合も適切に処理します。
    引数:
        :param command: 実行するコマンド文字列。
        :type command: str
        :param workdir: コマンドを実行する作業ディレクトリのパス。
        :type workdir: Path
        :param shell: shell=True でコマンドを実行するかどうかを示す真偽値。
        :type shell: bool
        :param timeout_sec: コマンドのタイムアウト時間（秒）。0以下の場合は無制限。
        :type timeout_sec: float
        :param encoding: 標準出力および標準エラー出力をデコードするためのエンコーディング。
        :type encoding: str
    戻り値:
        :returns: コマンドの実行結果を含む RunResult オブジェクト。
        :rtype: RunResult
    """
    started_at = now_iso()
    timed_out = False

    try:
        if shell:
            cmd_for_run: str | list[str] = command
        else:
            cmd_for_run = shlex.split(command, posix=(os.name != "nt"))

        completed = subprocess.run(
            cmd_for_run,
            cwd=str(workdir),
            shell=shell,
            text=True,
            encoding=encoding,
            errors="replace",
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=None if timeout_sec <= 0 else timeout_sec,
        )

        return RunResult(
            command=command,
            returncode=completed.returncode,
            stdout=completed.stdout or "",
            stderr=completed.stderr or "",
            started_at=started_at,
            finished_at=now_iso(),
            timed_out=False,
        )

    except subprocess.TimeoutExpired as e:
        timed_out = True

        stdout = e.stdout or ""
        stderr = e.stderr or ""

        if isinstance(stdout, bytes):
            stdout = stdout.decode(encoding, errors="replace")
        if isinstance(stderr, bytes):
            stderr = stderr.decode(encoding, errors="replace")

        stderr += f"\nTIMEOUT: command exceeded timeout_sec={timeout_sec}\n"

        return RunResult(
            command=command,
            returncode=-999,
            stdout=stdout,
            stderr=stderr,
            started_at=started_at,
            finished_at=now_iso(),
            timed_out=timed_out,
        )

    except Exception:
        return RunResult(
            command=command,
            returncode=-998,
            stdout="",
            stderr=traceback.format_exc(),
            started_at=started_at,
            finished_at=now_iso(),
            timed_out=False,
        )


def compile_optional_regex(pattern_text: str) -> re.Pattern[str] | None:
    """
    概要:
        オプションの正規表現パターンをコンパイルします。
    詳細説明:
        指定されたパターン文字列が空白でない場合に、大文字小文字を区別しない正規表現オブジェクトをコンパイルして返します。
        パターン文字列が空または空白のみの場合は None を返します。
    引数:
        :param pattern_text: コンパイルする正規表現パターン文字列。
        :type pattern_text: str
    戻り値:
        :returns: コンパイルされた正規表現オブジェクト、またはNone。
        :rtype: re.Pattern[str] | None
    """
    if not pattern_text.strip():
        return None
    return re.compile(pattern_text, re.IGNORECASE)


def scan_text_for_hits(
    relpath: str,
    text: str,
    error_regex: re.Pattern[str],
    warning_regex: re.Pattern[str],
    ignore_regex: re.Pattern[str] | None,
    max_hits_per_file: int,
) -> list[LogHit]:
    """
    概要:
        テキストから指定された正規表現パターンに一致する警告/エラー行を走査します。
    詳細説明:
        テキストを1行ずつ読み込み、error_regex または warning_regex に一致する行を LogHit オブジェクトとして記録します。
        ignore_regex に一致する行はスキップされます。
        1ファイルあたりのヒット数には上限があります。
    引数:
        :param relpath: テキストの相対パス（ファイル名または __stderr__）。
        :type relpath: str
        :param text: 走査対象のテキスト。
        :type text: str
        :param error_regex: エラーパターンを検出するための正規表現オブジェクト。
        :type error_regex: re.Pattern[str]
        :param warning_regex: 警告パターンを検出するための正規表現オブジェクト。
        :type warning_regex: re.Pattern[str]
        :param ignore_regex: 無視する行パターンを検出するための正規表現オブジェクト、またはNone。
        :type ignore_regex: re.Pattern[str] | None
        :param max_hits_per_file: 1ファイルあたりで記録する最大ヒット数。
        :type max_hits_per_file: int
    戻り値:
        :returns: 検出された LogHit オブジェクトのリスト。
        :rtype: list[LogHit]
    """
    hits: list[LogHit] = []

    for i, line in enumerate(text.splitlines(), start=1):
        if ignore_regex and ignore_regex.search(line):
            continue

        kind: str | None = None
        if error_regex.search(line):
            kind = "ERROR"
        elif warning_regex.search(line):
            kind = "WARNING"

        if kind is not None:
            hits.append(
                LogHit(
                    relpath=relpath,
                    lineno=i,
                    kind=kind,
                    line=line.rstrip(),
                )
            )

        if len(hits) >= max_hits_per_file:
            break

    return hits


def scan_log_files(
    workdir: Path,
    files: list[FileInfo],
    log_exts: set[str],
    encoding: str,
    error_regex: re.Pattern[str],
    warning_regex: re.Pattern[str],
    ignore_regex: re.Pattern[str] | None,
    max_read_bytes: int,
    max_hits_per_file: int,
) -> list[LogHit]:
    """
    概要:
        指定されたログファイルリストを走査し、警告/エラーを検出します。
    詳細説明:
        FileInfoオブジェクトのリストから、指定されたログファイル拡張子を持つファイルを抽出し、
        ファイルの内容から警告やエラーパターンを検索します。
        読み込むバイト数や1ファイルあたりのヒット数には上限があります。
    引数:
        :param workdir: 作業ディレクトリのパス。
        :type workdir: Path
        :param files: 走査対象の FileInfo オブジェクトのリスト。
        :type files: list[FileInfo]
        :param log_exts: ログファイルとして扱う拡張子のセット。
        :type log_exts: set[str]
        :param encoding: ログファイルをデコードするためのエンコーディング。
        :type encoding: str
        :param error_regex: エラーパターンを検出するための正規表現オブジェクト。
        :type error_regex: re.Pattern[str]
        :param warning_regex: 警告パターンを検出するための正規表現オブジェクト。
        :type warning_regex: re.Pattern[str]
        :param ignore_regex: 無視する行パターンを検出するための正規表現オブジェクト、またはNone。
        :type ignore_regex: re.Pattern[str] | None
        :param max_read_bytes: 各ログファイルから読み込む最大バイト数。
        :type max_read_bytes: int
        :param max_hits_per_file: 1ファイルあたりで記録する最大ヒット数。
        :type max_hits_per_file: int
    戻り値:
        :returns: 検出された LogHit オブジェクトのリスト。
        :rtype: list[LogHit]
    """
    hits: list[LogHit] = []

    for info in files:
        path = workdir / info.relpath
        ext = path.suffix.lower()

        if ext not in log_exts:
            continue

        try:
            with path.open("rb") as f:
                data = f.read(max_read_bytes + 1)
        except OSError:
            continue

        if len(data) > max_read_bytes:
            data = data[:max_read_bytes]

        text = data.decode(encoding, errors="replace")
        hits.extend(
            scan_text_for_hits(
                relpath=info.relpath,
                text=text,
                error_regex=error_regex,
                warning_regex=warning_regex,
                ignore_regex=ignore_regex,
                max_hits_per_file=max_hits_per_file,
            )
        )

    return hits


def make_log_hits_section(hits: list[LogHit], max_items: int) -> str:
    """
    概要:
        ログヒット情報のMarkdownセクションを生成します。
    詳細説明:
        LogHitオブジェクトのリストから、警告/エラーの情報を表示するMarkdown形式のテーブルを作成します。
        ヒットが見つからない場合はその旨を報告します。
        表示するアイテム数には上限があり、超過した場合は省略表示されます。
    引数:
        :param hits: LogHitオブジェクトのリスト。
        :type hits: list[LogHit]
        :param max_items: テーブルに表示する最大ヒット数。
        :type max_items: int
    戻り値:
        :returns: Markdown形式のログヒットセクション文字列。
        :rtype: str
    """
    lines: list[str] = []
    lines.append("## Warning / Error scan")
    lines.append("")

    if not hits:
        lines.append("新規・更新された log/out/txt および stderr から、指定パターンに一致する行は見つかりませんでした。")
        lines.append("")
        return "\n".join(lines)

    lines.append("| Kind | File | Line | Message |")
    lines.append("|---|---|---:|---|")

    for hit in hits[:max_items]:
        file_part = "stderr" if hit.relpath == "__stderr__" else md_path_link(hit.relpath)
        lines.append(
            f"| {hit.kind} | {file_part} | {hit.lineno} | {md_escape(hit.line)} |"
        )

    if len(hits) > max_items:
        lines.append(f"| ... | ... | ... | {len(hits) - max_items} hits omitted |")

    lines.append("")
    return "\n".join(lines)


def make_images_section(files: list[FileInfo], max_items: int) -> str:
    """
    概要:
        生成または変更された画像ファイルのMarkdownセクションを生成します。
    詳細説明:
        ファイル情報のリストから画像ファイルを抽出し、それぞれの画像をMarkdown形式で埋め込みます。
        画像が見つからない場合はその旨を報告します。
        埋め込む画像数には上限があり、超過した場合は省略表示されます。
    引数:
        :param files: FileInfoオブジェクトのリスト。
        :type files: list[FileInfo]
        :param max_items: 埋め込む画像の最大数。
        :type max_items: int
    戻り値:
        :returns: Markdown形式の画像セクション文字列。
        :rtype: str
    """
    images = [f for f in files if Path(f.relpath).suffix.lower() in IMAGE_EXTS]

    lines: list[str] = []
    lines.append("## Generated / Modified images")
    lines.append("")

    if not images:
        lines.append("新規・更新された画像ファイルはありません。")
        lines.append("")
        return "\n".join(lines)

    for info in images[:max_items]:
        lines.append(f"### {info.relpath}")
        lines.append("")
        lines.append(md_image(info.relpath))
        lines.append("")

    if len(images) > max_items:
        lines.append(f"{len(images) - max_items} image files omitted.")
        lines.append("")

    return "\n".join(lines)


def make_report(
    workdir: Path,
    before: dict[str, FileInfo],
    after: dict[str, FileInfo],
    created: list[FileInfo],
    modified: list[FileInfo],
    deleted: list[FileInfo],
    run_result: RunResult,
    log_hits: list[LogHit],
    args: argparse.Namespace,
) -> str:
    """
    概要:
        全体の実行レポートをMarkdown形式で生成します。
    詳細説明:
        コマンド実行のサマリー、実行されたコマンド、実行前後のファイルツリー、
        ファイル変更のテーブル、生成・変更された画像、ログの警告・エラー、
        標準出力、標準エラー出力、および環境情報を含む包括的なMarkdownレポートを作成します。
        各種セクションの表示アイテム数や文字数には制限が適用されます。
    引数:
        :param workdir: 作業ディレクトリのパス。
        :type workdir: Path
        :param before: 実行前のファイルスナップショット辞書。
        :type before: dict[str, FileInfo]
        :param after: 実行後のファイルスナップショット辞書。
        :type after: dict[str, FileInfo]
        :param created: 作成されたファイルのリスト。
        :type created: list[FileInfo]
        :param modified: 変更されたファイルのリスト。
        :type modified: list[FileInfo]
        :param deleted: 削除されたファイルのリスト。
        :type deleted: list[FileInfo]
        :param run_result: コマンドの実行結果を含む RunResult オブジェクト。
        :type run_result: RunResult
        :param log_hits: ログファイルと標準エラー出力から検出された LogHit オブジェクトのリスト。
        :type log_hits: list[LogHit]
        :param args: コマンドライン引数を格納した Namespace オブジェクト。
        :type args: argparse.Namespace
    戻り値:
        :returns: Markdown形式の実行レポート文字列。
        :rtype: str
    """
    changed = created + modified

    lines: list[str] = []

    lines.append("# Execution Report")
    lines.append("")
    lines.append("## Summary")
    lines.append("")
    lines.append("| Item | Value |")
    lines.append("|---|---|")
    lines.append(f"| Workdir | {workdir} |")
    lines.append(f"| Started at | {run_result.started_at} |")
    lines.append(f"| Finished at | {run_result.finished_at} |")
    lines.append(f"| Return code | {run_result.returncode} |")
    lines.append(f"| Timed out | {run_result.timed_out} |")
    lines.append(f"| Files before | {len(before)} |")
    lines.append(f"| Files after | {len(after)} |")
    lines.append(f"| Created files | {len(created)} |")
    lines.append(f"| Modified files | {len(modified)} |")
    lines.append(f"| Deleted files | {len(deleted)} |")
    lines.append("")
    lines.append("")

    lines.append("## Command")
    lines.append("")
    lines.append(fenced_block(run_result.command, lang="bash", max_chars=args.max_output_chars))
    lines.append("")

    lines.append("## Files before execution")
    lines.append("")
    lines.append(fenced_block(render_tree(before, args.max_tree_items), lang="text"))
    lines.append("")

    lines.append(make_file_table(list(before.values()), "Files before execution links", args.max_file_items))
    
    lines.append("## File changes")
    lines.append("")
    lines.append(make_file_table(created, "Created files", args.max_file_items))
    lines.append(make_file_table(modified, "Modified files", args.max_file_items))
    lines.append(make_file_table(deleted, "Deleted files", args.max_file_items))

    lines.append(make_images_section(changed, args.max_image_items))

    lines.append(make_log_hits_section(log_hits, args.max_log_hits))

    lines.append("## stdout")
    lines.append("")
    lines.append(fenced_block(run_result.stdout, lang="text", max_chars=args.max_output_chars))
    lines.append("")

    lines.append("## stderr")
    lines.append("")
    lines.append(fenced_block(run_result.stderr, lang="text", max_chars=args.max_output_chars))
    lines.append("")

    lines.append("## Environment")
    lines.append("")
    lines.append("| Item | Value |")
    lines.append("|---|---|")
    lines.append(f"| Python | {sys.version.split()[0]} |")
    lines.append(f"| Platform | {platform.platform()} |")
    lines.append(f"| Script | {Path(__file__).name} |")
    lines.append("")

    return "\n".join(lines)


def run_pandoc(
    report_md: Path,
    report_html: Path,
    pandoc_cmd: str,
    toc: bool,
    standalone: bool,
    encoding: str,
) -> tuple[int, str, str]:
    """
    概要:
        Pandocを使用してMarkdownレポートをHTMLに変換します。
    詳細説明:
        指定されたMarkdownファイルを入力として、Pandocコマンドを実行し、
        HTMLファイルを生成します。目次 (toc) やスタンドアロンモード (standalone)
        のオプションを適用できます。Pandocの標準出力と標準エラー出力を捕捉して返します。
    引数:
        :param report_md: 入力となるMarkdownレポートファイルのパス。
        :type report_md: Path
        :param report_html: 出力されるHTMLファイルのパス。
        :type report_html: Path
        :param pandoc_cmd: Pandocコマンドの実行ファイル名またはパス。
        :type pandoc_cmd: str
        :param toc: HTMLレポートに目次を含めるかどうかを示す真偽値。
        :type toc: bool
        :param standalone: スタンドアロンHTMLファイルを生成するかどうかを示す真偽値。
        :type standalone: bool
        :param encoding: Pandocの出力デコードに使用するエンコーディング。
        :type encoding: str
    戻り値:
        :returns: Pandocの終了コード、標準出力、標準エラー出力のタプル。
        :rtype: tuple[int, str, str]
    """
    cmd = [pandoc_cmd, str(report_md), "-o", str(report_html)]

    if standalone:
        cmd.insert(1, "-s")

    if toc:
        cmd.insert(1, "--toc")

    completed = subprocess.run(
        cmd,
        cwd=str(report_md.parent),
        text=True,
        encoding=encoding,
        errors="replace",
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    return completed.returncode, completed.stdout or "", completed.stderr or ""


def positive_or_zero_float(text: str) -> float:
    """
    概要:
        文字列を非負の浮動小数点数に変換します。
    詳細説明:
        argparse の型として使用され、入力文字列を浮動小数点数に変換します。
        負の値やゼロも許容します。
    引数:
        :param text: 浮動小数点数に変換する文字列。
        :type text: str
    戻り値:
        :returns: 変換された浮動小数点数。
        :rtype: float
    """
    value = float(text)
    return value


def build_argparser() -> argparse.ArgumentParser:
    """
    概要:
        コマンドライン引数パーサーを構築します。
    詳細説明:
        スクリプトが受け入れるすべてのコマンドライン引数を定義し、
        ヘルプメッセージとデフォルト値、型変換などを設定します。
    戻り値:
        :returns: 構築された argparse.ArgumentParser オブジェクト。
        :rtype: argparse.ArgumentParser
    """
    p = argparse.ArgumentParser(
        description="Run a command and generate Markdown/HTML execution report.",
        formatter_class=argparse.RawTextHelpFormatter,
    )

    p.add_argument("--workdir", type=str, default=".", help="作業ディレクトリ")
    p.add_argument("--cmd", type=str, required=True, help="workdir 内で実行するコマンド文字列")

    p.add_argument(
        "--report",
        type=str,
        default="report.md",
        help="Markdownレポート名。workdir内に作成される。",
    )

    p.add_argument(
        "--html",
        type=int,
        default=0,
        choices=[0, 1],
        help="1ならpandocでHTMLも作成する",
    )
    p.add_argument(
        "--html-name",
        type=str,
        default="report.html",
        help="HTML出力名。workdir内に作成される。",
    )
    p.add_argument(
        "--pandoc-cmd",
        type=str,
        default="pandoc",
        help="pandocコマンド名またはパス",
    )
    p.add_argument("--toc", type=int, default=1, choices=[0, 1], help="HTMLに目次を付ける")
    p.add_argument(
        "--standalone",
        type=int,
        default=1,
        choices=[0, 1],
        help="pandoc -s を使う",
    )

    p.add_argument(
        "--shell",
        type=int,
        default=1,
        choices=[0, 1],
        help="1なら shell=True で実行する。Windowsでは通常1が便利。",
    )
    p.add_argument(
        "--timeout",
        type=positive_or_zero_float,
        default=0.0,
        help="タイムアウト秒。0以下なら無制限。",
    )

    p.add_argument(
        "--ignore",
        type=str,
        default=DEFAULT_IGNORE,
        help="監視から除外するパターンのカンマ区切り",
    )

    p.add_argument(
        "--log-exts",
        type=str,
        default=LOG_EXTS_DEFAULT,
        help="warning/error を走査する拡張子のカンマ区切り",
    )

    p.add_argument(
        "--error-regex",
        type=str,
        default=r"\b(error|exception|traceback|failed|failure|not found|permission denied|overflow|underflow)\b",
        help="ERRORとして拾う正規表現",
    )
    p.add_argument(
        "--warning-regex",
        type=str,
        default=r"\b(warning|warn|deprecated|convergence|ill-conditioned)\b",
        help="WARNINGとして拾う正規表現",
    )
    p.add_argument(
        "--ignore-log-regex",
        type=str,
        default="",
        help="log走査時に無視する行の正規表現。例: 'mean squared error'",
    )

    p.add_argument("--encoding", type=str, default="utf-8", help="stdout/stderr/log 読み込みencoding")
    p.add_argument("--max-read-bytes", type=int, default=2_000_000, help="logファイル読み込み最大byte数")
    p.add_argument("--max-output-chars", type=int, default=30000, help="stdout/stderrのMarkdown出力最大文字数")
    p.add_argument("--max-tree-items", type=int, default=500, help="実行前ファイルツリーの最大表示数")
    p.add_argument("--max-file-items", type=int, default=300, help="created/modified/deleted 表の最大表示数")
    p.add_argument("--max-image-items", type=int, default=50, help="画像埋め込み最大数")
    p.add_argument("--max-log-hits", type=int, default=300, help="warning/error hit 表示最大数")
    p.add_argument("--max-hits-per-file", type=int, default=100, help="1ファイルあたりのwarning/error hit最大数")

    return p


def make_inside_workdir(workdir: Path, name: str) -> Path:
    """
    概要:
        指定された名前のパスを作業ディレクトリ内に安全に構築します。
    詳細説明:
        出力パスが必ず作業ディレクトリのサブパスとなるようにします。
        name が絶対パスであっても、ファイル名部分のみを使用して workdir 内にパスを構築します。
        これにより、workdir 外への不正なファイル作成を防ぎます。
        必要であれば親ディレクトリを作成します。
    引数:
        :param workdir: 基準となる作業ディレクトリのパス。
        :type workdir: Path
        :param name: 構築するパスのファイル名または相対パス。
        :type name: str
    戻り値:
        :returns: 作業ディレクトリ内にある解決済みのパス。
        :rtype: Path
    例外:
        :raises ValueError: 出力パスが作業ディレクトリ内に構築できない場合。
    """
    raw = Path(name)

    if raw.is_absolute():
        # 絶対パスを許すと「workdir内に作る」という仕様が崩れるため、ファイル名だけ使う。
        raw = Path(raw.name)

    path = (workdir / raw).resolve()
    workdir_resolved = workdir.resolve()

    try:
        path.relative_to(workdir_resolved)
    except ValueError:
        raise ValueError(f"Output path must be inside workdir: {path}")

    path.parent.mkdir(parents=True, exist_ok=True)
    return path


def main() -> int:
    """
    概要:
        スクリプトのメイン実行ロジック。
    詳細説明:
        コマンドライン引数を解析し、指定されたコマンドを実行し、
        ファイルシステムのスナップショットを比較してレポートを生成します。
        Markdownレポートファイルと、オプションでHTMLレポートファイルを生成します。
        エラー処理と Pandoc の実行も行います。
    戻り値:
        :returns: テスト対象コマンドの終了コード。エラーの場合は1。
        :rtype: int
    """
    parser = build_argparser()
    args = parser.parse_args()

    workdir = Path(args.workdir).resolve()

    if not workdir.exists():
        print(f"ERROR: workdir does not exist: {workdir}", file=sys.stderr)
        return 1

    if not workdir.is_dir():
        print(f"ERROR: workdir is not a directory: {workdir}", file=sys.stderr)
        return 1

    try:
        report_md = make_inside_workdir(workdir, args.report)
        report_html = make_inside_workdir(workdir, args.html_name)
    except Exception:
        traceback.print_exc()
        return 1

    ignore_patterns = parse_csv_list(args.ignore)
    log_exts = {x.lower() for x in parse_csv_list(args.log_exts)}

    error_regex = re.compile(args.error_regex, re.IGNORECASE)
    warning_regex = re.compile(args.warning_regex, re.IGNORECASE)
    ignore_log_regex = compile_optional_regex(args.ignore_log_regex)

    print(f"[run_report] workdir: {workdir}")
    print(f"[run_report] command: {args.cmd}")

    before = snapshot_files(workdir, ignore_patterns)

    run_result = run_command(
        command=args.cmd,
        workdir=workdir,
        shell=bool(args.shell),
        timeout_sec=args.timeout,
        encoding=args.encoding,
    )

    after = snapshot_files(workdir, ignore_patterns)

    created, modified, deleted = diff_snapshots(before, after)

    changed = created + modified

    log_hits = scan_log_files(
        workdir=workdir,
        files=changed,
        log_exts=log_exts,
        encoding=args.encoding,
        error_regex=error_regex,
        warning_regex=warning_regex,
        ignore_regex=ignore_log_regex,
        max_read_bytes=args.max_read_bytes,
        max_hits_per_file=args.max_hits_per_file,
    )

    # stderr も warning/error scan 対象にする
    log_hits.extend(
        scan_text_for_hits(
            relpath="__stderr__",
            text=run_result.stderr,
            error_regex=error_regex,
            warning_regex=warning_regex,
            ignore_regex=ignore_log_regex,
            max_hits_per_file=args.max_hits_per_file,
        )
    )

    report_text = make_report(
        workdir=workdir,
        before=before,
        after=after,
        created=created,
        modified=modified,
        deleted=deleted,
        run_result=run_result,
        log_hits=log_hits,
        args=args,
    )

    report_md.write_text(report_text, encoding="utf-8", newline="\n")
    print(f"[run_report] wrote: {report_md}")

    if args.html == 1:
        try:
            rc, out, err = run_pandoc(
                report_md=report_md,
                report_html=report_html,
                pandoc_cmd=args.pandoc_cmd,
                toc=bool(args.toc),
                standalone=bool(args.standalone),
                encoding=args.encoding,
            )
            if rc == 0:
                print(f"[run_report] wrote: {report_html}")
            else:
                print(f"[run_report] pandoc failed: returncode={rc}", file=sys.stderr)
                if out:
                    print(out)
                if err:
                    print(err, file=sys.stderr)
        except FileNotFoundError:
            print(
                "[run_report] pandoc was not found. Markdown report was generated, but HTML was not.",
                file=sys.stderr,
            )
        except Exception:
            traceback.print_exc()

    # テスト対象コマンドの return code をそのまま返す。
    return run_result.returncode


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception:
        traceback.print_exc()
        input("\nPress ENTER to terminate>>\n")
        sys.exit(1)