#!/usr/bin/env python3
from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
import unicodedata
from pathlib import Path

try:
    import yaml
except ImportError:
    yaml = None


INLINE_LINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
DESCRIPTION_RE = re.compile(r'^description:\s*"?(.+?)"?$', re.MULTILINE)
MOC_TYPE_RE = re.compile(r"^moc_type:\s*(\S+)", re.MULTILINE)
TITLE_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):", re.MULTILINE)
SECTION_RE = re.compile(r"^##\s+.+$", re.MULTILINE)
BRIDGE_DESCRIPTION_HINTS = ("接合", "つなぐ", "接続", "橋", "橋渡し", "横断", "跨ぐ")
CONTAINER_SECTION_TITLES = {"目次", "🗺 目次", "🛤 切り口別の記録", "🧭 目次"}
LOG_SECTION_KEYWORDS = ("ログ", "📋", "🔄", "実績", "育成ログ", "学習ログ", "分析済み")
LINK_WITH_DESCRIPTION_RE = re.compile(
    r"^\s*-\s*\[\[.+?\]\].*?[：:].{5,}$", re.MULTILINE
)
ALLOWED_FRONTMATTER_KEYS = {
    "title",
    "description",
    "moc_type",
    "aliases",
}
REQUIRED_FRONTMATTER_KEYS = {"title", "description"}
ALLOWED_MOC_TYPES = {"synthesis", "narrative", "framework", "root", "bridge", "method"}
MOC_SECTION_TITLE = "📁 テーマ別MOC"

# H2 セクション見出しの絵文字チェックを免除するメタ系見出し
META_SECTION_TITLES_NO_EMOJI = {"関連", "参考", "出典", "備考", "メモ", "理論基盤"}

# 禁止語句辞書（write-filter.md 由来）
BANNED_PHRASES_YAML = Path(__file__).resolve().parent.parent.parent.parent / "scripts" / "audit_banned_phrases.yaml"


def _load_banned_categories() -> dict:
    """audit_banned_phrases.yaml の categories: を読む。
    返り値: {category_key: {label, rule_section, reason, exception?, phrases: [{phrase, alternatives?}]}}
    """
    if yaml is None or not BANNED_PHRASES_YAML.exists():
        return {}
    try:
        data = yaml.safe_load(BANNED_PHRASES_YAML.read_text(encoding="utf-8"))
        return data.get("categories", {}) if data else {}
    except Exception:
        return {}


BANNED_CATEGORIES = _load_banned_categories()


def check_banned_phrases(body: str) -> list[str]:
    """本文に対して禁止語句を検出し、AIへの警告テキストをカテゴリごとにまとめて返す。

    AIが「何が・なぜダメで・どう置き換えるか・どのルールを読むか」を
    一読で把握できる形式にする。
    """
    if not BANNED_CATEGORIES:
        return []

    warnings: list[str] = []
    for cat_key, cat in BANNED_CATEGORIES.items():
        label = cat.get("label", cat_key)
        reason = cat.get("reason", "")
        rule_section = cat.get("rule_section", "")
        exception = cat.get("exception", "")
        phrases = cat.get("phrases", [])

        hits: list[tuple[str, list[str]]] = []
        for entry in phrases:
            phrase = entry.get("phrase", "")
            if not phrase:
                continue
            if phrase in body:
                alts = entry.get("alternatives", []) or []
                hits.append((phrase, alts))

        if not hits:
            continue

        lines = [f"{label}を検出しました（{len(hits)}件）"]
        for phrase, alts in hits:
            if alts:
                alt_str = " / ".join(alts)
                lines.append(f"  検出語: 「{phrase}」  代替: {alt_str}")
            else:
                lines.append(f"  検出語: 「{phrase}」")
        lines.append(f"  理由: {reason}")
        if exception:
            lines.append(f"  例外: {exception}")
        if rule_section:
            lines.append(f"  参照: .claude/rules/write-filter.md → 「{rule_section}」")

        warnings.append("\n".join(lines))

    return warnings


def check_h2_emoji(sections: list[str]) -> list[str]:
    """H2 セクション見出しの先頭に絵文字があるかチェックする。

    充実したMOCでは📁絵文字が使われており、
    視認性とナビゲーション性が上がる。「関連」「参考」等のメタ見出しは免除。
    """
    no_emoji: list[str] = []
    for section in sections:
        heading = section_heading(section)
        if not heading:
            continue
        # メタ見出しは絵文字なくてOK
        if heading in META_SECTION_TITLES_NO_EMOJI:
            continue
        # 先頭文字が絵文字（Unicode category "So" = Symbol, Other）かチェック
        first_char = heading[:1]
        if not first_char:
            continue
        if unicodedata.category(first_char) != "So":
            no_emoji.append(heading)

    if not no_emoji:
        return []

    joined = " / ".join(f"「{h}」" for h in no_emoji)
    return [
        f"H2 セクション見出しに絵文字がありません: {joined}。"
        " このMOC運用では📁🎶📝🤖🛠💼🧠🗣のような絵文字で見出しを区別する慣習があります。"
        " 各セクションの内容を示す絵文字を先頭に付けてください。"
        "「関連」「参考」「出典」「備考」「メモ」「理論基盤」のようなメタ見出しは免除されます。"
    ]

TYPE_DEFAULTS = {
    "synthesis": {
        "min_links": 6,
        "min_sections": 2,
        "min_links_per_section": 2,
        "split_threshold": 8,
        "soft_max_links": 35,
        "hard_max_links": 50,
        "max_sections_warning": 6,
    },
    "framework": {
        "min_links": 6,
        "min_sections": 2,
        "min_links_per_section": 2,
        "split_threshold": 8,
        "soft_max_links": 35,
        "hard_max_links": 50,
        "max_sections_warning": 6,
    },
    "narrative": {
        "min_links": 5,
        "min_sections": 2,
        "min_links_per_section": 1,
        "split_threshold": 8,
        "soft_max_links": 30,
        "hard_max_links": 45,
        "max_sections_warning": 6,
    },
    "bridge": {
        "min_links": 6,
        "min_sections": 2,
        "min_links_per_section": 2,
        "split_threshold": 7,
        "soft_max_links": 30,
        "hard_max_links": 45,
        "max_sections_warning": 6,
    },
    "root": {
        "min_links": 8,
        "min_sections": 3,
        "min_links_per_section": 1,
        "split_threshold": 10,
        "soft_max_links": 40,
        "hard_max_links": 60,
        "max_sections_warning": 7,
    },
    "method": {
        "min_links": 5,
        "min_sections": 3,
        "min_links_per_section": 1,
        "split_threshold": 7,
        "soft_max_links": 28,
        "hard_max_links": 40,
        "max_sections_warning": 6,
    },
}


def find_vault_root(start: str, marker: str | None = None) -> Path:
    """Vault ルート探索。`.obsidian/` の存在で判定する。
    marker を指定すると、ディレクトリ名一致でも探索する（後方互換のフォールバック）。
    """
    path = Path(start).resolve()
    while True:
        if (path / ".obsidian").is_dir():
            return path
        if marker and path.name == marker:
            return path
        if path.parent == path:
            label = marker or ".obsidian/"
            raise RuntimeError(f"Vault root ({label}) が見つかりません: {start}")
        path = path.parent


def normalize_link_target(link: str) -> str:
    target = link.split("|", 1)[0].split("#", 1)[0].strip().removesuffix(".md")
    return unicodedata.normalize("NFC", target)


def build_title_index(root: Path) -> dict[str, list[Path]]:
    # macOS のファイル名は NFD、本文の WikiLink は NFC で書かれるため、
    # キーを NFC に正規化してから保存する（照合側も normalize_link_target が NFC 化する）。
    index: dict[str, list[Path]] = {}
    for path in root.rglob("*.md"):
        key = unicodedata.normalize("NFC", path.stem)
        index.setdefault(key, []).append(path)
    return index


def find_root_backlinks(moc_root: Path, target_titles: list[str], current_path: Path) -> list[Path]:
    """root MOC からの直接リンク、または他 MOC の📁テーマ別MOCセクション内リンクを探す。

    子MOCは親MOCの📁セクションからリンクされていれば root 到達とみなす。
    親MOC自身の root 到達は親の監査で検証される。
    """
    backlinks: list[Path] = []
    for path in moc_root.rglob("*.md"):
        if path.resolve() == current_path.resolve():
            continue
        try:
            content = path.read_text(encoding="utf-8")
        except Exception:
            continue
        frontmatter, body = split_frontmatter(content)
        moc_type = extract_moc_type(frontmatter)
        # root MOC からの直接リンク（従来の挙動）
        if moc_type == "root":
            if any(f"[[{candidate}]]" in content for candidate in target_titles):
                backlinks.append(path)
                continue
        # 任意の MOC の📁テーマ別MOCセクション内リンク
        moc_section_start = body.find(f"## {MOC_SECTION_TITLE}")
        if moc_section_start < 0:
            continue
        after = body[moc_section_start:]
        next_heading = re.search(r"\n## ", after[4:])
        moc_section_text = after[: next_heading.start() + 4] if next_heading else after
        if any(f"[[{candidate}]]" in moc_section_text for candidate in target_titles):
            backlinks.append(path)
    return backlinks


def split_frontmatter(content: str) -> tuple[str, str]:
    match = FRONTMATTER_RE.match(content)
    if not match:
        return "", content
    return match.group(1), content[match.end():]


def extract_description(frontmatter: str) -> str:
    match = DESCRIPTION_RE.search(frontmatter)
    return match.group(1).strip() if match else ""


def extract_moc_type(frontmatter: str) -> str:
    match = MOC_TYPE_RE.search(frontmatter)
    return match.group(1).strip() if match else "synthesis"


def extract_title(content: str, path: Path) -> str:
    match = TITLE_RE.search(content)
    return match.group(1).strip() if match else path.stem


def extract_frontmatter_keys(frontmatter: str) -> list[str]:
    return FRONTMATTER_KEY_RE.findall(frontmatter)


def extract_aliases(frontmatter: str) -> list[str]:
    match = re.search(r"^aliases:\n((?:  - .*\n?)*)", frontmatter, re.MULTILINE)
    if not match:
        return []
    aliases: list[str] = []
    for line in match.group(1).splitlines():
        line = line.strip()
        if line.startswith("- "):
            aliases.append(line[2:].strip())
    return aliases


def expected_moc_alias(title: str) -> str:
    return f"MOC_{title}"


def _auto_fix_aliases(
    path: Path,
    frontmatter: str,
    keys: list[str],
    aliases: list[str],
    moc_type: str,
    title: str,
) -> list[str]:
    """aliases が不足していたらファイルを直接修正し、追加した alias のリストを返す。"""
    needed: list[str] = []

    if moc_type != "root":
        expected = expected_moc_alias(title)
        if expected not in aliases:
            needed.append(expected)
    # root の root-... は slug が自明でないので自動生成しない

    if not needed:
        return []

    content = path.read_text(encoding="utf-8")
    if "aliases" not in keys or not aliases:
        # aliases セクション自体がない → 追加
        alias_block = "aliases:\n" + "".join(f"  - {a}\n" for a in needed)
        # frontmatter の末尾 "---" の直前に挿入
        content = content.replace("\n---\n", f"\n{alias_block}---\n", 1)
    else:
        # 既存 aliases の末尾に追記
        last_alias_line = None
        for line in content.splitlines():
            stripped = line.strip()
            if stripped.startswith("- ") and any(a in stripped for a in aliases):
                last_alias_line = line
        if last_alias_line:
            new_lines = "\n".join(f"  - {a}" for a in needed)
            content = content.replace(last_alias_line, f"{last_alias_line}\n{new_lines}", 1)

    path.write_text(content, encoding="utf-8")
    return needed


def validate_frontmatter(frontmatter: str, content: str, path: Path) -> tuple[str, str, list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []
    title = extract_title(content, path)
    description = extract_description(frontmatter)

    if not frontmatter:
        errors.append("frontmatter がありません。MOC では YAML に title と description を必ず入れてください。")
        return title, description, errors, warnings

    keys = extract_frontmatter_keys(frontmatter)
    moc_type = extract_moc_type(frontmatter)
    unknown_keys = sorted(set(keys) - ALLOWED_FRONTMATTER_KEYS)
    if unknown_keys:
        joined = ", ".join(unknown_keys)
        errors.append(
            "MOC に不要な frontmatter key があります: "
            f"{joined}. "
            "MOC で使ってよい key は title / description / moc_type / aliases だけです。"
            " それ以外は削除してください。"
        )

    missing_keys = [key for key in REQUIRED_FRONTMATTER_KEYS if key not in keys]
    if missing_keys:
        joined = ", ".join(missing_keys)
        errors.append(
            f"MOC の frontmatter に必須 key がありません: {joined}. "
            "title と description を追加してください。"
        )

    frontmatter_title_match = re.search(r'^title:\s*"?(.+?)"?$', frontmatter, re.MULTILINE)
    if not frontmatter_title_match:
        if "title" in keys:
            errors.append("frontmatter の title が読めません。`title: ...` の1行を入れてください。")
    else:
        frontmatter_title = frontmatter_title_match.group(1).strip()
        if frontmatter_title != title:
            errors.append(
                "frontmatter の title と H1 が一致していません: "
                f"{frontmatter_title} != {title}. "
                "どちらかに統一してください。"
            )

    if not description:
        if "description" in keys:
            errors.append("description が空です。frontmatter に内容のある description を入れてください。")
    else:
        # description の品質チェック
        desc_lower = description.lower()
        title_lower = title.lower()
        desc_issues: list[str] = []
        if len(description) < 15:
            desc_issues.append("短すぎます（15文字未満）")
        if desc_lower.rstrip("。.") == title_lower:
            desc_issues.append("タイトルをそのまま繰り返しています")
        weak_patterns = ["に関するMOC", "のMOC", "についてのMOC", "に関するまとめ", "のまとめ", "についてまとめ"]
        if any(p in description for p in weak_patterns):
            desc_issues.append("「〜のMOC」「〜のまとめ」は情報量ゼロです")
        if desc_issues:
            joined = "。".join(desc_issues)
            warnings.append(
                f"description の品質に改善の余地があります: {joined}。"
                " 良い description は【対象】何を扱うか【接続】何を束ねるか【用途】いつ参照するか の3要素で1〜2文。"
                " 例: 「日々の記録習慣を軸に、AI時代の一次情報の価値と継続運用の技法を束ねる。"
                "デイリーノート関連の執筆・スキル設計時に参照する。」"
            )

    if "moc_type" in keys and moc_type not in ALLOWED_MOC_TYPES:
        allowed = ", ".join(sorted(ALLOWED_MOC_TYPES))
        errors.append(
            f"moc_type が不正です: {moc_type}. "
            f"使える値は {allowed} です。いずれかに修正してください。"
        )

    aliases = extract_aliases(frontmatter)
    aliases_fixed = _auto_fix_aliases(path, frontmatter, keys, aliases, moc_type, title)
    if aliases_fixed:
        # Re-read after fix
        content = path.read_text(encoding="utf-8")
        frontmatter, _ = split_frontmatter(content)
        aliases = extract_aliases(frontmatter)
        warnings.append(f"aliases を自動補完しました: {', '.join(aliases_fixed)}")
    if moc_type == "root":
        first_char = title[:1]
        if not first_char or unicodedata.category(first_char) != "So":
            errors.append(
                "root MOC のタイトルは先頭に絵文字が必要です。"
                f" 現在の title は `{title}` です。"
                " root は知の地図の入口として一覧性と視認性が重要で、通常 MOC と一目で区別できる必要があります。"
                " 先頭に `🛠` `🧠` `🌿` `🎶` `👤` のような絵文字を付けてください。"
            )

    return title, description, errors, warnings


def format_audit_failure(errors: list[str], warnings: list[str] | None = None) -> str:
    lines = ["監査に失敗しました。修正してください:"]
    for error in errors:
        lines.append(f"- {error}")
    if warnings:
        lines.append("参考:")
        for warning in warnings:
            lines.append(f"- {warning}")
    return "\n".join(lines)


def threshold_defaults(moc_type: str) -> dict[str, int]:
    return TYPE_DEFAULTS.get(moc_type, TYPE_DEFAULTS["synthesis"]).copy()


def split_sections(body: str) -> list[str]:
    matches = list(SECTION_RE.finditer(body))
    if not matches:
        return []
    sections: list[str] = []
    for i, match in enumerate(matches):
        start = match.start()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(body)
        sections.append(body[start:end])
    return sections


def section_heading(section: str) -> str:
    first_line = section.splitlines()[0]
    return first_line[3:].strip()


def build_split_suggestions(
    moc_type: str,
    title: str,
    sections: list[str],
    inline_link_count: int,
    split_threshold: int,
    soft_max_links: int,
    hard_max_links: int,
    max_sections_warning: int,
) -> list[str]:
    suggestions: list[str] = []

    oversized_sections: list[tuple[str, int]] = []
    for section in sections:
        heading = section_heading(section)
        link_count = len(INLINE_LINK_RE.findall(section))
        if link_count >= split_threshold:
            oversized_sections.append((heading, link_count))

    for heading, link_count in oversized_sections:
        suggestions.append(
            "SPLIT_SUGGESTION: "
            f"{title} の `## {heading}` はリンク {link_count} 件で過密です。"
            " 独立した子 MOC として切り出せるか検討してください。"
        )

    if inline_link_count > hard_max_links:
        suggestions.append(
            "SPLIT_SUGGESTION: "
            f"{title} は総 WikiLink {inline_link_count} 件です。"
            f" {hard_max_links} 件を超えているため、moc-split を強く検討してください。"
        )
    elif inline_link_count > soft_max_links:
        suggestions.append(
            "SPLIT_SUGGESTION: "
            f"{title} は総 WikiLink {inline_link_count} 件です。"
            f" {soft_max_links} 件を超えているため、子 MOC 化の余地があります。"
        )

    if len(sections) > max_sections_warning:
        suggestions.append(
            "SPLIT_SUGGESTION: "
            f"{title} は見出し {len(sections)} 個です。"
            f" {max_sections_warning} 個を超えているため、切り口ごとの分割や中間 root を検討してください。"
        )

    if moc_type == "root" and oversized_sections:
        suggestions.append(
            "SPLIT_SUGGESTION: "
            f"root MOC `{title}` に過密セクションがあります。"
            " root も分割対象です。必要なら下位 root や子 MOC を追加してください。"
        )

    return suggestions


def run_obsidian(args: list[str]) -> str:
    import shutil

    obsidian_bin = shutil.which("obsidian") or shutil.which("Obsidian")
    if obsidian_bin is None:
        raise RuntimeError(
            "'obsidian' コマンドが PATH に見つかりません。"
            " /usr/local/bin/obsidian にシンボリックリンクがあるか確認してください。"
            f" 現在の PATH: {os.environ.get('PATH', '(未設定)')}"
        )
    try:
        result = subprocess.run(
            [obsidian_bin, *args],
            check=True,
            capture_output=True,
            text=True,
        )
        return result.stdout
    except FileNotFoundError as exc:
        raise RuntimeError(f"obsidian バイナリの実行に失敗: {obsidian_bin}") from exc
    except subprocess.CalledProcessError as exc:
        stderr = exc.stderr.strip() or exc.stdout.strip()
        raise RuntimeError(f"obsidian {' '.join(args)} に失敗しました: {stderr}") from exc


def audit_moc(
    path_str: str,
    min_links: int | None = None,
    min_sections: int | None = None,
    min_links_per_section: int | None = None,
    split_threshold: int | None = None,
    soft_max_links: int | None = None,
    hard_max_links: int | None = None,
    max_sections_warning: int | None = None,
    require_up_link: bool = False,
    allow_draft: bool = False,
    lite: bool = False,
) -> None:
    vault_root = find_vault_root(os.path.dirname(os.path.abspath(__file__)))
    moc_path = (vault_root / path_str).resolve() if not Path(path_str).is_absolute() else Path(path_str)
    brain_root = vault_root / "Brain"
    moc_root = brain_root / "MOC"

    if not moc_path.exists():
        raise RuntimeError(f"MOC が見つかりません: {moc_path}")
    if moc_root not in moc_path.parents:
        raise RuntimeError(f"MOC は Brain/MOC 配下である必要があります: {moc_path}")

    rel_path = str(moc_path.relative_to(vault_root))
    content = moc_path.read_text(encoding="utf-8")
    frontmatter, body = split_frontmatter(content)
    errors: list[str] = []
    warnings: list[str] = []

    title, description, frontmatter_errors, frontmatter_warnings = validate_frontmatter(frontmatter, content, moc_path)
    errors.extend(frontmatter_errors)
    warnings.extend(frontmatter_warnings)
    moc_type = extract_moc_type(frontmatter)
    if lite and moc_type == "root":
        # root MOC は構造監査をスキップするが、文体チェックは必ず通す
        banned_warnings = check_banned_phrases(body)
        root_sections = [
            section for section in split_sections(body)
            if section_heading(section) not in CONTAINER_SECTION_TITLES
        ]
        h2_emoji_warnings = check_h2_emoji(root_sections)
        for warning in banned_warnings + h2_emoji_warnings:
            print(f"WARNING: {warning}", file=sys.stderr)
        print("OK: root MOC — lite audit skipped (style checks only)")
        return

    target_titles = [title, moc_path.stem]
    for alias in extract_aliases(frontmatter):
        if alias not in target_titles:
            target_titles.append(alias)
    defaults = threshold_defaults(moc_type)
    min_links = defaults["min_links"] if min_links is None else min_links
    min_sections = defaults["min_sections"] if min_sections is None else min_sections
    min_links_per_section = defaults["min_links_per_section"] if min_links_per_section is None else min_links_per_section
    split_threshold = defaults["split_threshold"] if split_threshold is None else split_threshold
    soft_max_links = defaults["soft_max_links"] if soft_max_links is None else soft_max_links
    hard_max_links = defaults["hard_max_links"] if hard_max_links is None else hard_max_links
    max_sections_warning = defaults["max_sections_warning"] if max_sections_warning is None else max_sections_warning

    if not lite:
        title_index = build_title_index(brain_root)
    else:
        title_index = {}

    if not allow_draft and "status: draft" in content:
        errors.append("status: draft が残っています。削除するか、完成前なら --allow-draft を使ってください。")

    if "# " not in content:
        errors.append("見出しがありません。MOC には少なくとも 1 つの H1 見出しが必要です。")

    # H1 直下の導入文チェック（description と重複しやすい）
    all_sections_raw = split_sections(body)
    if all_sections_raw:
        # H1 と最初の ## の間のテキストを取得
        first_section_start = body.find("## ")
        if first_section_start > 0:
            preamble = body[:first_section_start]
            # Up: 行と空行を除いた本文行を数える
            preamble_lines = [
                line for line in preamble.splitlines()
                if line.strip()
                and not line.strip().startswith("Up:")
                and not line.strip().startswith("# ")
            ]
            if preamble_lines:
                warnings.append(
                    "H1 直下に説明文があります。"
                    " frontmatter の description が十分なら本文の導入文は不要です。"
                    " description を磨いて導入文を削除してください。"
                )

    sections = [
        section
        for section in all_sections_raw
        if section_heading(section) not in CONTAINER_SECTION_TITLES
    ]
    if len(sections) < min_sections:
        warnings.append(
            f"セクションが少なめです: {len(sections)}. "
            f"{moc_type} MOC の目安は {min_sections} 個以上です。必要なら切り口を増やしてください。"
        )
    too_thin_sections = [
        section.splitlines()[0]
        for section in sections
        if len(INLINE_LINK_RE.findall(section)) < min_links_per_section
        and not any(kw in section_heading(section) for kw in LOG_SECTION_KEYWORDS)
    ]
    if too_thin_sections:
        joined = ", ".join(too_thin_sections)
        warnings.append(
            f"リンク密度が薄い見出しがあります: {joined}. "
            f"{moc_type} MOC の目安では各見出し {min_links_per_section} 個以上の WikiLink があると使いやすいです。"
        )
    if len(sections) > max_sections_warning:
        warnings.append(
            f"見出し数が多めです: {len(sections)}. "
            f"{max_sections_warning} 個を超えたら、親MOC化や分割を検討してください。"
        )
    oversized_sections = [f"## {section_heading(section)}" for section in sections if len(INLINE_LINK_RE.findall(section)) >= split_threshold]
    if oversized_sections:
        joined = ", ".join(oversized_sections)
        warnings.append(f"セクションが過密です。moc-split を検討してください: {joined}")

    # リンク行に付随する説明文の検出（命題タイトルで自己完結すべき）
    # ただし MOC へのリンクはテーマ名なので説明文が必要 → 除外
    desc_lines = LINK_WITH_DESCRIPTION_RE.findall(body)
    desc_lines_non_moc = [
        line for line in desc_lines
        if not any(
            title_index.get(normalize_link_target(link))
            and any((brain_root / "MOC") in p.parents for p in title_index[normalize_link_target(link)])
            for link in INLINE_LINK_RE.findall(line)
        )
    ]
    if desc_lines_non_moc:
        warnings.append(
            f"リンクに説明文が付いている行が {len(desc_lines_non_moc)} 件あります。"
            " 命題形タイトルが十分なら説明文は不要です。"
            " タイトルだけで意味が通らない場合はタイトル自体を改善してください。"
            " 【強く推奨】説明文を削除するか、タイトルを磨いてください。"
        )

    # MOC へのリンクは `## 📁 テーマ別MOC` セクションに集約すべき
    # root MOC は本質的に MOC へのリンク集なのでこのチェックをスキップ
    # Up: 行は除外（H1 直後のメタ情報）
    all_sections = split_sections(body)
    first_section_heading = section_heading(all_sections[0]) if all_sections else ""
    if moc_type != "root":
        for i, section in enumerate(all_sections):
            heading = section_heading(section)
            if heading == MOC_SECTION_TITLE:
                continue  # このセクション内の MOC リンクは OK
            # このセクション内の MOC リンクを検出
            for link in INLINE_LINK_RE.findall(section):
                target = normalize_link_target(link)
                matches = title_index.get(target, [])
                is_moc_link = any((brain_root / "MOC") in p.parents for p in matches)
                if not is_moc_link:
                    continue
                # Up: 行の MOC リンクはスキップ
                for line in section.splitlines():
                    if f"[[{link}]]" in line and line.strip().startswith("Up:"):
                        break
                else:
                    errors.append(
                        f"MOC へのリンク [[{link.split('|')[0]}]] が `## {heading}` にあります。"
                        f" MOC へのリンクは `## {MOC_SECTION_TITLE}` セクション（最初の見出し）に"
                        " まとめてください。"
                    )

    inline_links = INLINE_LINK_RE.findall(content)
    if len(inline_links) < min_links:
        warnings.append(
            f"WikiLink が少なめです: {len(inline_links)}. "
            f"{moc_type} MOC の目安は {min_links} 個以上です。必要な関連先が足りないと感じたら追加してください。"
        )
    elif len(inline_links) > hard_max_links:
        warnings.append(
            f"WikiLink が多すぎます: {len(inline_links)}. "
            f"{hard_max_links} 個を超えたら分割を強く検討してください。"
        )
    elif len(inline_links) > soft_max_links:
        warnings.append(
            f"WikiLink がやや多めです: {len(inline_links)}. "
            f"{soft_max_links} 個を超えたら、必要に応じて子MOCへ分割してください。"
        )

    split_suggestions = build_split_suggestions(
        moc_type=moc_type,
        title=title,
        sections=sections,
        inline_link_count=len(inline_links),
        split_threshold=split_threshold,
        soft_max_links=soft_max_links,
        hard_max_links=hard_max_links,
        max_sections_warning=max_sections_warning,
    )

    if not lite:
        broken_links = []
        linked_paths: list[Path] = []
        for link in inline_links:
            target = normalize_link_target(link)
            matches = title_index.get(target, [])
            if not matches:
                broken_links.append(target)
            else:
                linked_paths.extend(matches)
        if broken_links:
            unique = ", ".join(sorted(set(broken_links)))
            errors.append(f"実在しない WikiLink があります: {unique}. リンク先タイトルを修正してください。")

        has_moc = any((brain_root / "MOC") in path.parents for path in linked_paths)
        has_permanent = any((brain_root / "Permanent") in path.parents for path in linked_paths)
        has_personal = any((brain_root / "Personal") in path.parents for path in linked_paths)
        root_backlinks = find_root_backlinks(moc_root, target_titles, moc_path)

        connectivity_errors: list[str] = []
        if moc_type == "root":
            # root MOC は apex 宣言。親 root への backlink は要求しない。
            if not has_moc:
                errors.append("root MOC には他の MOC へのリンクが必要です。関連する MOC へのリンクを追加してください。")
            if len(sections) < 2:
                errors.append("root MOC は少なくとも 2 つ以上の切り口を持たせてください。")
        if moc_type != "root" and not root_backlinks:
            connectivity_errors.append("この MOC は root MOC から辿れません。少なくとも 1 つの root MOC からリンクしてください。")

        if moc_type == "bridge":
            bridge_domains = sum([has_moc, has_permanent, has_personal])
            if bridge_domains < 2:
                errors.append("bridge MOC には少なくとも 2 領域以上へのリンクが必要です。MOC / Permanent / Personal のうち2種類以上へリンクしてください。")
            if not any(hint in description for hint in BRIDGE_DESCRIPTION_HINTS):
                errors.append("bridge MOC の description には、何をどう接続するかを書いてください。")
            if len(root_backlinks) < 1:
                errors.append("bridge MOC は root から辿れる必要があります。配置先の root MOC からリンクしてください。")

        if connectivity_errors:
            errors = connectivity_errors + errors

        if moc_type == "narrative":
            if not has_personal:
                errors.append("narrative MOC には Brain/Personal へのリンクが必要です。")
            permanent_count = sum(1 for p in linked_paths if (brain_root / "Permanent") in p.parents)
            personal_count = sum(1 for p in linked_paths if (brain_root / "Personal") in p.parents)
            total_brain = permanent_count + personal_count
            if total_brain > 0 and permanent_count / total_brain > 0.2:
                errors.append("narrative MOC は Brain/Permanent を主対象にしないでください。")

        if moc_type == "method":
            method_section_hits = sum(
                1
                for section in sections
                if any(keyword in section_heading(section) for keyword in ("定義", "根拠", "実践", "手順", "使い方", "適用", "応用"))
            )
            if method_section_hits < 2:
                errors.append(
                    "method MOC には `定義 / 根拠 / 実践` 系の見出しを少なくとも 2 つ以上入れてください。"
                    " method はリンク集ではなく、やり方として再利用できる構造が必要です。"
                )
            if not (has_permanent or has_personal):
                warnings.append(
                    "method MOC に Brain/Permanent または Brain/Personal へのリンクがありません。"
                    " 方法の根拠や実例と接続すると、あとでコンテンツ化しやすくなります。"
                )

    if require_up_link and "Up: [[" not in content:
        errors.append("親 MOC への Up リンクがありません。`Up: [[...]]` を追加してください。")

    # 禁止語句チェック（write-filter.md 由来）
    banned_warnings = check_banned_phrases(body)
    warnings.extend(banned_warnings)

    # H2 セクション見出しの絵文字チェック
    h2_emoji_warnings = check_h2_emoji(sections)
    warnings.extend(h2_emoji_warnings)

    if errors:
        raise RuntimeError(format_audit_failure(errors, warnings))
    for warning in warnings:
        print(f"WARNING: {warning}", file=sys.stderr)
    for suggestion in split_suggestions:
        print(suggestion, file=sys.stderr)


def main() -> None:
    parser = argparse.ArgumentParser(description="MOC の基本監査を行います。")
    parser.add_argument("--path", required=True, help="監査対象の MOC パス")
    parser.add_argument("--min-links", type=int, default=None, help="必要な最小 WikiLink 数。未指定なら moc_type ごとの既定値を使う")
    parser.add_argument("--min-sections", type=int, default=None, help="必要な最小セクション数。未指定なら moc_type ごとの既定値を使う")
    parser.add_argument("--min-links-per-section", type=int, default=None, help="各セクションに必要な最小 WikiLink 数。未指定なら moc_type ごとの既定値を使う")
    parser.add_argument("--split-threshold", type=int, default=None, help="この数以上のリンクを持つセクションは分割提案を出す。未指定なら moc_type ごとの既定値を使う")
    parser.add_argument("--soft-max-links", type=int, default=None, help="この数を超える総リンク数は分割を検討する。未指定なら moc_type ごとの既定値を使う")
    parser.add_argument("--hard-max-links", type=int, default=None, help="この数を超える総リンク数は強い分割警告を出す。未指定なら moc_type ごとの既定値を使う")
    parser.add_argument("--max-sections-warning", type=int, default=None, help="この数を超える見出し数は分割や親MOC化を検討する。未指定なら moc_type ごとの既定値を使う")
    parser.add_argument("--require-up-link", action="store_true", help="Up リンクを必須にする")
    parser.add_argument("--allow-draft", action="store_true", help="status: draft を許可する")
    parser.add_argument("--lite", action="store_true", help="簡易監査モード（CLI・ファイル走査なし）。hook 用")
    args = parser.parse_args()

    audit_moc(
        path_str=args.path,
        min_links=args.min_links,
        min_sections=args.min_sections,
        min_links_per_section=args.min_links_per_section,
        split_threshold=args.split_threshold,
        soft_max_links=args.soft_max_links,
        hard_max_links=args.hard_max_links,
        max_sections_warning=args.max_sections_warning,
        require_up_link=args.require_up_link,
        allow_draft=args.allow_draft,
        lite=args.lite,
    )
    print("OK: MOC audit passed")


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        sys.exit(1)
