#!/usr/bin/env python3
"""Conservatively enrich TSL API descriptions and search tags.

Markdown is the fact source. This tool never changes API headings, signatures,
parameter tables, return types, examples, or scope placement. It only updates
the first prose description line and the optional ``<!-- tags: ... -->`` line.
"""

from __future__ import annotations

import argparse
import csv
import html
import re
import sys
import unicodedata
from collections import defaultdict
from dataclasses import dataclass, field, replace
from pathlib import Path

from bs4 import BeautifulSoup


SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from api_markdown import DECLARATION_LINE_RE, iter_api_entries


TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
ACCESS_RE = re.compile(r"^访问[:：]\s*(.*?)\s*$", re.IGNORECASE)
H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
TERMINAL_PUNCTUATION_RE = re.compile(r"[。．.!！?？；;，,：:]+$")
MULTISPACE_RE = re.compile(r"\s+")
FORMULA_RE = re.compile(
    r"^([^。；;，,]{2,40}?)\s*(?<![<>=!])=(?!=)\s*(.+)$"
)
COMPARISON_RE = re.compile(r"<=|>=|==|!=|<|>")
RELATED_FUNCTION_RE = re.compile(
    r"^([A-Za-z][A-Za-z0-9_]*)\s*相关函数$"
)
ACRONYM_RE = re.compile(r"(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{1,15})(?![A-Za-z0-9_])")
ACTION_CLAUSE_RE = re.compile(
    r"^(?:用于|如果|由于|若(?!干)|支持|查询|输入|按照|按(?!钮)|与系统参数|"
    r"对.+?(?:进行|使用)|从.+?(?:获取|读取|计算|返回)|"
    r"由.+?(?:倒推|获取|计算|得到|生成))"
)

PROHIBITED_PROCESS_RE = re.compile(
    r"Source\s*ID|来源[:：]|已验证|验证通过|not[_ ]?found|"
    r"测试过程|探测结果|官网定义",
    re.IGNORECASE,
)
GENERIC_SUMMARIES = {
    "函数",
    "方法",
    "属性",
    "常量",
    "接口",
    "对象",
    "类",
    "内置函数",
    "内部函数",
    "统计分布相关函数",
}
GENERIC_TAGS = {
    "函数",
    "方法",
    "属性",
    "常量",
    "数据",
    "tsl",
    "builtin",
    "dotnet",
    "third",
    "deprecated",
    "function",
    "method",
    "property",
}
CLASS_DESCRIPTIONS = {
    "imap": "用于连接 IMAP 服务器、管理邮箱并读取邮件的内置对象",
    "mailmsg": "用于创建、解析、保存和发送邮件消息的内置对象",
    "messagepart": "表示邮件正文、附件等消息分段的内置对象",
    "pop3": "用于连接 POP3 服务器并读取或删除邮件的内置对象",
    "smtp": "用于连接 SMTP 服务器并发送邮件的内置对象",
    "tcipher": "提供对称加密和解密能力的内置对象",
    "tcookie": "表示 HTTP Cookie 名称、值和属性的内置对象",
    "tcookiecollection": "用于创建、查找和管理 HTTP Cookie 集合的内置对象",
    "tfilestream": "以文件为存储后端并提供流式读写能力的内置对象",
    "thandlestream": "封装系统句柄并提供流式读写能力的内置对象",
    "thashedstringlist": "使用哈希索引加速查找的字符串列表内置对象",
    "tinifile": "用于读取和写入 INI 配置文件的内置对象",
    "tmeminifile": "在内存中读取、修改和保存 INI 配置的内置对象",
    "tmemorystream": "以内存缓冲区保存和操作流数据的内置对象",
    "tregistryinifile": "以系统注册表为存储后端的 INI 配置内置对象",
    "trsa": "提供 RSA 非对称加密、解密和签名能力的内置对象",
    "tsession": "表示服务端会话及其键值数据的内置对象",
    "tsessionman": "用于创建、查找、维护和删除服务端会话的内置对象",
    "tstream": "定义顺序读写、定位和复制操作的流基类",
    "tstringlist": "提供字符串集合存储、查找、排序和名称值管理能力的内置对象",
    "twebrequest": "封装 HTTP 请求头、请求参数、Cookie 和请求正文的内置对象",
    "twebresponse": "用于设置 HTTP 状态、响应头、Cookie 和响应正文的内置对象",
}
GENERIC_DESCRIPTION_OVERRIDES = {
    "unicodeesc2": "将字符串转换为 \\uxxxx 形式的 Unicode 编码串",
    "sf_x2inv": "根据累计概率计算卡方分布的逆函数值",
    "sf_tinv": "根据累计概率计算 t 分布的逆函数值",
    "sf_sinx": "计算正弦积分",
    "sf_normalinv2": "根据累计概率计算标准正态分布的逆函数值",
    "sf_normalinv": "根据累计概率计算正态分布的逆函数值",
    "sf_normal": "计算正态分布函数值",
    "sf_gammainv": "根据累计概率计算标准 Gamma 分布的逆函数值",
    "sf_gamma": "计算伽马函数值",
    "sf_finv": "根据累计概率计算 F 分布的逆函数值",
    "sf_ex": "计算指数积分",
    "sf_error": "计算误差函数（概率函数）值",
    "sf_cosx": "计算余弦积分",
    "sf_betainv": "根据累计概率计算贝塔分布的逆函数值",
    "sf_beta": "计算贝塔函数值",
    "readfile": "读取本地文件中的数据",
    "millisecondspan": "计算两个日期时间之间相差的毫秒数",
    "importfile": "从文件中导入数据",
    "getmsgdigest": "计算字符串或文件的摘要并返回摘要字符串",
    "exportfile": "将数据导出到文件",
}
AWKWARD_DESCRIPTION_OVERRIDES = {
    "encodegraph": "根据指定的类型、名称、数据和属性数组生成图形并返回",
    "slndepreciation": (
        "按直线折旧法计算每期折旧额，计算公式为"
        "（资产原值 Cost - 资产残值 Salvage）/ 折旧周期数 Life"
    ),
    "format": "根据 formatstr 指定的格式将参数连接成字符串并返回",
    "ansiformat": "根据 formatstr 指定的格式将参数连接成字符串并返回",
    "dupestring": "将字符串 AText 重复 ACount 次并返回结果",
    "stockpjcj": "返回区间平均成交价，计算公式为区间成交金额 / 区间成交量",
}
LEADING_VERBS = (
    "返回",
    "获取",
    "读取",
    "写入",
    "设置",
    "创建",
    "删除",
    "移除",
    "清除",
    "添加",
    "插入",
    "查找",
    "搜索",
    "判断",
    "检查",
    "验证",
    "计算",
    "转换",
    "解析",
    "格式化",
    "比较",
    "执行",
    "调用",
    "连接",
    "关闭",
    "打开",
    "发送",
    "接收",
    "保存",
    "加载",
    "列出",
    "表示",
    "提供",
    "释放",
    "交换",
    "排序",
    "过滤",
    "统计",
    "控制",
    "指示",
    "提取",
    "根据",
    "将",
    "把",
    "生成",
    "构造",
    "采用",
    "利用",
    "输出",
    "得到",
    "获得",
    "取得",
    "新建",
)


@dataclass(frozen=True)
class SourceRecord:
    corpus: str
    page_id: str
    title: str
    page: Path
    description: str = ""


@dataclass(frozen=True)
class EntryContext:
    name: str
    signature: str
    kind: str
    scope: str
    page: str
    summary: str
    access: str = ""
    owner: str = ""
    tags: tuple[str, ...] = ()


@dataclass(frozen=True)
class PageContext:
    title: str
    path: str


@dataclass
class AuditChange:
    page: str
    name: str
    kind: str
    old_description: str
    new_description: str
    old_tags: tuple[str, ...]
    new_tags: tuple[str, ...]
    sources: tuple[str, ...]


@dataclass
class Audit:
    entries: int = 0
    source_matches: int = 0
    ambiguous_sources: int = 0
    changed_descriptions: int = 0
    changed_tags: int = 0
    changes: list[AuditChange] = field(default_factory=list)

    def merge(self, other: "Audit") -> None:
        self.entries += other.entries
        self.source_matches += other.source_matches
        self.ambiguous_sources += other.ambiguous_sources
        self.changed_descriptions += other.changed_descriptions
        self.changed_tags += other.changed_tags
        self.changes.extend(other.changes)


def normalize_key(value: str) -> str:
    return unicodedata.normalize("NFKC", value).casefold().strip()


def clean_text(value: str) -> str:
    value = html.unescape(value).replace("\xa0", " ")
    value = value.replace("“", '"').replace("”", '"')
    value = value.replace("‘", "'").replace("’", "'")
    return MULTISPACE_RE.sub(" ", value).strip()


def strip_terminal_punctuation(value: str) -> str:
    return TERMINAL_PUNCTUATION_RE.sub("", clean_text(value)).rstrip()


def safe_source_description(value: str) -> str:
    value = strip_terminal_punctuation(value)
    if not value or PROHIBITED_PROCESS_RE.search(value):
        return ""
    return value


def _extract_source_description(page: Path) -> str:
    try:
        soup = BeautifulSoup(page.read_text(encoding="utf-8"), "html.parser")
    except (OSError, UnicodeError):
        return ""
    root = soup.select_one("#help_content") or soup
    for expected in ("简述", "说明"):
        for label in root.select(".DescriteTitle"):
            if clean_text(label.get_text(" ", strip=True)) != expected:
                continue
            marker = label.parent if label.parent is not None else label
            sibling = marker.find_next_sibling()
            if sibling is None:
                sibling = label.find_next_sibling()
            if sibling is not None:
                return safe_source_description(sibling.get_text(" ", strip=True))
    return ""


class SourceCatalog:
    PREFERENCES = {
        "builtin": ("tsl_base", "net_function", "knowledge_base"),
        "third": ("net_function", "tsl_base", "knowledge_base"),
        "deprecated": ("net_function", "tsl_base", "knowledge_base"),
        "dotnet": ("net_function", "tsl_base", "knowledge_base"),
    }

    def __init__(self, records: dict[str, list[SourceRecord]] | None = None):
        self.records = records or {}
        self._description_cache: dict[Path, str] = {}

    @classmethod
    def empty(cls) -> "SourceCatalog":
        return cls({})

    @classmethod
    def from_docs_root(cls, docs_root: Path) -> "SourceCatalog":
        records: dict[str, list[SourceRecord]] = defaultdict(list)
        for corpus in ("net_function", "tsl_base", "knowledge_base"):
            root = docs_root / corpus
            manifest = root / "manifest.tsv"
            if not manifest.is_file():
                continue
            with manifest.open(encoding="utf-8", newline="") as handle:
                for row in csv.DictReader(handle, delimiter="\t"):
                    if row.get("status") != "ok" or not row.get("title"):
                        continue
                    record = SourceRecord(
                        corpus=corpus,
                        page_id=row.get("id", ""),
                        title=row["title"],
                        page=root / row.get("path", ""),
                    )
                    records[normalize_key(record.title)].append(record)
        return cls(dict(records))

    def _load_description(self, record: SourceRecord) -> SourceRecord:
        if record.description:
            return record
        if record.page not in self._description_cache:
            self._description_cache[record.page] = _extract_source_description(
                record.page
            )
        return replace(
            record, description=self._description_cache[record.page]
        )

    def match(self, entry: EntryContext) -> list[SourceRecord]:
        matches = self.records.get(normalize_key(entry.name), [])
        preference = self.PREFERENCES.get(
            entry.scope,
            ("net_function", "tsl_base", "knowledge_base"),
        )
        rank = {name: index for index, name in enumerate(preference)}

        def page_order(record: SourceRecord):
            try:
                page_id = (0, int(record.page_id))
            except ValueError:
                page_id = (1, record.page_id)
            return rank.get(record.corpus, 99), page_id

        return sorted(matches, key=page_order)

    def describe(self, records: list[SourceRecord]) -> list[SourceRecord]:
        return [self._load_description(record) for record in records]


def _starts_with_verb(value: str) -> bool:
    return value.startswith(LEADING_VERBS) or bool(
        ACTION_CLAUSE_RE.match(value)
    )


def _choose_source_description(
    current: str, sources: list[SourceRecord]
) -> str:
    candidates = [
        safe_source_description(record.description) for record in sources
    ]
    candidates = [value for value in candidates if value]
    if not candidates:
        return current
    if len(sources) == 1 and current in GENERIC_SUMMARIES:
        candidate = candidates[0]
        if candidate not in GENERIC_SUMMARIES and len(candidate) > len(current):
            return candidate
    if PROHIBITED_PROCESS_RE.search(current):
        return candidates[0]
    return current


def _property_description(value: str, access: str) -> str:
    if _starts_with_verb(value):
        return value
    if (
        len(value) > 30
        or any(mark in value for mark in "。；，")
        or value.startswith(("功能同", "只读", "用于"))
    ):
        return value
    normalized = normalize_key(access).replace(" ", "")
    if value.startswith("是否"):
        return (
            f"控制{value}"
            if "write" in normalized
            else f"指示{value}"
        )
    if "read" in normalized and "write" in normalized:
        return f"获取或设置{value}"
    if "write" in normalized:
        return f"设置{value}"
    return f"获取{value}"


def _name_intent(name: str) -> str:
    key = normalize_key(name)
    rules = (
        (("is", "if", "has", "can", "check", "valid"), "判断"),
        (("get", "find", "query", "search"), "获取"),
        (("read", "load"), "读取"),
        (("write", "save", "export"), "写入"),
        (("set",), "设置"),
        (("create", "make", "new"), "创建"),
        (("delete", "remove", "drop"), "删除"),
        (("clear", "reset"), "清除"),
        (("add", "append", "insert"), "添加"),
        (("parse", "decode"), "解析"),
        (("encode", "convert", "to"), "转换"),
        (("format",), "格式化"),
        (("calc", "compute"), "计算"),
        (("open",), "打开"),
        (("close",), "关闭"),
        (("send", "post"), "发送"),
        (("connect", "login"), "连接"),
        (("list",), "列出"),
    )
    for prefixes, intent in rules:
        if key.startswith(prefixes):
            return intent
    return ""


def improve_description(
    entry: EntryContext, sources: list[SourceRecord]
) -> str:
    current = strip_terminal_punctuation(entry.summary)
    current = _choose_source_description(current, sources)
    current = strip_terminal_punctuation(current)
    if not current:
        return entry.summary

    if entry.kind == "class":
        replacement = CLASS_DESCRIPTIONS.get(normalize_key(entry.name))
        if replacement and re.fullmatch(
            rf"{re.escape(entry.name)}\s*内置对象", current, re.IGNORECASE
        ):
            return replacement

    if entry.kind == "property":
        return _property_description(current, entry.access)

    key = normalize_key(entry.name)
    generic_override = GENERIC_DESCRIPTION_OVERRIDES.get(key)
    if generic_override and current.endswith("相关函数"):
        return generic_override
    awkward_override = AWKWARD_DESCRIPTION_OVERRIDES.get(key)
    if awkward_override and current.startswith(
        ("返回根据", "返回将", "返回:", "返回：")
    ):
        return awkward_override

    current = re.sub(
        r"^(?:该|本)函数(?:主要(?:是)?)?\s*", "", current
    )
    current = re.sub(r"^功能[:：]\s*", "", current)
    if current.startswith("是否"):
        current = f"判断{current}"

    for old, new in (
        ("读出", "读取"),
        ("取出", "获取"),
        ("得到", "获取"),
        ("获得", "获取"),
        ("取得", "获取"),
        ("新建", "创建"),
    ):
        if current.startswith(old):
            current = new + current[len(old):]
            break
    if current.startswith("读") and not current.startswith("读取"):
        current = "读取" + current[1:]
    if current.startswith("写") and not current.startswith("写入"):
        current = "写入" + current[1:]
    current = current.replace("读出", "读取")
    current = current.replace("装载内容", "加载内容")

    related_function = RELATED_FUNCTION_RE.fullmatch(current)
    if related_function:
        return f"返回 {related_function.group(1)} 对应的数据"

    formula = FORMULA_RE.match(current)
    page_key = normalize_key(entry.page)
    if (
        formula
        and ("/financial/" in page_key or "/financial_report/" in page_key)
        and not _starts_with_verb(current)
    ):
        left, right = formula.groups()
        return f"计算{left.strip()}，公式为{right.strip()}"

    if _starts_with_verb(current):
        return current

    if "/financial/" in page_key or "/financial_report/" in page_key:
        if (
            re.match(r"^\d+(?:\s|[.．、)])", current)
            or COMPARISON_RE.search(current)
        ):
            return current
        return f"返回{current}"

    intent = _name_intent(entry.name)
    if intent and len(current) <= 28 and not any(
        verb in current[:16] for verb in LEADING_VERBS
    ):
        return f"{intent}{current}"
    return current


DOMAIN_RULES = (
    (r"数组|列表|(?:^|[^a-z])(?:array|fmarray|list)(?:$|[^a-z])", ("数组", "列表", "array", "list")),
    (r"矩阵|(?:^|[^a-z])matrix(?:$|[^a-z])|\bmt_", ("矩阵", "matrix")),
    (r"字符串|文本|(?:^|[^a-z])(?:string|char|text)(?:$|[^a-z])", ("字符串", "文本", "string", "text")),
    (r"日期|时间|(?:^|[^a-z])(?:datetime|date|time)(?:$|[^a-z])", ("日期时间", "日期", "时间", "datetime")),
    (r"文件|目录|路径|(?:^|[^a-z])(?:file|folder|directory|path)(?:$|[^a-z])", ("文件", "目录", "路径", "file")),
    (r"网络|(?:^|[^a-z])(?:http|https|cgi|cookie|url)(?:$|[^a-z])", ("网络", "HTTP", "请求", "network")),
    (r"缓存|(?:^|[^a-z])cache(?:$|[^a-z])", ("缓存", "缓存管理", "cache")),
    (r"数据库|(?:^|[^a-z])(?:sql|dbf|ini)(?:$|[^a-z])", ("数据库", "SQL", "database")),
    (r"进程|线程|(?:^|[^a-z])(?:process|thread|pipe)(?:$|[^a-z])", ("进程", "线程", "process")),
    (r"颜色|(?:^|[^a-z])(?:color|rgb|cmyk)(?:$|[^a-z])", ("颜色", "RGB", "color")),
    (r"图形|图表|(?:^|[^a-z])(?:graph|chart)(?:$|[^a-z])", ("图形", "图表", "graph")),
    (r"统计|概率|分布|(?:^|[^a-z])(?:statistics|cdf|pdf)(?:$|[^a-z])", ("统计", "概率", "statistics")),
    (r"优化|线性规划|(?:^|[^a-z])optimization(?:$|[^a-z])", ("优化", "求解", "optimization")),
    (r"数值|数学|(?:^|[^a-z])(?:numeric|math)(?:$|[^a-z])", ("数学", "数值", "numeric")),
    (r"类型转换|(?:^|[^a-z])(?:convert|conversion)(?:$|[^a-z])", ("类型转换", "转换", "conversion")),
    (r"对象|(?:^|[^a-z])(?:object|class)(?:$|[^a-z])", ("对象", "实例", "object")),
    (r"(?:^|[^a-z])(?:com|ole)(?:$|[^a-z])|activex", ("COM", "OLE", "自动化")),
    (r"(?:^|[^a-z])ftp(?:$|[^a-z])", ("FTP", "文件传输")),
    (r"邮件|(?:^|[^a-z])(?:smtp|pop3|imap|mail)(?:$|[^a-z])", ("邮件", "SMTP", "POP3", "IMAP")),
    (r"财务|金融|证券|股票|基金|债券|期货|期权", ("金融",)),
    (r"报表|报告期|report", ("报表", "报告期")),
)

SPECIAL_TAG_RULES = (
    (r"去重|删除重复|dropduplicate|dedup", ("去重", "删除重复", "deduplicate")),
    (r"哈希索引|哈希表", ("哈希索引", "哈希表", "hash")),
    (
        r"摘要|(?:^|[^a-z0-9_])(?:crc32|md5|sha1|sha224|sha256|sha384|sha512|sm3|digest|hash)(?:$|[^a-z0-9_])",
        ("摘要", "哈希", "散列", "digest", "hash"),
    ),
    (r"排序|sort", ("排序", "sort")),
    (r"过滤|筛选|filter", ("过滤", "筛选", "filter")),
    (r"查找|搜索|find|search|lookup", ("查找", "搜索", "lookup")),
)

INTENT_TAGS = {
    "返回": ("返回", "获取"),
    "获取": ("获取", "查询"),
    "读取": ("读取", "获取"),
    "写入": ("写入", "保存"),
    "设置": ("设置", "修改"),
    "创建": ("创建", "生成"),
    "删除": ("删除", "移除"),
    "清除": ("清除", "重置"),
    "添加": ("添加", "追加"),
    "查找": ("查找", "搜索"),
    "判断": ("判断", "检查"),
    "计算": ("计算", "求解"),
    "转换": ("转换", "编码"),
    "解析": ("解析", "parse"),
    "格式化": ("格式化", "format"),
    "打开": ("打开", "open"),
    "关闭": ("关闭", "close"),
    "发送": ("发送", "提交"),
    "连接": ("连接", "登录"),
    "列出": ("列出", "列表"),
    "输出": ("输出", "打印"),
}


def _taxonomy_tags(title: str) -> list[str]:
    result = []
    for raw in re.split(r"\s*/\s*|\s+-\s+", title):
        value = raw.strip(" #")
        value = re.sub(r"\([^)]*\)|（[^）]*）", "", value).strip()
        for suffix in ("相关函数", "及其实现", "相关"):
            if value.endswith(suffix):
                value = value[: -len(suffix)].rstrip()
        if not value or normalize_key(value) in GENERIC_TAGS:
            continue
        if len(value) > 18:
            continue
        chinese = "".join(re.findall(r"[\u3400-\u9fff]+", value))
        latin = re.sub(r"[\u3400-\u9fff]+", " ", value).strip()
        if latin and normalize_key(latin) not in GENERIC_TAGS:
            result.extend(
                re.findall(r"[A-Za-z][A-Za-z0-9_+.-]*", latin)
            )
        if chinese:
            result.append(chinese)
    return result


def _description_intent(summary: str) -> str:
    value = strip_terminal_punctuation(summary)
    replacements = {
        "得到": "获取",
        "获得": "获取",
        "取得": "获取",
        "新建": "创建",
    }
    for source, target in replacements.items():
        if value.startswith(source):
            return target
    for intent in INTENT_TAGS:
        if value.startswith(intent):
            return intent
    for intent in ("读取", "写入", "保存", "计算", "判断", "转换", "解析"):
        if intent in value[:16]:
            return "写入" if intent == "保存" else intent
    return ""


def derive_tags(entry: EntryContext, page: PageContext) -> list[str]:
    tags: list[str] = []
    seen: set[str] = set()

    def add(value: str) -> None:
        value = clean_text(value).strip()
        key = normalize_key(value)
        if (
            not value
            or key in seen
            or key in GENERIC_TAGS
            or len(tags) >= 12
        ):
            return
        seen.add(key)
        tags.append(value)

    for tag in entry.tags:
        add(tag)

    for tag in _taxonomy_tags(page.title):
        add(tag)

    haystack = " ".join(
        (entry.name, entry.signature, entry.summary, page.title, page.path)
    )
    domain_haystack = " ".join((page.title, page.path))
    for pattern, aliases in SPECIAL_TAG_RULES:
        if re.search(pattern, haystack, re.IGNORECASE):
            for alias in aliases:
                add(alias)

    summary = strip_terminal_punctuation(entry.summary)
    intent = _description_intent(summary)
    if not intent and not _starts_with_verb(summary):
        is_market_price = (
            normalize_key(entry.name) in {"open", "close"}
            and "盘价" in summary
        )
        if not is_market_price:
            intent = _name_intent(entry.name)
    for alias in INTENT_TAGS.get(intent, ()):
        add(alias)
    for pattern, aliases in DOMAIN_RULES:
        if re.search(pattern, domain_haystack, re.IGNORECASE):
            for alias in aliases:
                add(alias)
    for acronym in ACRONYM_RE.findall(entry.summary):
        add(acronym)
    return tags


def _scope_for_page(page: str) -> str:
    parts = Path(page).parts
    return parts[0] if len(parts) > 1 else Path(page).stem


def _page_title(lines: list[str], page: str) -> PageContext:
    for line in lines:
        match = H1_RE.match(line)
        if match:
            return PageContext(match.group(1), page)
    return PageContext(Path(page).stem, page)


def _entry_metadata(lines: list[str], start: int, end: int):
    declaration_seen = False
    description_index = None
    tag_index = None
    access = ""
    for index in range(start + 1, end):
        text = lines[index].strip()
        if not text:
            continue
        if not declaration_seen:
            if DECLARATION_LINE_RE.fullmatch(text):
                declaration_seen = True
            continue
        tag_match = TAGS_RE.fullmatch(text)
        if tag_match:
            tag_index = index
            continue
        access_match = ACCESS_RE.fullmatch(text)
        if access_match:
            access = access_match.group(1)
            continue
        if description_index is None:
            if text.startswith(("|", "#", "返回：", "类型：", "可见性：", "值：")):
                continue
            description_index = index
    tags = ()
    if tag_index is not None:
        tags = tuple(TAGS_RE.fullmatch(lines[tag_index].strip()).group(1).split())
    return description_index, tag_index, access, tags


def enrich_markdown(
    text: str, page: str, source_catalog: SourceCatalog
) -> tuple[str, Audit]:
    had_final_newline = text.endswith("\n")
    lines = text.splitlines()
    page_context = _page_title(lines, page)
    entries = [entry for entry in iter_api_entries(lines) if entry.heading.valid]
    audit = Audit(entries=len(entries))
    root_owner = ""
    owners: dict[int, str] = {}
    for entry in entries:
        if entry.heading.level == 2:
            root_owner = entry.heading.name if entry.heading.kind in {"class", "unit"} else ""
        owners[entry.start] = root_owner if entry.heading.level > 2 else ""

    for api in reversed(entries):
        description_index, tag_index, access, existing_tags = _entry_metadata(
            lines, api.start, api.end
        )
        if description_index is None:
            continue
        old_description = lines[description_index].strip()
        entry = EntryContext(
            name=api.heading.name,
            signature=api.heading.signature,
            kind=api.heading.kind,
            scope=_scope_for_page(page),
            page=page,
            summary=old_description,
            access=access,
            owner=owners.get(api.start, ""),
            tags=existing_tags,
        )
        sources = source_catalog.match(entry)
        if sources:
            audit.source_matches += 1
        if len(sources) > 1:
            audit.ambiguous_sources += 1
        evidence = sources
        normalized_summary = strip_terminal_punctuation(old_description)
        if (
            normalized_summary in GENERIC_SUMMARIES
            or PROHIBITED_PROCESS_RE.search(normalized_summary)
        ):
            evidence = source_catalog.describe(sources)
        new_description = improve_description(entry, evidence)
        entry_for_tags = replace(entry, summary=new_description)
        new_tags = tuple(derive_tags(entry_for_tags, page_context))

        if new_description != old_description:
            lines[description_index] = new_description
            audit.changed_descriptions += 1
        if new_tags != existing_tags:
            rendered = f"<!-- tags: {' '.join(new_tags)} -->"
            if tag_index is not None:
                lines[tag_index] = rendered
            else:
                insert_at = description_index + 1
                if insert_at < len(lines) and lines[insert_at].strip() == "":
                    insert_at += 1
                    lines[insert_at:insert_at] = [rendered, ""]
                else:
                    lines[insert_at:insert_at] = ["", rendered, ""]
            audit.changed_tags += 1

        if new_description != old_description or new_tags != existing_tags:
            audit.changes.append(
                AuditChange(
                    page=page,
                    name=api.heading.name,
                    kind=api.heading.kind,
                    old_description=old_description,
                    new_description=new_description,
                    old_tags=existing_tags,
                    new_tags=new_tags,
                    sources=tuple(
                        f"{record.corpus}:{record.page_id}" for record in sources
                    ),
                )
            )

    result = "\n".join(lines)
    if had_final_newline:
        result += "\n"
    return result, audit


def _write_report(path: Path, audit: Audit) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.writer(handle, delimiter="\t", lineterminator="\n")
        writer.writerow(
            (
                "page",
                "name",
                "kind",
                "old_description",
                "new_description",
                "old_tags",
                "new_tags",
                "sources",
            )
        )
        for item in audit.changes:
            writer.writerow(
                (
                    item.page,
                    item.name,
                    item.kind,
                    item.old_description,
                    item.new_description,
                    " ".join(item.old_tags),
                    " ".join(item.new_tags),
                    " ".join(item.sources),
                )
            )


def _selected(page: str, scopes: set[str], prefixes: tuple[str, ...]) -> bool:
    if scopes and _scope_for_page(page) not in scopes:
        return False
    return not prefixes or page.startswith(prefixes)


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group(required=True)
    mode.add_argument("--audit", action="store_true", help="report changes only")
    mode.add_argument("--apply", action="store_true", help="write proposed changes")
    parser.add_argument("--root", type=Path, required=True, help="codegen Markdown root")
    parser.add_argument(
        "--docs-root", type=Path, required=True, help="cached tmp/docs root"
    )
    parser.add_argument("--report", type=Path, help="optional TSV audit report")
    parser.add_argument("--scope", action="append", default=[])
    parser.add_argument("--prefix", action="append", default=[])
    args = parser.parse_args(argv)

    catalog = SourceCatalog.from_docs_root(args.docs_root)
    aggregate = Audit()
    changed_files = 0
    for md in sorted(args.root.rglob("*.md")):
        page = md.relative_to(args.root).as_posix()
        if not _selected(page, set(args.scope), tuple(args.prefix)):
            continue
        before_stat = md.stat()
        original = md.read_text(encoding="utf-8")
        enriched, audit = enrich_markdown(original, page, catalog)
        aggregate.merge(audit)
        if enriched == original:
            continue
        changed_files += 1
        if args.apply:
            after_stat = md.stat()
            if (
                after_stat.st_mtime_ns != before_stat.st_mtime_ns
                or after_stat.st_size != before_stat.st_size
            ):
                raise RuntimeError(f"target changed while processing: {md}")
            md.write_text(enriched, encoding="utf-8", newline="\n")

    if args.report:
        _write_report(args.report, aggregate)
    print(
        "entries={entries} source_matches={source_matches} "
        "ambiguous_sources={ambiguous_sources} changed_files={changed_files} "
        "changed_descriptions={changed_descriptions} changed_tags={changed_tags}".format(
            entries=aggregate.entries,
            source_matches=aggregate.source_matches,
            ambiguous_sources=aggregate.ambiguous_sources,
            changed_files=changed_files,
            changed_descriptions=aggregate.changed_descriptions,
            changed_tags=aggregate.changed_tags,
        )
    )
    return 0


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