#!/usr/bin/env python3
"""Lint TSL codegen API Markdown against the house standard.

The standard lives in tools/tsl-codegen/STANDARD.md.
Each typed H2 starts one top-level declaration; typed H3/H4 headings describe
class or unit members. Rules split into hard errors (CI-blocking) and soft
warnings (style convergence over the ~12k existing entries).

Hard errors include incomplete descriptions/metadata/parameter tables and
invalid class/unit API headings, visibility, or child-heading levels.

Soft warnings:
  - optional-parameter wording not starting with `可选。`
  - malformed / empty `<!-- tags: ... -->` line

Exit status: 1 if any error (or, with --strict, any warning); else 0.

Usage:
    python lint.py --file path/to/page.md
    python lint.py --dir path/to/codegen-dir
    python lint.py --dir path/to/codegen-dir --strict
"""
import argparse
import re
import sys
from pathlib import Path

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

RETURN_LINE_RE = re.compile(r"^返回[:：]")
RETURN_RE = re.compile(r"^返回[:：]\s*\S")
TYPE_LINE_RE = re.compile(r"^类型[:：]\s*(.*?)\s*$", re.IGNORECASE)
TYPE_RE = re.compile(r"^类型[:：]\s*\S")
VALUE_LINE_RE = re.compile(r"^值[:：]")
VALUE_RE = re.compile(r"^值[:：]\s*(?:`[^`]+`|[^`\s])")
ACCESS_RE = re.compile(r"^访问[:：]\s*(read|write|read\s*/\s*write)\s*$")
VISIBILITY_RE = re.compile(r"^可见性[:：]\s*`?(public|protected|private)`?\s*$")
BASE_RE = re.compile(r"^父类[:：]")
MODIFIERS_RE = re.compile(r"^修饰符[:：]")
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->\s*$")
FENCE_RE = re.compile(r"^(```|~~~)")
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+(.+?)\s*$")
VALUE_HEADING_RE = re.compile(r"^`.+?`\s+取值$")
OPTIONAL_HINT_RE = re.compile(r"可选|可省略|省略")
# Split a table row on unescaped pipes so `nil\|array` stays one cell.
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
SEP_CELL_RE = re.compile(r"^:?-+:?$")

PARAM_HEADER = ["参数", "类型", "说明"]


def scan_body(lines, start, end):
    """Return [(lineno, raw, in_fence)] for the entry body (excludes the
    signature line). Fence delimiter lines are marked in_fence so callers
    skip both the fences and their contents."""
    body = []
    in_fence = False
    for idx in range(start + 1, end):
        raw = lines[idx]
        if FENCE_RE.match(raw.strip()):
            body.append((idx, raw, True))
            in_fence = not in_fence
            continue
        body.append((idx, raw, in_fence))
    return body


def has_params(sig):
    """True if the signature's parentheses hold anything (`...` counts)."""
    left = sig.find("(")
    right = sig.rfind(")")
    if left == -1 or right == -1 or right < left:
        return False
    return bool(sig[left + 1:right].strip())


def signature_params(sig):
    """Return the parameter names carried by a normalized API signature."""
    left = sig.find("(")
    right = sig.rfind(")")
    if left == -1 or right == -1 or right < left:
        return []
    raw = sig[left + 1:right].strip()
    return [item.strip() for item in raw.split(",")] if raw else []


def split_row(text):
    """Split a markdown table row into trimmed cells, honoring `\\|` escapes."""
    parts = CELL_SPLIT_RE.split(text.strip())
    if parts and parts[0].strip() == "":
        parts = parts[1:]
    if parts and parts[-1].strip() == "":
        parts = parts[:-1]
    return [p.strip() for p in parts]


def is_separator_row(cells):
    return bool(cells) and all(SEP_CELL_RE.match(c) for c in cells)


def find_table(body):
    """Return (header_lineno, header_cells, [(lineno, cells)] data_rows) for the
    first pipe table in the body, or None. Skips fenced content."""
    collected = []
    for lineno, raw, in_fence in body:
        if in_fence:
            continue
        stripped = raw.strip()
        if stripped.startswith("|"):
            collected.append((lineno, stripped))
        elif collected:
            break  # blank/prose line ends the table
    if not collected:
        return None
    header_lineno, header_text = collected[0]
    header_cells = split_row(header_text)
    data = []
    for lineno, text in collected[1:]:
        cells = split_row(text)
        if is_separator_row(cells):
            continue
        data.append((lineno, cells))
    return header_lineno, header_cells, data


def find_description(body):
    """Return (found, lineno_of_offending_line). found is True when the first
    content line after the signature is prose. When False the lineno points at
    the table/heading/return line that showed up where a description belongs
    (or None if the entry is empty)."""
    for lineno, raw, in_fence in body:
        stripped = raw.strip()
        if not stripped or in_fence:
            continue
        if DECLARATION_LINE_RE.fullmatch(stripped):
            continue
        if (
            stripped.startswith("<!--")
            or stripped.startswith("声明：")
            or stripped.startswith("|")
            or stripped.startswith("#")
            or RETURN_LINE_RE.match(stripped)
            or TYPE_LINE_RE.match(stripped)
            or VALUE_LINE_RE.match(stripped)
            or ACCESS_RE.match(stripped)
            or VISIBILITY_RE.match(stripped)
            or BASE_RE.match(stripped)
            or MODIFIERS_RE.match(stripped)
        ):
            return False, lineno
        return True, lineno
    return False, None


def check_entry(
    md_display,
    lines,
    start,
    end,
    sig,
    findings,
    *,
    returns_required=True,
    visibility_required=False,
    validate_parameter_rows=False,
):
    entry_line = start + 1  # 1-based signature line, used for entry-level errors
    body = scan_body(lines, start, end)

    # description ----------------------------------------------------------
    found, off_lineno = find_description(body)
    if not found:
        line = (off_lineno + 1) if off_lineno is not None else entry_line
        findings.append((md_display, line, "error", "description",
                         f"`{sig}` 缺少描述（签名后第一行须为非空描述）"))

    # return ---------------------------------------------------------------
    has_return = any(
        RETURN_RE.match(raw.strip())
        for _, raw, in_fence in body if not in_fence
    )
    if returns_required and not has_return:
        findings.append((md_display, entry_line, "error", "return",
                         f"`{sig}` 缺少 `返回：类型` 行"))

    visibilities = []
    for lineno, raw, in_fence in body:
        if in_fence:
            continue
        match = VISIBILITY_RE.match(raw.strip())
        if match:
            visibilities.append((lineno, match.group(1)))
    if visibility_required and not visibilities:
        findings.append(
            (
                md_display,
                entry_line,
                "error",
                "visibility",
                f"`{sig}` 缺少 `可见性：public|protected` 行",
            )
        )
    for lineno, visibility in visibilities:
        if visibility == "private":
            findings.append(
                (
                    md_display,
                    lineno + 1,
                    "error",
                    "visibility",
                    "private API 不得进入文档",
                )
            )

    # parameter table ------------------------------------------------------
    table = find_table(body)
    wants_params = has_params(sig)
    if wants_params and table is None:
        findings.append((md_display, entry_line, "error", "param-table",
                         f"`{sig}` 有参数但缺少参数表"))
    elif not wants_params and table is not None:
        header_lineno = table[0]
        findings.append((md_display, header_lineno + 1, "error", "param-table",
                         f"`{sig}` 无参数却存在参数表"))
    elif table is not None:
        header_lineno, header_cells, data_rows = table
        if header_cells != PARAM_HEADER:
            findings.append((md_display, header_lineno + 1, "error",
                             "param-header",
                             f"参数表表头须为 {' / '.join(PARAM_HEADER)}，"
                             f"实为 {' / '.join(header_cells) or '(空)'}"))
        elif validate_parameter_rows:
            actual_names = []
            rows_valid = True
            for lineno, cells in data_rows:
                if len(cells) != 3 or any(not cell.strip() for cell in cells):
                    rows_valid = False
                    findings.append(
                        (
                            md_display,
                            lineno + 1,
                            "error",
                            "param-row",
                            "参数表每行都必须包含非空的参数名、类型和说明",
                        )
                    )
                    continue
                name_cell = cells[0]
                if name_cell.startswith("`") and name_cell.endswith("`"):
                    name_cell = name_cell[1:-1]
                actual_names.append(name_cell.strip())
            expected_names = signature_params(sig)
            if rows_valid and [name.casefold() for name in actual_names] != [
                name.casefold() for name in expected_names
            ]:
                findings.append(
                    (
                        md_display,
                        header_lineno + 1,
                        "error",
                        "param-names",
                        f"参数表名称/顺序必须与 `{sig}` 一致",
                    )
                )
        # soft: optional-parameter wording
        for lineno, cells in data_rows:
            if len(cells) < 3:
                continue
            desc = cells[2]
            if OPTIONAL_HINT_RE.search(desc) and not desc.startswith("可选。"):
                findings.append((md_display, lineno + 1, "warning", "optional",
                                 "可选参数说明建议以 `可选。` 开头"))

    # soft: tags line ------------------------------------------------------
    for lineno, raw, in_fence in body:
        if in_fence:
            continue
        m = TAGS_RE.match(raw.strip())
        if m and not m.group(1).split():
            findings.append((md_display, lineno + 1, "warning", "tags",
                             "空的 tags 行；填入关键词或删除"))


def check_api_subheadings(md_display, lines, entry, findings):
    """Enforce the fixed API child-heading levels and labels."""
    heading = entry.heading

    allowed_titles = set()
    if heading.level == 2 and heading.root_kind == "function":
        allowed_titles.add("示例")
        allow_values = False
    elif heading.kind in {"function", "method"}:
        allowed_titles.add("示例")
        allow_values = True
    elif heading.kind == "property":
        allow_values = True
    else:
        allow_values = False
    expected_level = heading.level + 1

    for lineno, raw, in_fence in scan_body(lines, entry.start, entry.end):
        if in_fence:
            continue
        match = HEADING_RE.match(raw.strip())
        if not match:
            continue
        level = len(match.group(1))
        title = match.group(2)
        title_allowed = title in allowed_titles or bool(
            allow_values and VALUE_HEADING_RE.match(title)
        )
        if level == expected_level and title_allowed:
            continue
        if not allow_values and not allowed_titles:
            message = f"{heading.kind} `{heading.signature}` 不得包含子标题"
        else:
            message = (
                f"{heading.kind} `{heading.signature}` 的子标题必须位于 "
                f"H{expected_level}，且只能使用参数取值"
                + ("或示例" if "示例" in allowed_titles else "")
            )
        findings.append(
            (md_display, lineno + 1, "error", "subheading", message)
        )


def lint_file(md, root, findings):
    try:
        display = md.relative_to(root).as_posix()
    except ValueError:
        display = str(md)
    lines = md.read_text(encoding="utf-8").splitlines()
    for entry in iter_api_entries(lines):
        heading = entry.heading
        if not heading.valid:
            findings.append(
                (
                    display,
                    entry.start + 1,
                    "error",
                    "heading",
                    heading.error,
                )
            )
            body = scan_body(lines, entry.start, entry.end)
            for lineno, raw, in_fence in body:
                if in_fence:
                    continue
                visibility = VISIBILITY_RE.match(raw.strip())
                if visibility and visibility.group(1) == "private":
                    findings.append(
                        (
                            display,
                            lineno + 1,
                            "error",
                            "visibility",
                            "private API 不得进入文档",
                        )
                    )
            continue

        kind = heading.kind
        check_api_subheadings(display, lines, entry, findings)
        if kind == "function":
            check_entry(
                display,
                lines,
                entry.start,
                entry.end,
                heading.signature,
                findings,
                validate_parameter_rows=heading.root_kind != "function",
            )
            continue
        if kind == "method":
            check_entry(
                display,
                lines,
                entry.start,
                entry.end,
                heading.signature,
                findings,
                returns_required=False,
                visibility_required=True,
                validate_parameter_rows=True,
            )
            continue
        if kind == "property":
            check_entry(
                display,
                lines,
                entry.start,
                entry.end,
                heading.signature,
                findings,
                returns_required=False,
                visibility_required=True,
                validate_parameter_rows=True,
            )
            body = scan_body(lines, entry.start, entry.end)
            empty_types = [
                lineno
                for lineno, raw, fenced in body
                if not fenced
                and (match := TYPE_LINE_RE.fullmatch(raw.strip()))
                and not match.group(1).strip()
            ]
            for lineno in empty_types:
                findings.append(
                    (
                        display,
                        lineno + 1,
                        "error",
                        "type",
                        f"`{heading.signature}` 的类型不能为空",
                    )
                )
            if not any(
                ACCESS_RE.match(raw.strip())
                for _, raw, fenced in body
                if not fenced
            ):
                findings.append(
                    (
                        display,
                        entry.start + 1,
                        "error",
                        "access",
                        f"`{heading.signature}` 缺少访问方式",
                    )
                )
            continue

        body = scan_body(lines, entry.start, entry.end)
        found, offending = find_description(body)
        if not found:
            line = offending + 1 if offending is not None else entry.start + 1
            findings.append(
                (
                    display,
                    line,
                    "error",
                    "description",
                    f"`{heading.signature}` 缺少描述",
                )
            )

        if kind in {"class", "unit"}:
            continue

        visibility_required = heading.binding != "unit"
        visibilities = [
            (lineno, match.group(1))
            for lineno, raw, fenced in body
            if not fenced and (match := VISIBILITY_RE.match(raw.strip()))
        ]
        if visibility_required and not visibilities:
            findings.append(
                (
                    display,
                    entry.start + 1,
                    "error",
                    "visibility",
                    f"`{heading.signature}` 缺少 `可见性：public|protected` 行",
                )
            )
        for lineno, visibility in visibilities:
            if visibility == "private":
                findings.append(
                    (
                        display,
                        lineno + 1,
                        "error",
                        "visibility",
                        "private API 不得进入文档",
                    )
                )

        has_type = any(TYPE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
        has_value = any(VALUE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
        if kind in {"field", "variable"} and not has_type:
            findings.append(
                (display, entry.start + 1, "error", "type", f"`{heading.signature}` 缺少类型")
            )
        if kind == "constant" and not has_value:
            findings.append(
                (display, entry.start + 1, "error", "value", f"`{heading.signature}` 缺少值")
            )


def gather_targets(paths, root):
    """Expand paths (files/dirs) into a sorted list of *.md."""
    targets = []
    for p in paths:
        if p.is_dir():
            targets.extend(p.rglob("*.md"))
        elif p.is_file() and p.suffix == ".md":
            targets.append(p)
    return sorted(set(targets))


def main(argv=None):
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(
        description="校验 Markdown 文件或目录",
        add_help=False,
        allow_abbrev=False,
    )
    parser.add_argument(
        "--help",
        action="help",
        help="显示本帮助并退出（不提供 -h 短选项）",
    )
    target_group = parser.add_mutually_exclusive_group(required=True)
    target_group.add_argument("--file", help="要校验的单个 Markdown 文件")
    target_group.add_argument("--dir", help="要递归校验的目录")
    parser.add_argument("--strict", action="store_true",
                        help="treat warnings as failures")
    args = parser.parse_args(argv)

    target = Path(args.file or args.dir)
    if not target.exists():
        print(f"ERROR: target not found: {target}", file=sys.stderr)
        return 2
    if args.file and (not target.is_file() or target.suffix.lower() != ".md"):
        print(f"ERROR: --file requires a Markdown file: {target}", file=sys.stderr)
        return 2
    if args.dir and not target.is_dir():
        print(f"ERROR: --dir requires a directory: {target}", file=sys.stderr)
        return 2

    root = target if target.is_dir() else target.parent
    targets = gather_targets([target], root)
    if not targets:
        print("no markdown targets found", file=sys.stderr)
        return 2

    findings = []
    for md in targets:
        lint_file(md, root, findings)

    findings.sort(key=lambda f: (f[0], f[1], 0 if f[2] == "error" else 1))
    for display, line, level, rule, message in findings:
        print(f"{display}:{line}: {level}: [{rule}] {message}")

    errors = sum(1 for f in findings if f[2] == "error")
    warnings = sum(1 for f in findings if f[2] == "warning")
    print(
        f"\n{len(targets)} files, {errors} error(s), {warnings} warning(s)",
        file=sys.stderr,
    )
    if errors or (args.strict and warnings):
        return 1
    return 0


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