import html
import importlib.util
import os
import csv
import json
import math
import re
import shutil
import traceback
import unicodedata
from dataclasses import dataclass, replace
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
from urllib.parse import quote

try:
    from fastapi import Request
except Exception:  # pragma: no cover - FastAPI is supplied by WebUI at runtime.
    Request = Any  # type: ignore[assignment]


EXTENSION_NAME = "Prompt Dictionary"
EXTENSION_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = EXTENSION_DIR / "data"
DICTIONARY_PATH = DATA_DIR / "prompt_dictionary.xlsx"
TSV_DICTIONARY_PATH = DATA_DIR / "prompt_dictionary.tsv"
QUERY_LEXICON_PATH = DATA_DIR / "query_lexicon.json"
EDIT_OVERLAY_TSV_PATH = DATA_DIR / "prompt_dictionary_edits.tsv"
TAG_GROUPS_TSV_PATH = DATA_DIR / "tag_groups.tsv"
MANUAL_TAG_GROUP_EDIT_TSV_PATH = DATA_DIR / "manual_tag_group_edit.tsv"
PROMPT_DICTIONARY_RUNTIME_JS_DIR = DATA_DIR / "runtime_js"
PROMPT_DICTIONARY_DATA_JS_PATH = PROMPT_DICTIONARY_RUNTIME_JS_DIR / "00_prompt_dictionary_data.js"
PROMPT_DICTIONARY_LEXICON_JS_PATH = PROMPT_DICTIONARY_RUNTIME_JS_DIR / "01_prompt_dictionary_lexicon.js"
PROMPT_DICTIONARY_EDITS_JS_PATH = EXTENSION_DIR / "javascript" / "03_prompt_dictionary_edits.js"
PROMPT_DICTIONARY_TAG_GROUPS_JS_PATH = EXTENSION_DIR / "javascript" / "04_prompt_dictionary_tag_groups.js"
V1_BACKUP_DIR = DATA_DIR / "backups" / "v1"
V1_BACKUP_TSV_PATH = V1_BACKUP_DIR / "prompt_dictionary.tsv"
V1_BACKUP_QUERY_LEXICON_PATH = V1_BACKUP_DIR / "query_lexicon.json"
V1_BACKUP_DATA_JS_PATH = V1_BACKUP_DIR / "javascript" / "00_prompt_dictionary_data.js"
V1_BACKUP_LEXICON_JS_PATH = V1_BACKUP_DIR / "javascript" / "01_prompt_dictionary_lexicon.js"
FANBOX_SECTION_LINKS_PATH = DATA_DIR / "fanbox_section_links.tsv"
FANBOX_SECTION_LINKS_JS_PATH = EXTENSION_DIR / "javascript" / "02_prompt_dictionary_fanbox_links.js"
FANBOX_SECTION_LINK_AUDIT_PATH = DATA_DIR / "fanbox_section_link_audit.tsv"
FANBOX_SECTION_SOURCE_HTML_PATH = Path.home() / "Desktop" / "画像生成プロンプト大辞典【for illustrious】｜AIイラストが理解る！【スタジオ真榊】｜pixivFANBOX.html"
FANBOX_SECTION_SOURCE_MHTML_PATH = Path.home() / "Desktop" / "#プロンプト辞典｜AIイラストが理解る！【スタジオ真榊】｜pixivFANBOX.mhtml"
FANBOX_SECTION_IMPORTER_PATH = EXTENSION_DIR / "tools" / "import_fanbox_section_links.py"
PROMPT_DICTIONARY_POST_URL = "https://studiomasakaki.fanbox.cc/posts/10051055"
TAG_RENAMES = {
    "hair_twirling": "twirling_hair",
    "hair twirling": "twirling_hair",
    "vagina": "pussy",
}
MAX_RESULTS = 20
INLINE_MAX_RESULTS = 50
RELATED_TAG_LIMIT = 12
TSV_EDIT_FIELDS = (
    "edit_display_tag",
    "edit_ja",
    "edit_description",
    "edit_related_tags",
    "edit_related_tags_override",
    "edit_promoted",
    "edit_updated_at",
)
EDIT_OVERLAY_FIELDS = (
    "tag",
    "display_tag",
    "ja",
    "description",
    "aliases",
    "search_aliases",
    "related_tags",
    "dictionary_section",
    "post_count",
    "rank_count",
    "category",
    "is_deprecated",
    "has_tag",
    "has_wiki",
    "wiki_note",
    "is_dictionary",
    "importance",
    "edit_display_tag",
    "edit_ja",
    "edit_description",
    "edit_related_tags",
    "edit_related_tags_override",
    "edit_promoted",
    "edit_updated_at",
    "edit_removed",
)
TAG_GROUP_FIELDS = (
    "group_key",
    "group_label",
    "source_title",
    "source_url",
    "section",
    "tag",
    "ja_short",
    "ja_source",
    "is_hidden",
    "note",
)
MANUAL_TAG_GROUP_FIELDS = (
    "group_order",
    *TAG_GROUP_FIELDS,
)
COLOR_COMPONENTS = {
    "red",
    "blue",
    "green",
    "black",
    "white",
    "brown",
    "blonde",
    "yellow",
    "purple",
    "pink",
    "orange",
    "aqua",
    "grey",
    "gray",
    "silver",
    "gold",
}
HAIR_CONTEXT_PENALTY_COMPONENTS = {
    "another's",
    "arm",
    "armpit",
    "ass",
    "body",
    "breast",
    "chest",
    "cum",
    "facial",
    "grab",
    "grabbing",
    "hairjob",
    "hold",
    "holding",
    "leg",
    "navel",
    "nipple",
    "penis",
    "pull",
    "pulling",
    "pubic",
    "pussy",
    "sex",
    "stray",
}
FAMILY_OBJECT_COMPONENTS = {"hair"}
FAMILY_REQUIRED_CONTEXT_COMPONENTS = {"grab", "grabbing", "hold", "holding", "wet"}

DIRECT_COMPONENT_ALIASES: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = (
    (("髪", "髪の毛", "ヘア", "へあ"), ("hair",)),
    (("つかむ", "つかみ", "つかん", "つかま", "つかめ", "つかっ", "掴む", "掴み", "掴ん", "掴ま", "掴め", "掴っ"), ("grab", "grabbing")),
    (("握る", "握り", "握っ", "握ら", "握れ"), ("grab", "grabbing", "hold", "holding")),
    (("持つ", "持ち", "持っ", "持た", "持て"), ("hold", "holding")),
    (("座る", "座り", "座っ", "座ら", "座れ"), ("sitting",)),
    (("立つ", "立ち", "立っ", "立た", "立て"), ("standing",)),
    (("濡れる", "濡れ", "濡ら"), ("wet",)),
)
PLACEHOLDER_DISPLAY_RE = re.compile(r"[○〇◯]{2,}|[～〜]+")
PLACEHOLDER_CIRCLE_RE = re.compile(r"[○〇◯]{2,}")


try:
    from rapidfuzz import fuzz as rapidfuzz_fuzz
except Exception:
    rapidfuzz_fuzz = None


if os.environ.get("PROMPT_DICTIONARY_STANDALONE") == "1":
    gr = None
    script_callbacks = None
    webui_scripts = None
else:
    try:
        import gradio as gr
        from modules import script_callbacks
        from modules import scripts as webui_scripts
    except Exception:
        gr = None
        script_callbacks = None
        webui_scripts = None
        print(f"[{EXTENSION_NAME}] WebUI modules are not available:\n{traceback.format_exc()}")


@dataclass(frozen=True)
class DictionaryEntry:
    tag: str
    ja: str
    description: str
    display_tag: str = ""
    aliases: tuple[str, ...] = ()
    search_aliases: tuple[str, ...] = ()
    related_tags: tuple[str, ...] = ()
    dictionary_section: str = ""
    wiki_note: str = ""
    post_count: int = 0
    rank_count: int = 0
    category: int = 0
    is_deprecated: bool = False
    is_dictionary: bool = False
    importance: int = 0


@dataclass(frozen=True)
class DictionaryLoadResult:
    entries: tuple[DictionaryEntry, ...]
    source: str
    warning: str = ""
    used_fallback: bool = False


@dataclass(frozen=True)
class SearchMatch:
    entry: DictionaryEntry
    score: float
    matched_field: str


FALLBACK_ENTRIES: tuple[DictionaryEntry, ...] = (
    DictionaryEntry(
        tag="twirling_hair",
        ja="髪をいじる",
        description="髪をいじる。hand in own hairに近いが、もっとくりくりと人差し指でいじる。",
        aliases=("髪の毛をいじる", "hair_twirling", "hair twirling", "頭や髪に関するポーズ"),
    ),
    DictionaryEntry(
        tag="masterpiece",
        ja="クォリティタグ",
        description="学習した無数の画像群のうち、傑作（＝最高品質）と格付けされたクラスタから学習された傾向を用いる。",
        aliases=("高品質", "品質向上", "クォリティタグ"),
    ),
    DictionaryEntry(
        tag="ruffling hair",
        ja="波打つ髪",
        description="「波打つ髪」の意味だが、本来は自分の髪をいじるタグ。",
        aliases=("髪をいじる", "ruffling_hair", "頭や髪に関するポーズ"),
    ),
)

_CACHE: DictionaryLoadResult | None = None
_CACHE_MTIME: float | None = None
_BASE_ROW_CACHE: dict[str, dict[str, str]] | None = None
_BASE_ROW_CACHE_MTIME: float | None = None
_LEXICON_CACHE: dict[str, list[str]] | None = None
_SECTION_LINKS_CACHE: dict[str, str] | None = None


def _warn(message: str) -> None:
    print(f"[{EXTENSION_NAME}] {message}")


def _cell_to_text(value: Any) -> str:
    if value is None:
        return ""
    return str(value).strip()


def _tsv_fieldname(value: Any) -> str:
    return _cell_to_text(value).lstrip("\ufeff")


def _normalize_tsv_row(row: dict[Any, Any]) -> dict[str, str]:
    normalized: dict[str, str] = {}
    for key, value in row.items():
        if key is None:
            continue
        field = _tsv_fieldname(key)
        if not field:
            continue
        text = _cell_to_text(value)
        if field not in normalized or text:
            normalized[field] = text
    return normalized


def _split_aliases(value: Any) -> tuple[str, ...]:
    text = _cell_to_text(value)
    if not text:
        return ()
    return tuple(part.strip() for part in re.split(r"[,、，\n\r]+", text) if part.strip())


def _normalize_placeholder_display(value: Any) -> str:
    text = _cell_to_text(value)
    if not text:
        return ""
    text = PLACEHOLDER_DISPLAY_RE.sub("〇〇", text)
    text = re.sub(r"([A-Za-z0-9])〇〇", r"\1 〇〇", text)
    text = re.sub(r"〇〇([A-Za-z0-9])", r"〇〇 \1", text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def _normalize_placeholder_alias_text(value: Any) -> str:
    aliases: list[str] = []
    for alias in _split_aliases(value):
        normalized = _normalize_placeholder_text(alias)
        if normalized and normalized not in aliases:
            aliases.append(normalized)
    return ",".join(aliases)


def _normalize_placeholder_text(value: Any) -> str:
    text = _cell_to_text(value)
    if not text:
        return ""
    text = PLACEHOLDER_CIRCLE_RE.sub("〇〇", text)
    text = re.sub(r"([A-Za-z][A-Za-z0-9_.'()/-]*)(?:[～〜]+)", r"\1 〇〇", text)
    text = re.sub(r"(?:[～〜]+)([A-Za-z][A-Za-z0-9_.'()/-]*)", r"〇〇 \1", text)
    text = re.sub(r"([A-Za-z][A-Za-z0-9_.'()/-]*)〇〇", r"\1 〇〇", text)
    text = re.sub(r"〇〇([A-Za-z][A-Za-z0-9_.'()/-]*)", r"〇〇 \1", text)
    text = re.sub(r"([（(「『\s])(?:[～〜]+)(?=(?:を|が|に|の|へ|で|と|式|比べ|側|風))", r"\1〇〇", text)
    text = re.sub(r"([ぁ-んァ-ヶ一-龠])(?:[～〜]+)(?=(?:式|比べ|側|風))", r"\1〇〇", text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def _parse_post_count(value: Any) -> int:
    try:
        return max(0, int(_cell_to_text(value) or "0"))
    except ValueError:
        return 0


def _canonical_tag_name(tag: str) -> str:
    text = _cell_to_text(tag)
    key = text.casefold().replace(" ", "_")
    return TAG_RENAMES.get(key, text)


def _unique_texts(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
    seen: set[str] = set()
    result: list[str] = []
    for value in values:
        text = _cell_to_text(value)
        if not text:
            continue
        key = text.casefold()
        if key in seen:
            continue
        seen.add(key)
        result.append(text)
    return tuple(result)


def _dictionary_section_set(value: str) -> set[str]:
    sections: set[str] = set()
    for section in _cell_to_text(value).replace("／", "/").split("/"):
        text = section.strip()
        if text:
            sections.add(text)
    return sections


def _leading_dictionary_label(description: str) -> str:
    text = _cell_to_text(description).strip()
    if not text:
        return ""
    match = re.match(r"^([ぁ-んァ-ヶ一-龠A-Za-z0-9・ー〇〇]{1,24}?)(?:な雰囲気に|風に|になる|が出る|が描写される|。|、|…)", text)
    if not match:
        return ""
    label = match.group(1).strip("「」『』（）() ")
    if not label or len(label) > 20:
        return ""
    if re.search(r"(少し|全体|場合|こちら|それ|これ|ため|よう)", label):
        return ""
    return label


def _repair_dictionary_heading(entry: DictionaryEntry) -> DictionaryEntry:
    if not entry.is_dictionary:
        return entry
    sections = _dictionary_section_set(entry.dictionary_section)
    if not sections or entry.ja not in sections:
        return entry
    label = _leading_dictionary_label(entry.description)
    if not label:
        return entry
    return replace(
        entry,
        ja=label,
        aliases=_unique_texts([*entry.aliases, label]),
    )


def _debug_edit_field_value(row: dict[str, str], edit_field: str, fallback_field: str) -> str:
    return _cell_to_text(row.get(edit_field)) or _cell_to_text(row.get(fallback_field))


def _tsv_row_key(row: dict[str, str] | DictionaryEntry | str) -> str:
    if isinstance(row, DictionaryEntry):
        tag = row.tag
    elif isinstance(row, dict):
        tag = row.get("tag", "")
    else:
        tag = row
    key = unicodedata.normalize("NFKC", _cell_to_text(tag)).casefold().replace(" ", "_")
    key = re.sub(r"_+", "_", key)
    return TAG_RENAMES.get(key, key)


def _merge_dictionary_entries(entries: tuple[DictionaryEntry, ...]) -> tuple[DictionaryEntry, ...]:
    merged_by_tag: dict[str, DictionaryEntry] = {}
    order: list[str] = []

    for entry in entries:
        canonical_tag = _canonical_tag_name(entry.tag)
        normalized = replace(
            entry,
            display_tag=_normalize_placeholder_display(entry.display_tag),
            description=_normalize_placeholder_text(entry.description),
            aliases=_unique_texts([_normalize_placeholder_display(alias) for alias in entry.aliases]),
            search_aliases=_unique_texts([_normalize_placeholder_display(alias) for alias in entry.search_aliases]),
        )
        normalized = _repair_dictionary_heading(normalized)
        if canonical_tag != entry.tag:
            normalized = replace(
                normalized,
                tag=canonical_tag,
                aliases=_unique_texts([*normalized.aliases, entry.tag, entry.tag.replace("_", " ")]),
            )

        existing = merged_by_tag.get(canonical_tag)
        if existing is None:
            merged_by_tag[canonical_tag] = normalized
            order.append(canonical_tag)
            continue

        primary, secondary = existing, normalized
        if normalized.is_dictionary and not existing.is_dictionary:
            primary, secondary = normalized, existing

        merged_by_tag[canonical_tag] = replace(
            primary,
            ja=primary.ja or secondary.ja,
            description=primary.description or secondary.description,
            aliases=_unique_texts([*primary.aliases, *secondary.aliases, secondary.tag]),
            search_aliases=_unique_texts([*primary.search_aliases, *secondary.search_aliases]),
            related_tags=_unique_texts([*primary.related_tags, *secondary.related_tags]),
            dictionary_section=primary.dictionary_section or secondary.dictionary_section,
            wiki_note=primary.wiki_note or secondary.wiki_note,
            display_tag=primary.display_tag or secondary.display_tag,
            post_count=max(primary.post_count, secondary.post_count),
            rank_count=max(primary.rank_count, secondary.rank_count),
            category=primary.category or secondary.category,
            is_deprecated=primary.is_deprecated or secondary.is_deprecated,
            is_dictionary=primary.is_dictionary or secondary.is_dictionary,
            importance=max(primary.importance, secondary.importance),
        )

    return tuple(merged_by_tag[tag] for tag in order)


def _katakana_to_hiragana(value: str) -> str:
    chars: list[str] = []
    for char in value:
        codepoint = ord(char)
        if 0x30A1 <= codepoint <= 0x30F6:
            chars.append(chr(codepoint - 0x60))
        else:
            chars.append(char)
    return "".join(chars)


_JAPANESE_VERB_READING_VARIANTS: tuple[tuple[str, str], ...] = (
    ("摑", "つか"),
    ("掴", "つか"),
    ("揉", "も"),
    ("撫で", "なて"),
    ("なで", "なて"),
    ("撫ぜ", "なぜ"),
    ("咥え", "くわえ"),
    ("銜え", "くわえ"),
    ("舐め", "なめ"),
    ("挟ま", "はさま"),
    ("挟み", "はさみ"),
    ("挟む", "はさむ"),
    ("挟ん", "はさん"),
    ("縛ら", "しばら"),
    ("縛り", "しばり"),
    ("縛る", "しばる"),
    ("縛っ", "しばっ"),
    ("繋が", "つなが"),
    ("繋ぎ", "つなぎ"),
    ("繋ぐ", "つなぐ"),
    ("繋げ", "つなげ"),
    ("繫が", "つなが"),
    ("繫ぎ", "つなぎ"),
    ("繫ぐ", "つなぐ"),
    ("繫げ", "つなげ"),
    ("結ば", "むすば"),
    ("結び", "むすび"),
    ("結ぶ", "むすぶ"),
    ("結ん", "むすん"),
    ("触ら", "さわら"),
    ("触り", "さわり"),
    ("触る", "さわる"),
    ("触れ", "ふれ"),
    ("開け", "あけ"),
    ("開い", "あい"),
    ("閉じ", "とじ"),
    ("閉め", "しめ"),
    ("広げ", "ひろげ"),
    ("拡げ", "ひろげ"),
    ("曲げ", "まげ"),
    ("伸ば", "のば"),
    ("隠し", "かくし"),
    ("隠す", "かくす"),
    ("脱が", "ぬが"),
    ("脱ぎ", "ぬぎ"),
    ("脱ぐ", "ぬぐ"),
    ("着る", "きる"),
    ("着て", "きて"),
    ("座ら", "すわら"),
    ("座り", "すわり"),
    ("座る", "すわる"),
    ("立た", "たた"),
    ("立ち", "たち"),
    ("立つ", "たつ"),
    ("寝る", "ねる"),
    ("寝て", "ねて"),
    ("持た", "もた"),
    ("持ち", "もち"),
    ("持つ", "もつ"),
    ("乗ら", "のら"),
    ("乗り", "のり"),
    ("乗る", "のる"),
    ("見せ", "みせ"),
    ("見る", "みる"),
    ("笑う", "わらう"),
    ("笑い", "わらい"),
    ("泣く", "なく"),
    ("泣き", "なき"),
)


def _normalize_japanese_verb_readings(value: str) -> str:
    text = value
    for source, target in _JAPANESE_VERB_READING_VARIANTS:
        text = text.replace(source, target)
    return text


def normalize_query(value: Any) -> str:
    """Normalize Japanese prompt dictionary queries for forgiving matching."""
    text = unicodedata.normalize("NFKC", _cell_to_text(value)).casefold()
    text = _katakana_to_hiragana(text)
    text = text.replace("髮", "髪")
    for source, target in {
        "赤い": "赤",
        "赤色の": "赤",
        "青い": "青",
        "青色の": "青",
        "黒い": "黒",
        "黒色の": "黒",
        "白い": "白",
        "白色の": "白",
        "黄色い": "黄",
        "黄色の": "黄",
        "茶色い": "茶",
        "茶色の": "茶",
        "緑色の": "緑",
        "紫色の": "紫",
        "灰色の": "灰",
        "金色の": "金",
        "銀色の": "銀",
        "ぴんく色の": "ぴんく",
        "水色の": "水色",
    }.items():
        text = text.replace(source, target)
    text = _normalize_japanese_verb_readings(text)
    if text in {"へあ", "へあー"}:
        text = "髪"
    text = text.replace("の毛", "")
    text = text.replace("ノ毛", "")
    text = text.replace("のけ", "")
    text = text.replace("ノけ", "")

    weak_particles = {"を", "が", "に", "の", "で", "へ"}
    normalized_chars: list[str] = []

    for char in text:
        if char in weak_particles:
            continue
        if char.isspace():
            continue
        category = unicodedata.category(char)
        if category.startswith("P") or category.startswith("S"):
            continue
        normalized_chars.append(char)

    return "".join(normalized_chars)


def _normalize_component_alias_text(value: Any) -> str:
    text = _katakana_to_hiragana(unicodedata.normalize("NFKC", _cell_to_text(value)).casefold()).replace("髮", "髪")
    text = _normalize_japanese_verb_readings(text)
    return "".join(char for char in text if not char.isspace() and not unicodedata.category(char).startswith(("P", "S")))


def normalize_symbol_query(value: Any) -> str:
    text = unicodedata.normalize("NFKC", _cell_to_text(value)).casefold().strip()
    text = text.replace("_", " ")
    return re.sub(r"\s+", " ", text)


def _tag_components(value: str) -> tuple[str, ...]:
    components: list[str] = []
    for component in re.findall(r"[a-z0-9][a-z0-9']*", _cell_to_text(value).casefold().replace("_", " ").replace("-", " ")):
        if component in {"the", "and", "from", "with", "for", "into", "non"} or len(component) < 2:
            continue
        if component not in components:
            components.append(component)
    return tuple(components)


def _load_query_lexicon() -> dict[str, list[str]]:
    global _LEXICON_CACHE
    if _LEXICON_CACHE is not None:
        return _LEXICON_CACHE
    try:
        data = json.loads(QUERY_LEXICON_PATH.read_text(encoding="utf-8"))
        _LEXICON_CACHE = {normalize_query(key): [str(item) for item in value] for key, value in data.items() if isinstance(value, list)}
    except Exception:
        _LEXICON_CACHE = {}
    return _LEXICON_CACHE


def _refresh_fanbox_section_links_from_html() -> None:
    global _SECTION_LINKS_CACHE
    if not FANBOX_SECTION_IMPORTER_PATH.exists():
        return
    try:
        spec = importlib.util.spec_from_file_location("_prompt_dictionary_fanbox_importer", FANBOX_SECTION_IMPORTER_PATH)
        if spec is None or spec.loader is None:
            return
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)

        source_path = module.select_source([FANBOX_SECTION_SOURCE_HTML_PATH, FANBOX_SECTION_SOURCE_MHTML_PATH])
        if not source_path.exists():
            return
        source_mtime = source_path.stat().st_mtime
        output_paths = (FANBOX_SECTION_LINKS_PATH, FANBOX_SECTION_LINKS_JS_PATH)
        outputs_are_current = all(path.exists() and path.stat().st_mtime >= source_mtime for path in output_paths)
        if outputs_are_current:
            with FANBOX_SECTION_LINKS_PATH.open("r", encoding="utf-8", newline="") as file:
                existing_link_count = max(0, sum(1 for _ in file) - 1)
            if existing_link_count >= 20:
                return

        page_html = module.read_text(source_path)
        sections = module.read_dictionary_sections(TSV_DICTIONARY_PATH)
        links = module.build_links(page_html, sections)
        if not links:
            return
        module.write_tsv(links, FANBOX_SECTION_LINKS_PATH)
        module.write_js(links, FANBOX_SECTION_LINKS_JS_PATH)
        module.write_audit(links, sections, FANBOX_SECTION_LINK_AUDIT_PATH)
        _SECTION_LINKS_CACHE = None
    except Exception as exc:
        _warn(f"FANBOX HTMLからのジャンルリンク更新に失敗しました: {exc}")


def _load_fanbox_section_links() -> dict[str, str]:
    global _SECTION_LINKS_CACHE
    _refresh_fanbox_section_links_from_html()
    if _SECTION_LINKS_CACHE is not None:
        return _SECTION_LINKS_CACHE

    links: dict[str, str] = {}
    try:
        with FANBOX_SECTION_LINKS_PATH.open("r", encoding="utf-8", newline="") as file:
            reader = csv.DictReader(file, delimiter="\t")
            for row in reader:
                section = _cell_to_text(row.get("section"))
                url = _cell_to_text(row.get("url"))
                if section and url:
                    links[section] = url
    except Exception:
        links = {}

    _SECTION_LINKS_CACHE = links
    return links


def _simplify_fanbox_section(section: str) -> str:
    section = _cell_to_text(section).strip(" \t\r\n　「」『』[]【】")
    section = re.sub(r"[（(][^）)]{1,80}[）)]", "", section)
    section = re.sub(r"\s+", "", section)
    return section.strip(" \t\r\n　「」『』[]【】")


def _fanbox_section_url(section: str) -> str:
    links = _load_fanbox_section_links()
    section = section.strip()
    if section in links:
        return links[section]

    simplified = _simplify_fanbox_section(section)
    candidates = [simplified]
    candidates.extend(_simplify_fanbox_section(part) for part in re.split(r"[/／]", section))
    for candidate in candidates:
        if candidate and candidate in links:
            return links[candidate]

    return ""


_refresh_fanbox_section_links_from_html()


def _query_components(raw_query: str) -> tuple[str, ...]:
    query = normalize_query(raw_query)
    alias_query = _normalize_component_alias_text(raw_query)
    seen: set[str] = set()
    components: list[str] = []

    def add(component: str) -> None:
        if component and component not in seen:
            seen.add(component)
            components.append(component)

    lexicon = _load_query_lexicon()
    for term in sorted(lexicon, key=len, reverse=True):
        components_for_term = lexicon[term]
        is_color_term = any(component in COLOR_COMPONENTS for component in components_for_term)
        if (len(term) >= 2 or is_color_term) and term in query:
            for component in components_for_term:
                add(component)
    for terms, aliases in DIRECT_COMPONENT_ALIASES:
        if any((normalized_term := _normalize_component_alias_text(term)) and normalized_term in alias_query for term in terms):
            for component in aliases:
                add(component)
    for component in _tag_components(raw_query):
        add(component)
    return tuple(components)


def _query_family_components(raw_query: str) -> tuple[str, ...]:
    query = _normalize_component_alias_text(raw_query)
    seen: set[str] = set()
    components: list[str] = []

    def add(component: str) -> None:
        if component and component not in seen:
            seen.add(component)
            components.append(component)

    for terms, aliases in DIRECT_COMPONENT_ALIASES:
        if any((normalized_term := _normalize_component_alias_text(term)) and normalized_term in query for term in terms):
            for component in aliases:
                add(component)
    for component in _tag_components(raw_query):
        add(component)
    return tuple(components)


def _has_symbol(value: str) -> bool:
    return any(unicodedata.category(char).startswith(("P", "S")) for char in value)


def danbooru_wiki_url(tag: str) -> str:
    wiki_slug = quote(tag.strip().replace(" ", "_"), safe="_")
    return f"https://danbooru.donmai.us/wiki_pages/{wiki_slug}"


def danbooru_posts_url(tag: str) -> str:
    tag_slug = quote(tag.strip().replace(" ", "_"), safe="_")
    return f"https://danbooru.donmai.us/posts?tags={tag_slug}"


def _canonical_tag(tag: str) -> str:
    return tag.strip().casefold().replace("_", " ")


def _canonical_tag_key(tag: Any) -> str:
    key = unicodedata.normalize("NFKC", _cell_to_text(tag)).strip().casefold().replace(" ", "_")
    key = re.sub(r"_+", "_", key)
    return TAG_RENAMES.get(key, key)


def _tag_popularity(entry: DictionaryEntry) -> int:
    if entry.is_deprecated:
        return 0
    return max(0, int(entry.rank_count or entry.post_count or 0))


DEPRECATED_ALTERNATIVE_RE = re.compile(r"(?:代替候補|代替タグ)[:：]\s*([^。．\n]+)")
TAG_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9_:+!'./-]*", re.IGNORECASE)


def _deprecated_alternative_tags(entry: DictionaryEntry) -> tuple[str, ...]:
    seen = {_canonical_tag(entry.tag)}
    alternatives: list[str] = []
    for source in (entry.wiki_note, entry.description):
        for match in DEPRECATED_ALTERNATIVE_RE.finditer(source or ""):
            for token_match in TAG_TOKEN_RE.finditer(match.group(1)):
                tag = token_match.group(0).rstrip(".,;:")
                key = _canonical_tag(tag)
                if not tag or key in seen:
                    continue
                seen.add(key)
                alternatives.append(tag)
    return tuple(alternatives)


def _display_description(entry: DictionaryEntry) -> str:
    if entry.is_deprecated and not _has_placeholder_display(entry):
        alternatives = _deprecated_alternative_tags(entry)
        suffix = f"代替候補：{', '.join(alternatives)}" if alternatives else ""
        return f"{entry.ja or entry.tag}。Danbooruタグとしては非推奨とされています。{suffix}"
    description = re.sub(r"\s*/\s*Danbooruタグ。post_count:\s*\d+", "", entry.description or "")
    description = re.sub(r"\s*[?？]【[^】]{1,40}】[^。．.!?！？/]*(?:参照(?:のこと)?。?)?", "", description)
    description = re.sub(r"\s*[?？]【[^】]{1,40}】", "", description)
    description = re.sub(r"\s*/\s*Danbooruタグ。", "", description)
    description = re.sub(r"^Danbooruタグ。post_count:\s*\d+$", "", description)
    description = re.sub(r"^Danbooruタグ。$", "", description).strip()
    if description:
        return _normalize_placeholder_text(description)
    label = entry.ja or entry.tag
    return f"{label}を意味するDanbooruタグ。"


def _entry_display_tag(entry: DictionaryEntry) -> str:
    return _normalize_placeholder_display(entry.display_tag or entry.tag)


def _entry_prompt_tag(entry: DictionaryEntry) -> str:
    return _normalize_placeholder_display(entry.display_tag or entry.tag)


def _display_description_html(entry: DictionaryEntry) -> str:
    if not entry.is_deprecated or _has_placeholder_display(entry):
        return html.escape(_display_description(entry))
    label = html.escape(entry.ja or entry.tag)
    alternative_links = "".join(
        f'<button type="button" class="pd-alt-tag" data-pd-search-tag="{html.escape(tag, quote=True)}">{html.escape(tag)}</button>'
        for tag in _deprecated_alternative_tags(entry)
    )
    suffix = f"代替候補：{alternative_links}" if alternative_links else ""
    return f"{label}。Danbooruタグとしては非推奨とされています。{suffix}"


def _read_xlsx(path: Path) -> tuple[tuple[DictionaryEntry, ...], str]:
    try:
        from openpyxl import load_workbook
    except Exception:
        return (), "openpyxlがインストールされていないため、xlsxを読めませんでした。requirements.txtから依存関係を入れてください。"

    try:
        workbook = load_workbook(path, read_only=True, data_only=True)
        try:
            sheet = workbook.active
            rows = sheet.iter_rows(values_only=True)
            header_row = next(rows, None)

            if not header_row:
                return (), "xlsxの先頭行にヘッダーが見つかりませんでした。"

            headers = {
                unicodedata.normalize("NFKC", _cell_to_text(value)).casefold(): index
                for index, value in enumerate(header_row)
                if _cell_to_text(value)
            }

            missing = [name for name in ("tag", "ja", "description", "aliases") if name not in headers]
            if missing:
                return (), f"xlsxに必要な列がありません: {', '.join(missing)}"

            entries: list[DictionaryEntry] = []
            for row in rows:
                tag = _cell_to_text(row[headers["tag"]] if headers["tag"] < len(row) else "")
                if not tag:
                    continue

                entries.append(
                    DictionaryEntry(
                        tag=tag,
                        display_tag=_normalize_placeholder_display(row[headers["display_tag"]] if "display_tag" in headers and headers["display_tag"] < len(row) else ""),
                        ja=_cell_to_text(row[headers["ja"]] if headers["ja"] < len(row) else ""),
                        description=_cell_to_text(row[headers["description"]] if headers["description"] < len(row) else ""),
                        aliases=_split_aliases(_normalize_placeholder_alias_text(row[headers["aliases"]] if headers["aliases"] < len(row) else "")),
                        search_aliases=_split_aliases(_normalize_placeholder_alias_text(row[headers["search_aliases"]] if "search_aliases" in headers and headers["search_aliases"] < len(row) else "")),
                        related_tags=_split_aliases(row[headers["related_tags"]] if "related_tags" in headers and headers["related_tags"] < len(row) else ""),
                        dictionary_section=_cell_to_text(row[headers["dictionary_section"]] if "dictionary_section" in headers and headers["dictionary_section"] < len(row) else ""),
                        wiki_note=_cell_to_text(row[headers["wiki_note"]] if "wiki_note" in headers and headers["wiki_note"] < len(row) else ""),
                        post_count=_parse_post_count(row[headers["post_count"]] if "post_count" in headers and headers["post_count"] < len(row) else ""),
                        rank_count=_parse_post_count(row[headers["rank_count"]] if "rank_count" in headers and headers["rank_count"] < len(row) else ""),
                        category=_parse_post_count(row[headers["category"]] if "category" in headers and headers["category"] < len(row) else ""),
                        is_deprecated=_cell_to_text(row[headers["is_deprecated"]] if "is_deprecated" in headers and headers["is_deprecated"] < len(row) else "") in {"1", "true", "True", "TRUE", "非推奨"},
                        is_dictionary=_cell_to_text(row[headers["is_dictionary"]] if "is_dictionary" in headers and headers["is_dictionary"] < len(row) else "") in {"1", "true", "True", "TRUE", "辞"},
                        importance=_parse_post_count(row[headers["importance"]] if "importance" in headers and headers["importance"] < len(row) else ""),
                    )
                )
        finally:
            workbook.close()

        if not entries:
            return (), "xlsxから有効なtag行を読み込めませんでした。"

        return _merge_dictionary_entries(tuple(entries)), ""
    except Exception:
        return (), "xlsx読み込み中にエラーが発生しました:\n" + traceback.format_exc()


def _read_tsv(path: Path) -> tuple[tuple[DictionaryEntry, ...], str]:
    try:
        with path.open("r", encoding="utf-8", newline="") as file:
            reader = csv.DictReader(file, delimiter="\t")
            required = {"tag", "ja", "description", "aliases"}
            fieldnames = set(reader.fieldnames or [])
            missing = sorted(required - fieldnames)
            if missing:
                return (), f"tsvに必要な列がありません: {', '.join(missing)}"

            entries = [
                DictionaryEntry(
                    tag=_cell_to_text(row.get("tag")),
                    display_tag=_normalize_placeholder_display(row.get("display_tag")),
                    ja=_cell_to_text(row.get("ja")),
                    description=_cell_to_text(row.get("description")),
                    aliases=_split_aliases(_normalize_placeholder_alias_text(row.get("aliases"))),
                    search_aliases=_split_aliases(_normalize_placeholder_alias_text(row.get("search_aliases"))),
                    related_tags=_split_aliases(row.get("related_tags")),
                    dictionary_section=_cell_to_text(row.get("dictionary_section")),
                    wiki_note=_cell_to_text(row.get("wiki_note")),
                    post_count=_parse_post_count(row.get("post_count")),
                    rank_count=_parse_post_count(row.get("rank_count")),
                    category=_parse_post_count(row.get("category")),
                    is_deprecated=_cell_to_text(row.get("is_deprecated")) in {"1", "true", "True", "TRUE", "非推奨"},
                    is_dictionary=_cell_to_text(row.get("is_dictionary")) in {"1", "true", "True", "TRUE", "辞"},
                    importance=_parse_post_count(row.get("importance")),
                )
                for row in reader
                if _cell_to_text(row.get("tag"))
            ]

        if not entries:
            return (), "tsvから有効なtag行を読み込めませんでした。"

        return _merge_dictionary_entries(tuple(entries)), ""
    except Exception:
        return (), "tsv読み込み中にエラーが発生しました:\n" + traceback.format_exc()


def load_dictionary(force: bool = False) -> DictionaryLoadResult:
    global _CACHE, _CACHE_MTIME

    active_path = TSV_DICTIONARY_PATH if TSV_DICTIONARY_PATH.exists() else DICTIONARY_PATH
    mtime = active_path.stat().st_mtime if active_path.exists() else None
    if not force and _CACHE is not None and _CACHE_MTIME == mtime:
        return _CACHE

    if TSV_DICTIONARY_PATH.exists():
        entries, warning = _read_tsv(TSV_DICTIONARY_PATH)
        if entries:
            result = DictionaryLoadResult(
                entries=entries,
                source=str(TSV_DICTIONARY_PATH.relative_to(EXTENSION_DIR)),
                warning=warning,
                used_fallback=False,
            )
        else:
            result = DictionaryLoadResult(
                entries=FALLBACK_ENTRIES,
                source="内蔵サンプルデータ",
                warning=warning or "tsvから辞書を読み込めなかったため、内蔵サンプルデータを使っています。",
                used_fallback=True,
            )
    elif DICTIONARY_PATH.exists():
        entries, warning = _read_xlsx(DICTIONARY_PATH)
        if entries:
            result = DictionaryLoadResult(
                entries=entries,
                source=str(DICTIONARY_PATH.relative_to(EXTENSION_DIR)),
                warning=warning,
                used_fallback=False,
            )
        else:
            result = DictionaryLoadResult(
                entries=FALLBACK_ENTRIES,
                source="内蔵サンプルデータ",
                warning=warning or "xlsxから辞書を読み込めなかったため、内蔵サンプルデータを使っています。",
                used_fallback=True,
            )
    else:
        result = DictionaryLoadResult(
            entries=FALLBACK_ENTRIES,
            source="内蔵サンプルデータ",
            warning="data/prompt_dictionary.xlsx が見つからないため、内蔵サンプルデータを使っています。",
            used_fallback=True,
        )

    _CACHE = result
    _CACHE_MTIME = mtime
    return result


def _ensure_v1_backup() -> None:
    V1_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    V1_BACKUP_DATA_JS_PATH.parent.mkdir(parents=True, exist_ok=True)
    for source, destination in (
        (TSV_DICTIONARY_PATH, V1_BACKUP_TSV_PATH),
        (QUERY_LEXICON_PATH, V1_BACKUP_QUERY_LEXICON_PATH),
        (PROMPT_DICTIONARY_DATA_JS_PATH, V1_BACKUP_DATA_JS_PATH),
        (PROMPT_DICTIONARY_LEXICON_JS_PATH, V1_BACKUP_LEXICON_JS_PATH),
    ):
        if source.exists() and not destination.exists():
            shutil.copy2(source, destination)


def _read_dictionary_rows(path: Path = TSV_DICTIONARY_PATH) -> tuple[list[dict[str, str]], list[str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as file:
        reader = csv.DictReader(file, delimiter="\t")
        fieldnames = [_tsv_fieldname(field) for field in list(reader.fieldnames or []) if _tsv_fieldname(field)]
        rows = [_normalize_tsv_row(row) for row in reader]
    for field in TSV_EDIT_FIELDS:
        if field not in fieldnames:
            fieldnames.append(field)
    for row in rows:
        for field in fieldnames:
            row.setdefault(field, "")
    return rows, fieldnames


def _base_rows_by_tag() -> dict[str, dict[str, str]]:
    global _BASE_ROW_CACHE, _BASE_ROW_CACHE_MTIME

    mtime = TSV_DICTIONARY_PATH.stat().st_mtime if TSV_DICTIONARY_PATH.exists() else None
    if _BASE_ROW_CACHE is not None and _BASE_ROW_CACHE_MTIME == mtime:
        return _BASE_ROW_CACHE

    if not TSV_DICTIONARY_PATH.exists():
        _BASE_ROW_CACHE = {}
        _BASE_ROW_CACHE_MTIME = mtime
        return _BASE_ROW_CACHE

    rows, _fieldnames = _read_dictionary_rows()
    _BASE_ROW_CACHE = {
        _tsv_row_key(row): row
        for row in rows
        if _cell_to_text(row.get("tag"))
    }
    _BASE_ROW_CACHE_MTIME = mtime
    return _BASE_ROW_CACHE


def _write_dictionary_rows(rows: list[dict[str, str]], fieldnames: list[str]) -> None:
    TSV_DICTIONARY_PATH.parent.mkdir(parents=True, exist_ok=True)
    temp_path = TSV_DICTIONARY_PATH.with_suffix(".tsv.tmp")
    with temp_path.open("w", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=fieldnames, delimiter="\t", lineterminator="\n", extrasaction="ignore")
        writer.writeheader()
        for row in rows:
            writer.writerow({field: _cell_to_text(row.get(field, "")) for field in fieldnames})
    temp_path.replace(TSV_DICTIONARY_PATH)


def _write_dictionary_js(rows: list[dict[str, str]], fieldnames: list[str]) -> None:
    PROMPT_DICTIONARY_DATA_JS_PATH.parent.mkdir(parents=True, exist_ok=True)
    payload = [{field: _cell_to_text(row.get(field, "")) for field in fieldnames} for row in rows]
    temp_path = PROMPT_DICTIONARY_DATA_JS_PATH.with_suffix(".js.tmp")
    with temp_path.open("w", encoding="utf-8", newline="\n") as file:
        file.write("window.PROMPT_DICTIONARY_DATA = ")
        json.dump(payload, file, ensure_ascii=False, separators=(",", ":"))
        file.write(";\n")
    temp_path.replace(PROMPT_DICTIONARY_DATA_JS_PATH)


def _read_edit_overlay_rows() -> tuple[list[dict[str, str]], list[str]]:
    fieldnames = list(EDIT_OVERLAY_FIELDS)
    if not EDIT_OVERLAY_TSV_PATH.exists():
        return [], fieldnames

    with EDIT_OVERLAY_TSV_PATH.open("r", encoding="utf-8-sig", newline="") as file:
        reader = csv.DictReader(file, delimiter="\t")
        existing_fields = [_tsv_fieldname(field) for field in list(reader.fieldnames or []) if _tsv_fieldname(field)]
        rows = [_normalize_tsv_row(row) for row in reader]

    for field in existing_fields:
        if field not in fieldnames:
            fieldnames.append(field)
    for row in rows:
        for field in fieldnames:
            row.setdefault(field, "")
    return rows, fieldnames


def _write_edit_overlay_rows(rows: list[dict[str, str]], fieldnames: list[str]) -> None:
    EDIT_OVERLAY_TSV_PATH.parent.mkdir(parents=True, exist_ok=True)
    for field in EDIT_OVERLAY_FIELDS:
        if field not in fieldnames:
            fieldnames.append(field)
    rows = [_normalize_edit_overlay_row(row, fieldnames) for row in rows]

    temp_path = EDIT_OVERLAY_TSV_PATH.with_suffix(".tsv.tmp")
    with temp_path.open("w", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=fieldnames, delimiter="\t", lineterminator="\n", extrasaction="ignore")
        writer.writeheader()
        for row in rows:
            writer.writerow({field: _cell_to_text(row.get(field, "")) for field in fieldnames})
    temp_path.replace(EDIT_OVERLAY_TSV_PATH)
    _write_edit_overlay_js(rows, fieldnames)


def _normalize_edit_overlay_row(row: dict[str, str], fieldnames: list[str]) -> dict[str, str]:
    normalized = {field: _cell_to_text(row.get(field, "")) for field in fieldnames}
    for field in ("display_tag", "edit_display_tag"):
        if field in normalized:
            normalized[field] = _normalize_placeholder_display(normalized.get(field))
    for field in ("description", "edit_description"):
        if field in normalized:
            normalized[field] = _normalize_placeholder_text(normalized.get(field))
    for field in ("aliases", "search_aliases"):
        if field in normalized:
            normalized[field] = _normalize_placeholder_alias_text(normalized.get(field))
    return normalized


def _write_edit_overlay_js(rows: list[dict[str, str]], fieldnames: list[str]) -> None:
    PROMPT_DICTIONARY_EDITS_JS_PATH.parent.mkdir(parents=True, exist_ok=True)
    payload = [
        {field: _cell_to_text(row.get(field, "")) for field in fieldnames}
        for row in rows
        if _cell_to_text(row.get("tag"))
    ]
    temp_path = PROMPT_DICTIONARY_EDITS_JS_PATH.with_suffix(".js.tmp")
    with temp_path.open("w", encoding="utf-8", newline="\n") as file:
        file.write("window.PROMPT_DICTIONARY_EDITS = ")
        json.dump(payload, file, ensure_ascii=False, separators=(",", ":"))
        file.write(";\n")
    temp_path.replace(PROMPT_DICTIONARY_EDITS_JS_PATH)


def _upsert_edit_overlay_row(edit_row: dict[str, str]) -> None:
    rows, fieldnames = _read_edit_overlay_rows()
    key = _tsv_row_key(edit_row)
    index = next((idx for idx, item in enumerate(rows) if _tsv_row_key(item) == key), -1)
    if index >= 0:
        rows[index] = edit_row
    else:
        rows.append(edit_row)
    _write_edit_overlay_rows(rows, fieldnames)


def _read_tsv_rows(path: Path, default_fields: tuple[str, ...]) -> tuple[list[dict[str, str]], list[str]]:
    fieldnames = list(default_fields)
    if not path.exists():
        return [], fieldnames

    with path.open("r", encoding="utf-8-sig", newline="") as file:
        reader = csv.DictReader(file, delimiter="\t")
        existing_fields = [_tsv_fieldname(field) for field in list(reader.fieldnames or []) if _tsv_fieldname(field)]
        rows = [_normalize_tsv_row(row) for row in reader]

    for field in existing_fields:
        if field not in fieldnames:
            fieldnames.append(field)
    for row in rows:
        for field in fieldnames:
            row.setdefault(field, "")
    return rows, fieldnames


def _write_tsv_rows(path: Path, rows: list[dict[str, str]], fieldnames: list[str]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp_path = path.with_suffix(path.suffix + ".tmp")
    with temp_path.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=fieldnames, delimiter="\t", lineterminator="\n", extrasaction="ignore")
        writer.writeheader()
        for row in rows:
            writer.writerow({field: _cell_to_text(row.get(field, "")) for field in fieldnames})
    temp_path.replace(path)


def _tag_group_full_key(row: dict[str, str] | tuple[str, str, str]) -> tuple[str, str, str]:
    if isinstance(row, tuple):
        group_key, section, tag = row
    else:
        group_key = _cell_to_text(row.get("group_key"))
        section = _cell_to_text(row.get("section"))
        tag = _canonical_tag_name(_cell_to_text(row.get("tag")))
    return group_key, section, tag


def _write_tag_groups_js(rows: list[dict[str, str]]) -> None:
    groups: dict[str, dict[str, Any]] = {}
    group_seen: dict[str, set[str]] = {}
    section_seen: set[tuple[str, str]] = set()

    for row in rows:
        tag = _canonical_tag_name(_cell_to_text(row.get("tag")))
        group_key = _cell_to_text(row.get("group_key"))
        if not tag or not group_key:
            continue
        group = groups.setdefault(group_key, {
            "label": _cell_to_text(row.get("group_label")) or group_key.replace("_", " "),
            "sourceTitle": _cell_to_text(row.get("source_title")) or f"tag_group:{group_key}",
            "sourceUrl": _cell_to_text(row.get("source_url")),
            "hidden": _cell_to_text(row.get("is_hidden")) == "1",
            "queryTriggers": [],
            "sections": [],
        })
        if _cell_to_text(row.get("is_hidden")) == "1":
            group["hidden"] = True

        tag_key = _tsv_row_key(tag)
        if tag_key in group_seen.setdefault(group_key, set()):
            continue
        group_seen[group_key].add(tag_key)

        section_title = _cell_to_text(row.get("section")) or "その他"
        section_key = (group_key, section_title)
        sections = group["sections"]
        if section_key not in section_seen:
            section_seen.add(section_key)
            sections.append({"title": section_title, "items": []})
        sections[-1]["items"].append([tag, _cell_to_text(row.get("ja_short"))])

    for group_key, group in groups.items():
        label = _cell_to_text(group.get("label"))
        key_text = re.sub(r"_(?:tags|tag)$", "", group_key).replace("_", " ")
        triggers = [label, key_text, group_key.replace("_", " ")]
        if key_text.endswith("s") and len(key_text) > 3:
            triggers.append(key_text[:-1])
        if group_key == "eyes_tags":
            triggers.extend(["瞳", "目", "眼", "見る", "みる", "eye", "eyes"])
        group["queryTriggers"] = [item for item in dict.fromkeys(triggers) if item]

    PROMPT_DICTIONARY_TAG_GROUPS_JS_PATH.parent.mkdir(parents=True, exist_ok=True)
    temp_path = PROMPT_DICTIONARY_TAG_GROUPS_JS_PATH.with_suffix(".js.tmp")
    with temp_path.open("w", encoding="utf-8", newline="\n") as file:
        file.write("window.PROMPT_DICTIONARY_TAG_GROUPS = ")
        json.dump(groups, file, ensure_ascii=False, separators=(",", ":"))
        file.write(";\n")
    temp_path.replace(PROMPT_DICTIONARY_TAG_GROUPS_JS_PATH)


def _upsert_dictionary_ja_overlay(tag: str, ja: str, updated_at: str) -> dict[str, str] | None:
    tag = _canonical_tag_name(tag)
    if not tag or not ja:
        return None

    base_rows = _base_rows_by_tag()
    key = _tsv_row_key(tag)
    row = dict(base_rows.get(key, {})) or None
    edit_rows, _edit_fieldnames = _read_edit_overlay_rows()
    edit_row = next((item for item in edit_rows if _tsv_row_key(item) == key and _cell_to_text(item.get("edit_removed")) != "1"), None)
    if edit_row:
        merged_row = dict(row or {})
        merged_row.update(edit_row)
        row = merged_row

    overlay = _overlay_row_from_base(row, tag)
    overlay["edit_ja"] = ja
    overlay["edit_updated_at"] = updated_at
    overlay["edit_removed"] = ""
    _upsert_edit_overlay_row(overlay)

    entry = dict(row or {"tag": tag})
    entry.update({
        "tag": tag,
        "edit_ja": ja,
        "edit_updated_at": updated_at,
        "edit_removed": "",
    })
    return entry


def _save_tag_group_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
    group_key = _cell_to_text(payload.get("group_key"))
    section = _cell_to_text(payload.get("section"))
    tag = _canonical_tag_name(_cell_to_text(payload.get("tag")))
    ja_short = _cell_to_text(payload.get("ja_short")) or tag
    updated_at = _cell_to_text(payload.get("updated_at")) or _cell_to_text(payload.get("edit_updated_at"))
    if not updated_at:
        updated_at = ""
    if not group_key or not section or not tag:
        return {"ok": False, "message": "タグ一覧の編集対象が不正です。"}

    rows, row_fields = _read_tsv_rows(TAG_GROUPS_TSV_PATH, TAG_GROUP_FIELDS)
    manual_rows, manual_fields = _read_tsv_rows(MANUAL_TAG_GROUP_EDIT_TSV_PATH, MANUAL_TAG_GROUP_FIELDS)
    if not rows:
        return {"ok": False, "message": "タグ一覧データが見つかりません。"}

    matching_rows = [row for row in rows if _canonical_tag_name(_cell_to_text(row.get("tag"))) == tag]
    target_rows = matching_rows or [row for row in rows if _tag_group_full_key(row) == (group_key, section, tag)]
    if not target_rows:
        return {"ok": False, "message": "タグ一覧の該当行が見つかりません。"}

    updated_keys: set[tuple[str, str, str]] = set()
    for row in target_rows:
        row["tag"] = tag
        row["ja_short"] = ja_short
        row["ja_source"] = "manual_ui"
        updated_keys.add(_tag_group_full_key(row))

    manual_index = {_tag_group_full_key(row): row for row in manual_rows if _cell_to_text(row.get("group_key")) and _cell_to_text(row.get("tag"))}
    group_orders = {
        _cell_to_text(row.get("group_key")): _cell_to_text(row.get("group_order"))
        for row in manual_rows
        if _cell_to_text(row.get("group_key")) and _cell_to_text(row.get("group_order"))
    }
    for row in target_rows:
        key = _tag_group_full_key(row)
        manual_row = manual_index.get(key)
        if manual_row is None:
            manual_row = {field: "" for field in manual_fields}
            manual_row["group_order"] = group_orders.get(_cell_to_text(row.get("group_key")), "")
            manual_rows.append(manual_row)
            manual_index[key] = manual_row
        for field in TAG_GROUP_FIELDS:
            manual_row[field] = _cell_to_text(row.get(field))
        manual_row["ja_short"] = ja_short
        manual_row["ja_source"] = "manual_ui"

    _write_tsv_rows(TAG_GROUPS_TSV_PATH, rows, row_fields)
    _write_tsv_rows(MANUAL_TAG_GROUP_EDIT_TSV_PATH, manual_rows, manual_fields)
    _write_tag_groups_js(rows)
    entry = _upsert_dictionary_ja_overlay(tag, ja_short, updated_at)

    return {
        "ok": True,
        "message": f"タグ一覧を保存しました（{len(updated_keys)}行）。",
        "tag": tag,
        "ja_short": ja_short,
        "updated_rows": [
            {field: _cell_to_text(row.get(field)) for field in TAG_GROUP_FIELDS}
            for row in target_rows
        ],
        "entry": _row_payload(entry),
    }


def _overlay_row_from_base(row: dict[str, str] | None, tag: str) -> dict[str, str]:
    overlay = {field: "" for field in EDIT_OVERLAY_FIELDS}
    overlay["tag"] = tag
    if row:
        for field in EDIT_OVERLAY_FIELDS:
            overlay[field] = _cell_to_text(row.get(field, overlay.get(field, "")))
    overlay["tag"] = tag
    return overlay


def _backup_rows_by_tag() -> dict[str, dict[str, str]]:
    if not V1_BACKUP_TSV_PATH.exists():
        return {}
    rows, _fieldnames = _read_dictionary_rows(V1_BACKUP_TSV_PATH)
    return {
        _tsv_row_key(row): row
        for row in rows
        if _cell_to_text(row.get("tag")) and _cell_to_text(row.get("category")) != "1"
    }


def _row_payload(row: dict[str, str] | None) -> dict[str, str] | None:
    if row is None:
        return None
    return {key: _cell_to_text(value) for key, value in row.items()}


def _save_dictionary_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
    _ensure_v1_backup()
    tag = _canonical_tag_name(_cell_to_text(payload.get("tag")))
    if not tag:
        return {"ok": False, "message": "tagが空です。"}

    base_rows = _base_rows_by_tag()
    key = _tsv_row_key(tag)
    row = dict(base_rows.get(key, {})) or None
    edit_rows, _edit_fieldnames = _read_edit_overlay_rows()
    edit_row = next((item for item in edit_rows if _tsv_row_key(item) == key and _cell_to_text(item.get("edit_removed")) != "1"), None)
    if edit_row:
        merged_row = dict(row or {})
        merged_row.update(edit_row)
        row = merged_row
    original_is_dictionary = row is not None and _cell_to_text(row.get("is_dictionary")) == "1"

    if row is None:
        row = {field: "" for field in EDIT_OVERLAY_FIELDS}
        row["tag"] = tag
        row["has_tag"] = "0"
        row["has_wiki"] = "0"

    display_tag = _normalize_placeholder_display(payload.get("display_tag"))
    ja = _cell_to_text(payload.get("ja")) or tag
    description = _normalize_placeholder_text(payload.get("description"))
    related_tags = ",".join(_split_aliases(payload.get("related_tags")))
    overlay = _overlay_row_from_base(row, tag)
    entry = dict(row)

    if original_is_dictionary:
        overlay["edit_display_tag"] = display_tag
        overlay["edit_ja"] = ja
        overlay["edit_description"] = description
        overlay["edit_related_tags"] = related_tags
        overlay["edit_related_tags_override"] = "1"
        overlay["edit_promoted"] = "0"
        entry.update({
            "edit_display_tag": display_tag,
            "edit_ja": ja,
            "edit_description": description,
            "edit_related_tags": related_tags,
            "edit_related_tags_override": "1",
            "edit_promoted": "0",
        })
    else:
        overlay["display_tag"] = display_tag
        overlay["ja"] = ja
        overlay["description"] = description
        overlay["related_tags"] = related_tags
        overlay["edit_related_tags_override"] = "1"
        overlay["is_dictionary"] = "1"
        overlay["edit_promoted"] = "1"
        entry.update({
            "display_tag": display_tag,
            "ja": ja,
            "description": description,
            "related_tags": related_tags,
            "edit_related_tags_override": "1",
            "is_dictionary": "1",
            "edit_promoted": "1",
        })
    updated_at = _cell_to_text(payload.get("updated_at"))
    overlay["edit_updated_at"] = updated_at
    entry["edit_updated_at"] = updated_at
    overlay["edit_removed"] = ""

    _upsert_edit_overlay_row(overlay)
    return {"ok": True, "message": "差分保存しました。", "entry": _row_payload(entry)}


def _reset_dictionary_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
    _ensure_v1_backup()
    tag = _canonical_tag_name(_cell_to_text(payload.get("tag")))
    if not tag:
        return {"ok": False, "message": "tagが空です。"}

    key = _tsv_row_key(tag)
    backup = _backup_rows_by_tag().get(key)
    removed: dict[str, str] | None = None
    entry: dict[str, str] | None = None

    if backup is None:
        removed = {"tag": tag, "edit_removed": "1"}
        _upsert_edit_overlay_row(_overlay_row_from_base(removed, tag))
    else:
        fieldnames = list(EDIT_OVERLAY_FIELDS)
        restored = {field: _cell_to_text(backup.get(field, "")) for field in fieldnames}
        for field in TSV_EDIT_FIELDS:
            restored[field] = ""
        overlay = _overlay_row_from_base(restored, tag)
        overlay["edit_updated_at"] = _cell_to_text(payload.get("updated_at"))
        overlay["edit_removed"] = ""
        _upsert_edit_overlay_row(overlay)
        entry = restored

    return {
        "ok": True,
        "message": "v1状態へ戻しました。",
        "entry": _row_payload(entry),
        "removed": _row_payload(removed),
    }


async def _api_save_dictionary_edit(request: Request) -> dict[str, Any]:
    try:
        payload = await request.json()
        if not isinstance(payload, dict):
            return {"ok": False, "message": "payloadが不正です。"}
        return _save_dictionary_edit_payload(payload)
    except Exception:
        return {"ok": False, "message": traceback.format_exc()}


async def _api_reset_dictionary_edit(request: Request) -> dict[str, Any]:
    try:
        payload = await request.json()
        if not isinstance(payload, dict):
            return {"ok": False, "message": "payloadが不正です。"}
        return _reset_dictionary_edit_payload(payload)
    except Exception:
        return {"ok": False, "message": traceback.format_exc()}


async def _api_save_tag_group_edit(request: Request) -> dict[str, Any]:
    try:
        payload = await request.json()
        if not isinstance(payload, dict):
            return {"ok": False, "message": "payloadが不正です。"}
        return _save_tag_group_edit_payload(payload)
    except Exception:
        return {"ok": False, "message": traceback.format_exc()}


def _bridge_json_response(result: dict[str, Any], payload: dict[str, Any] | None = None) -> str:
    if payload and payload.get("_request_id"):
        result = dict(result)
        result["_request_id"] = _cell_to_text(payload.get("_request_id"))
    return json.dumps(result, ensure_ascii=False, separators=(",", ":"))


def _save_dictionary_edit_bridge(payload_json: str) -> str:
    try:
        payload = json.loads(payload_json or "{}")
        if not isinstance(payload, dict):
            return _bridge_json_response({"ok": False, "message": "payloadが不正です。"})
        if _cell_to_text(payload.get("kind")) == "tag_group_edit":
            return _bridge_json_response(_save_tag_group_edit_payload(payload), payload)
        return _bridge_json_response(_save_dictionary_edit_payload(payload), payload)
    except Exception:
        return _bridge_json_response({"ok": False, "message": traceback.format_exc()})


def _reset_dictionary_edit_bridge(payload_json: str) -> str:
    try:
        payload = json.loads(payload_json or "{}")
        if not isinstance(payload, dict):
            return _bridge_json_response({"ok": False, "message": "payloadが不正です。"})
        return _bridge_json_response(_reset_dictionary_edit_payload(payload), payload)
    except Exception:
        return _bridge_json_response({"ok": False, "message": traceback.format_exc()})


def _on_app_started(_demo: Any, app: Any) -> None:
    app.add_api_route("/prompt-dictionary/v1_1/edit", _api_save_dictionary_edit, methods=["POST"])
    app.add_api_route("/prompt-dictionary/v1_1/reset", _api_reset_dictionary_edit, methods=["POST"])
    app.add_api_route("/prompt-dictionary/v1_1/tag_group_edit", _api_save_tag_group_edit, methods=["POST"])


def _simple_partial_ratio(left: str, right: str) -> float:
    if not left or not right:
        return 0.0

    short, long = (left, right) if len(left) <= len(right) else (right, left)
    if short in long:
        return 100.0

    window = len(short)
    best = 0.0
    for index in range(0, len(long) - window + 1):
        best = max(best, SequenceMatcher(None, short, long[index : index + window]).ratio() * 100)
    return best


def _fuzzy_score(query_norm: str, candidate_norm: str) -> float:
    if not query_norm or not candidate_norm:
        return 0.0

    if rapidfuzz_fuzz is not None:
        return float(max(
            rapidfuzz_fuzz.ratio(query_norm, candidate_norm),
            rapidfuzz_fuzz.partial_ratio(query_norm, candidate_norm),
        ))

    return max(
        SequenceMatcher(None, query_norm, candidate_norm).ratio() * 100,
        _simple_partial_ratio(query_norm, candidate_norm),
    )


def _loose_score(query_norm: str, candidate_norm: str, weights: dict[str, float]) -> float:
    if not query_norm or not candidate_norm:
        return 0.0

    if query_norm == candidate_norm:
        return weights["exact"]
    if candidate_norm.startswith(query_norm):
        return weights["prefix"] + min(len(query_norm) * 10, 90) - min(max(0, len(candidate_norm) - len(query_norm)) * 1.5, 90)
    index = candidate_norm.find(query_norm)
    if index >= 0:
        return weights["contains"] + min(len(query_norm) * 8, 90) - min(index * 26, 180) - min(max(0, len(candidate_norm) - len(query_norm)) * 1.2, 120)
    if candidate_norm in query_norm and _useful_reverse_candidate(candidate_norm):
        return weights["reverse"] + min(len(candidate_norm) * 8, 90)
    if min(len(query_norm), len(candidate_norm)) < 3:
        return 0.0

    if re.fullmatch(r"[a-z0-9]+", query_norm) and re.fullmatch(r"[a-z0-9]+", candidate_norm):
        length_delta = abs(len(query_norm) - len(candidate_norm))
        if len(query_norm) >= 5 and length_delta <= max(2, len(query_norm) // 4):
            typo_ratio = SequenceMatcher(None, query_norm, candidate_norm).ratio()
            if typo_ratio >= 0.88:
                return weights["prefix"] + min((typo_ratio - 0.88) * 900, 90) - min(length_delta * 25, 70)

    matched = 0
    cursor = 0
    for char in query_norm:
        index = candidate_norm.find(char, cursor)
        if index >= 0:
            matched += 1
            cursor = index + 1
    order_score = matched / max(len(query_norm), 1)
    if order_score >= 0.84:
        return weights["fuzzy"] + order_score * 120
    return 0.0


def _useful_reverse_candidate(candidate_norm: str) -> bool:
    if re.match(r"^[ぁぃぅぇぉっゃゅょゎ]", candidate_norm):
        return False
    if len(candidate_norm) >= 3:
        return True
    return bool(re.search(r"[\u3400-\u9fff]", candidate_norm))


def _symbol_score(symbol_query: str, candidate_symbol: str, weights: dict[str, float]) -> float:
    if not symbol_query or not candidate_symbol or not _has_symbol(symbol_query):
        return 0.0
    compact_query = re.sub(r"\s+", "", symbol_query)
    compact_candidate = re.sub(r"\s+", "", candidate_symbol)
    if symbol_query == candidate_symbol or compact_query == compact_candidate:
        return weights["exact"] + 40
    if len(symbol_query) >= 2 and (candidate_symbol.startswith(symbol_query) or compact_candidate.startswith(compact_query)):
        return weights["prefix"] + 20
    if len(symbol_query) >= 2 and (symbol_query in candidate_symbol or compact_query in compact_candidate):
        return weights["contains"]
    return 0.0


def _score_text(query_norm: str, symbol_query: str, candidate: str, weights: dict[str, float]) -> float:
    candidate_norm = normalize_query(candidate)
    candidate_symbol = normalize_symbol_query(candidate)
    return max(
        _loose_score(query_norm, candidate_norm, weights),
        _symbol_score(symbol_query, candidate_symbol, weights),
    )


def _component_score(query_components: tuple[str, ...], entry: DictionaryEntry) -> float:
    if not query_components:
        return 0.0
    entry_components = set(_tag_components(entry.tag))
    if not entry_components:
        return 0.0
    matched = sum(1 for component in query_components if component in entry_components)
    if not matched:
        return 0.0
    ratio = matched / len(query_components)
    extra = max(0, len(entry_components) - matched)
    if matched == len(query_components):
        base = 1180.0 if extra == 0 else 980.0
        return base + matched * 90.0 - min(extra * 18.0, 160.0)
    return 260.0 + ratio * 360.0 + matched * 35.0 - min(extra * 8.0, 80.0)


def _dictionary_score_boost(entry: DictionaryEntry, base_score: float) -> float:
    if not entry.is_dictionary or base_score < 120:
        return 0.0
    return 190.0


def _important_tag_boost(entry: DictionaryEntry, base_score: float) -> float:
    if not entry.is_dictionary or entry.is_deprecated or entry.importance <= 0 or base_score < 500:
        return 0.0
    return min(480.0, 180.0 * entry.importance)


def _has_placeholder_display(entry: DictionaryEntry) -> bool:
    return bool(PLACEHOLDER_DISPLAY_RE.search(_entry_display_tag(entry)))


def _exact_query_text_match(query_norm: str, symbol_query: str, entry: DictionaryEntry) -> bool:
    if not query_norm and not symbol_query:
        return False
    primary_values = (entry.ja, entry.tag, _entry_display_tag(entry))
    if query_norm and any(normalize_query(value) == query_norm for value in primary_values):
        return True
    if symbol_query and any(normalize_symbol_query(value) == symbol_query for value in primary_values):
        return True
    for alias in (*entry.aliases, *entry.search_aliases):
        if query_norm and normalize_query(alias) == query_norm:
            return True
        if symbol_query and normalize_symbol_query(alias) == symbol_query:
            return True
    return False


def _placeholder_pattern_boost(entry: DictionaryEntry, query_norm: str, symbol_query: str, base_score: float) -> float:
    if not entry.is_dictionary or not _has_placeholder_display(entry) or base_score < 500:
        return 0.0
    if _exact_query_text_match(query_norm, symbol_query, entry):
        return 320.0
    return 0.0


def _exact_canonical_tag_boost(entry: DictionaryEntry, query_tag_key: str) -> float:
    if query_tag_key and _canonical_tag_key(entry.tag) == query_tag_key:
        return 700.0
    return 0.0


def _representative_score_boost(query_norm: str, symbol_query: str, entry: DictionaryEntry, base_score: float) -> float:
    if not entry.is_dictionary or entry.is_deprecated or base_score < 700:
        return 0.0
    if not _exact_query_text_match(query_norm, symbol_query, entry):
        return 0.0
    description = entry.description or ""
    if any(marker in description for marker in ("代表タグ", "基本的", "基本タグ")):
        return 220.0
    return 0.0


def _dictionary_leading_description_boost(query_norm: str, entry: DictionaryEntry, base_score: float) -> float:
    if not entry.is_dictionary or entry.is_deprecated or base_score < 550 or len(query_norm) < 2:
        return 0.0
    if re.search(r"[\u3040-\u30ff\u3400-\u9fff]", entry.ja or ""):
        return 0.0
    if normalize_query(entry.ja) == query_norm or normalize_query(entry.tag) == query_norm:
        return 0.0
    description_norm = normalize_query(entry.description)
    if not description_norm.startswith(query_norm):
        return 0.0
    return 520.0


def _dictionary_exact_match_boost(query_norm: str, symbol_query: str, entry: DictionaryEntry, base_score: float) -> float:
    if not entry.is_dictionary or base_score < 700:
        return 0.0
    return 260.0 if _exact_query_text_match(query_norm, symbol_query, entry) else 0.0


def _dictionary_component_family_boost(
    family_components: tuple[str, ...],
    entry: DictionaryEntry,
    base_score: float,
    has_dictionary_exact_match: bool,
    query_norm: str,
    symbol_query: str,
) -> float:
    if not family_components or not entry.is_dictionary or entry.is_deprecated:
        return 0.0
    if has_dictionary_exact_match and not _exact_query_text_match(query_norm, symbol_query, entry):
        return 0.0
    entry_components = set(_tag_components(entry.tag))
    if not entry_components:
        return 0.0
    matched = sum(1 for component in family_components if component in entry_components)
    if not matched:
        return 0.0

    extra = max(0, len(entry_components) - matched)
    popularity = max(entry.rank_count, entry.post_count, 0)
    popularity_boost = min(math.log10(popularity + 10) * 38.0, 250.0)
    marker_text = f"{entry.description},{','.join(entry.aliases)}"
    marker_boost = 120.0 if re.search(r"[〇○×Xx]{2}|タグ群|種類を指定|組み合わせ|組み合わせる|ほかにも|他に", marker_text) else 0.0
    hair_context_penalty = 260.0 if family_components == ("hair",) and any(component in HAIR_CONTEXT_PENALTY_COMPONENTS for component in entry_components) else 0.0
    required_context_components = tuple(component for component in family_components if component in FAMILY_REQUIRED_CONTEXT_COMPONENTS)
    needs_context = any(component in FAMILY_OBJECT_COMPONENTS for component in family_components) and bool(required_context_components)
    missing_context_penalty = 360.0 if needs_context and not any(component in required_context_components for component in entry_components) else 0.0
    target_score = 1420.0 + matched * 80.0 + popularity_boost + marker_boost - min(extra * 10.0, 150.0) - hair_context_penalty - missing_context_penalty
    return max(0.0, target_score - base_score)


def _character_partial_name_boost(query_norm: str, entry: DictionaryEntry, base_score: float) -> float:
    if entry.category != 4 or base_score < 700 or len(query_norm) < 3:
        return 0.0
    ja_norm = normalize_query(entry.ja)
    if ja_norm and ja_norm != query_norm and query_norm in ja_norm and not ja_norm.startswith(query_norm):
        return 360.0
    return 0.0


def _proper_name_partial_penalty(query_norm: str, symbol_query: str, entry: DictionaryEntry, base_score: float) -> float:
    if entry.is_dictionary or base_score < 700 or len(query_norm) < 2:
        return 0.0
    if _exact_query_text_match(query_norm, symbol_query, entry):
        return 0.0

    normalized_values = [normalize_query(entry.ja), *(normalize_query(alias) for alias in (*entry.aliases, *entry.search_aliases))]
    if not any(value and value != query_norm and query_norm in value for value in normalized_values):
        return 0.0
    if entry.category == 4:
        return 300.0
    if entry.category in {1, 3}:
        return 240.0
    return 170.0


def _low_signal_score_penalty(entry: DictionaryEntry, base_score: float) -> float:
    if entry.is_dictionary or base_score < 700:
        return 0.0
    if entry.post_count <= 0 and entry.rank_count <= 0:
        return 180.0
    return 0.0


def _is_searchable_match(entry: DictionaryEntry, score: float) -> bool:
    if entry.is_dictionary:
        return True
    if entry.post_count > 0 or entry.rank_count > 0:
        return True
    return False


def _is_deprecated_visible_for_query(entry: DictionaryEntry, query_norm: str, symbol_query: str) -> bool:
    if not entry.is_deprecated:
        return True
    if entry.is_dictionary and _has_placeholder_display(entry) and _exact_query_text_match(query_norm, symbol_query, entry):
        return True
    return bool(
        (query_norm and normalize_query(entry.tag) == query_norm)
        or (symbol_query and normalize_symbol_query(entry.tag) == symbol_query)
    )


def _score_entry(
    query_norm: str,
    symbol_query: str,
    query_components: tuple[str, ...],
    family_components: tuple[str, ...],
    entry: DictionaryEntry,
    has_dictionary_exact_match: bool,
    query_tag_key: str,
) -> tuple[float, str]:
    candidates: list[tuple[str, str, dict[str, float]]] = [
        ("ja", entry.ja, {"exact": 1200, "prefix": 1030, "contains": 820, "reverse": 700, "fuzzy": 180}),
        ("tag", entry.tag, {"exact": 1250, "prefix": 1060, "contains": 850, "reverse": 720, "fuzzy": 190}),
        ("display_tag", _entry_display_tag(entry), {"exact": 1250, "prefix": 1060, "contains": 850, "reverse": 720, "fuzzy": 190}),
        ("description", entry.description, {"exact": 760, "prefix": 740, "contains": 700, "reverse": 420, "fuzzy": 120}),
    ]
    candidates.extend(("aliases", alias, {"exact": 1120, "prefix": 980, "contains": 760, "reverse": 650, "fuzzy": 160}) for alias in entry.aliases)
    candidates.extend(("search_aliases", alias, {"exact": 1120, "prefix": 980, "contains": 760, "reverse": 650, "fuzzy": 160}) for alias in entry.search_aliases)

    best_score = 0.0
    best_field = ""
    strong_matches = 0
    for field_name, text, weights in candidates:
        score = _score_text(query_norm, symbol_query, text, weights)
        if score > best_score:
            best_score = score
            best_field = field_name
        if score >= 400:
            strong_matches += 1

    best_score = max(best_score, _component_score(query_components, entry))
    best_score += min(max(0, strong_matches - 1) * 35, 90)
    best_score += _dictionary_score_boost(entry, best_score)
    best_score += _important_tag_boost(entry, best_score)
    best_score += _placeholder_pattern_boost(entry, query_norm, symbol_query, best_score)
    best_score += _exact_canonical_tag_boost(entry, query_tag_key)
    best_score += _dictionary_exact_match_boost(query_norm, symbol_query, entry, best_score)
    best_score += _representative_score_boost(query_norm, symbol_query, entry, best_score)
    best_score += _dictionary_leading_description_boost(query_norm, entry, best_score)
    best_score += _character_partial_name_boost(query_norm, entry, best_score)
    best_score += _dictionary_component_family_boost(family_components, entry, best_score, has_dictionary_exact_match, query_norm, symbol_query)
    if entry.is_deprecated and _is_deprecated_visible_for_query(entry, query_norm, symbol_query):
        best_score += 620.0
    best_score -= _low_signal_score_penalty(entry, best_score)
    return best_score, best_field


def search_dictionary(query: str, entries: tuple[DictionaryEntry, ...] | None = None, limit: int = MAX_RESULTS) -> list[SearchMatch]:
    query_norm = normalize_query(query)
    symbol_query = normalize_symbol_query(query)
    query_tag_key = _canonical_tag_key(symbol_query or query)
    if not query_norm and not symbol_query:
        return []

    if entries is None:
        entries = load_dictionary().entries

    matches: list[SearchMatch] = []
    components = _query_components(query)
    family_components = _query_family_components(query)
    has_dictionary_exact_match = any(
        entry.is_dictionary and _exact_query_text_match(query_norm, symbol_query, entry)
        for entry in entries
    )
    for entry in entries:
        score, matched_field = _score_entry(query_norm, symbol_query, components, family_components, entry, has_dictionary_exact_match, query_tag_key)
        if has_dictionary_exact_match:
            score -= _proper_name_partial_penalty(query_norm, symbol_query, entry, score)
        if score >= 120 and _is_searchable_match(entry, score) and _is_deprecated_visible_for_query(entry, query_norm, symbol_query):
            matches.append(SearchMatch(entry=entry, score=score, matched_field=matched_field))

    matches.sort(key=lambda item: (-item.score, -_tag_popularity(item.entry), item.entry.tag.casefold()))
    return matches[:limit]


def _status_html(load_result: DictionaryLoadResult) -> str:
    count = len(load_result.entries)
    source = html.escape(load_result.source)
    warning = html.escape(load_result.warning).replace("\n", "<br>")
    status_class = "pd-status-warning" if load_result.warning else "pd-status-ok"
    warning_html = f"<div class='pd-warning'>{warning}</div>" if warning else ""

    return (
        f"<div class='pd-status {status_class}'>"
        f"<strong>{count}</strong> 件の辞書データを読み込みました。"
        f"<span class='pd-status-source'>Source: {source}</span>"
        f"{warning_html}</div>"
    )


def _dictionary_section_html(entry: DictionaryEntry, class_name: str = "pd-section-link") -> str:
    if not entry.is_dictionary or not entry.dictionary_section:
        return ""
    sections = [part.strip() for part in entry.dictionary_section.split(" / ") if part.strip()]
    if not sections:
        return ""
    links = []
    for section in sections[:3]:
        url = _fanbox_section_url(section)
        if url:
            links.append(
                f'<a class="{class_name}" href="{html.escape(url, quote=True)}" target="_blank" rel="noopener noreferrer">{html.escape(section)}</a>'
            )
        else:
            links.append(f'<span class="{class_name} pd-section-link-missing">{html.escape(section)}</span>')
    return "".join(links)


def _related_tags_html(entry: DictionaryEntry) -> str:
    if entry.is_deprecated and not _has_placeholder_display(entry):
        return ""
    related = [tag.strip() for tag in entry.related_tags if tag.strip()]
    if not related:
        return ""
    links = [
        f'<button type="button" data-pd-search-tag="{html.escape(tag, quote=True)}">{html.escape(tag)}</button>'
        for tag in related[:RELATED_TAG_LIMIT]
    ]
    return f'<div class="pd-related"><span>関連</span>{"".join(links)}</div>'


def _result_card(match: SearchMatch) -> str:
    entry = match.entry
    display_tag = _entry_display_tag(entry)
    prompt_tag = _entry_prompt_tag(entry)
    tag = html.escape(display_tag)
    ja = html.escape(entry.ja or entry.tag)
    description = _display_description_html(entry)
    wiki_url = html.escape(danbooru_wiki_url(entry.tag), quote=True)
    posts_url = html.escape(danbooru_posts_url(entry.tag), quote=True)
    data_tag = html.escape(prompt_tag, quote=True)
    data_search_tag = html.escape(display_tag, quote=True)
    score = round(match.score)
    dictionary_badge = "<span class=\"pd-dictionary-badge\">【辞】</span>" + _dictionary_section_html(entry) if entry.is_dictionary else ""
    deprecated_badge = "<span class=\"pd-deprecated-badge\">非推奨</span>" if entry.is_deprecated and not _has_placeholder_display(entry) else ""
    related_html = _related_tags_html(entry)

    return f"""
<article class="pd-result-card">
  <div class="pd-result-main">
    <button type="button" class="pd-tag-button" data-pd-search-tag="{data_search_tag}">{tag}</button>
    <span class="pd-ja">{ja}{dictionary_badge}{deprecated_badge}</span>
    <span class="pd-score">score {score}</span>
  </div>
  <p class="pd-description">{description}</p>
  {related_html}
  <div class="pd-actions">
    <button type="button" class="pd-insert-tag" data-target="txt2img" data-tag="{data_tag}">txt2imgへ挿入</button>
    <button type="button" class="pd-insert-tag" data-target="img2img" data-tag="{data_tag}">img2imgへ挿入</button>
    <button type="button" class="pd-copy-tag" data-tag="{data_tag}">tagをコピー</button>
    <button type="button" data-pd-open-url="{posts_url}" aria-label="{tag} のDanbooru投稿一覧">📦</button>
    <button type="button" data-pd-open-url="{wiki_url}">wiki</button>
  </div>
</article>
"""


def _results_html(query: str, matches: list[SearchMatch]) -> str:
    query_text = html.escape(_cell_to_text(query))
    if not query_text:
        return (
            "<div class='pd-empty'>検索語を入力してください。"
            "例: <code>髪をいじる</code> / <code>髪の毛をいじる</code> / <code>twirling_hair</code></div>"
        )

    if not matches:
        return f"<div class='pd-empty'>「{query_text}」に一致する候補は見つかりませんでした。</div>"

    cards = "\n".join(_result_card(match) for match in matches)
    return f"<div class='pd-results-heading'>「{query_text}」の候補: {len(matches)}件</div><div class='pd-results'>{cards}</div>"


def search_and_render(query: str) -> tuple[str, str]:
    load_result = load_dictionary()
    matches = search_dictionary(query, entries=load_result.entries)
    return _status_html(load_result), _results_html(query, matches)


def reload_and_render(query: str) -> tuple[str, str]:
    load_result = load_dictionary(force=True)
    matches = search_dictionary(query, entries=load_result.entries)
    return _status_html(load_result), _results_html(query, matches)


if False and gr is not None and webui_scripts is not None:
    class PromptDictionaryEditBridgeScript(webui_scripts.Script):
        create_group = False
        sorting_priority = 9999

        def title(self) -> str:
            return "Prompt Dictionary Edit Bridge"

        def show(self, _is_img2img: bool) -> object:
            return webui_scripts.AlwaysVisible

        def ui(self, is_img2img: bool) -> list[Any]:
            suffix = "img2img" if is_img2img else "txt2img"
            with gr.Group(elem_id=f"pd_edit_bridge_{suffix}"):
                payload = gr.Textbox(
                    label="Prompt Dictionary edit payload",
                    value="",
                    lines=4,
                    elem_id=f"pd_edit_payload_{suffix}",
                    interactive=True,
                )
                result = gr.Textbox(
                    label="Prompt Dictionary edit result",
                    value="",
                    lines=4,
                    elem_id=f"pd_edit_result_{suffix}",
                    interactive=False,
                )
                save_button = gr.Button(value="Prompt Dictionary save", elem_id=f"pd_edit_save_{suffix}")
                reset_button = gr.Button(value="Prompt Dictionary reset", elem_id=f"pd_edit_reset_{suffix}")

            save_button.click(
                fn=_save_dictionary_edit_bridge,
                inputs=[payload],
                outputs=[result],
                show_progress=False,
            )
            reset_button.click(
                fn=_reset_dictionary_edit_bridge,
                inputs=[payload],
                outputs=[result],
                show_progress=False,
            )
            return [payload, result]


def create_prompt_dictionary_tab():
    if gr is None:
        return []

    load_result = load_dictionary()

    with gr.Blocks(analytics_enabled=False) as prompt_dictionary_interface:
        with gr.Column(elem_id="prompt_dictionary_panel"):
            gr.HTML(
                value=(
                    "<div class='pd-header'>"
                    "<h2>プロンプト大辞典</h2>"
                    "<p>日本語の見出しや別名からDanbooru系タグを検索できます。</p>"
                    "<div id='pd_notice' aria-live='polite'></div>"
                    "</div>"
                )
            )

            with gr.Row(elem_classes=["pd-search-row"]):
                query = gr.Textbox(
                    label="検索",
                    placeholder="検索",
                    lines=1,
                    elem_id="pd_query",
                )

            with gr.Row(elem_classes=["pd-button-row"]):
                search_button = gr.Button(value="検索", variant="primary", elem_id="pd_search_button")
                reload_button = gr.Button(value="辞書を再読み込み", elem_id="pd_reload_button")

            status = gr.HTML(value=_status_html(load_result), elem_id="pd_status")
            results = gr.HTML(value=_results_html("", []), elem_id="pd_results")

            search_button.click(
                fn=search_and_render,
                inputs=[query],
                outputs=[status, results],
                show_progress=False,
            )
            query_event = query.input if hasattr(query, "input") else query.change
            query_event(
                fn=search_and_render,
                inputs=[query],
                outputs=[status, results],
                show_progress=False,
            )
            reload_button.click(
                fn=reload_and_render,
                inputs=[query],
                outputs=[status, results],
                show_progress=False,
            )

    return [(prompt_dictionary_interface, "プロンプト大辞典", "prompt_dictionary")]


# The visible panel is injected by javascript/prompt_dictionary.js under the txt2img/img2img
# Generate buttons. The Gradio tab builder above is kept importable for local tests, but is not
# registered because this extension is meant to stay near the prompt controls.
if False and script_callbacks is not None:
    try:
        script_callbacks.on_app_started(_on_app_started, name="prompt_dictionary_v1_1_edit_api")
    except TypeError:
        script_callbacks.on_app_started(_on_app_started)
