#!/usr/bin/env python3
"""Query bundled TSL API reference data."""
import argparse
import re
import sys
import unicodedata
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 value_domains import (  # noqa: E402
    ApiHint,
    ValueDomainCatalog,
    domains_path_for_index,
)

TOP_LEVEL_RE = re.compile(r"^##(?!#)\s+`(.+?)`\s*$")
PLAIN_H2_RE = re.compile(r"^##(?!#)\s+")
BARE_ENTRY_RE = re.compile(r"^(#{3,4})(?!#)\s+`(.+?)`\s*$")
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+")
H3_RE = re.compile(r"^###(?!#)\s+")
DECLARATION_RE = re.compile(r"^声明：(.*?)\s*$")
TOP_LEVEL_TYPES = {"function", "class", "unit"}
FENCE_RE = re.compile(r"^\s*(```|~~~)")
CLASS_MEMBER_LABELS = {
    "function",
    "class function",
    "property",
    "field",
    "static field",
    "const",
    "static const",
}
UNIT_DIRECT_LABELS = {"function", "var", "const", "class"}
SKILL_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_TSV = SKILL_ROOT / "data" / "function_index.tsv"
DEFAULT_VALUE_DOMAINS = SKILL_ROOT / "data" / "value_domains.json"
DEFAULT_CODEGEN_ROOT = SKILL_ROOT / "references" / "codegen"
REQUIRED_COLUMNS = (
    "name",
    "scope",
    "module",
    "signature",
    "page",
    "anchor",
    "tags",
    "summary",
)
OPTIONAL_COLUMNS = (
    "kind",
    "binding",
    "visibility",
    "owner",
    "qualified_name",
)
SEARCH_FIELD_WEIGHTS = (
    ("name", 120),
    ("qualified_name", 120),
    ("signature", 90),
    ("tags", 70),
    ("summary", 50),
    ("module", 30),
    ("scope", 30),
    ("owner", 25),
    ("kind", 20),
    ("binding", 20),
    ("visibility", 20),
)
# Complete coverage in curated semantic text must outrank incidental short
# aliases in composite identifiers, such as "index" and "max".
SEMANTIC_COVERAGE_BONUSES = (
    ("tags", 240),
    ("summary", 200),
)

HELP_EPILOG = """\
检索分两步，--kw 的摘要不能直接作为 API 事实：

  1. 先按关键词取候选（只有摘要表格，不含条目正文）
     lookup.py --kw 数组 去重
  2. 再按候选里的 qualified_name 取回条目正文
     lookup.py --name arrDropDuplicate

已知确切名称时可以直接用 --name，跳过第 1 步。
只查询一个 API scope 时使用 --scope；可用值从当前 function_index.tsv 读取，
不存在的 scope 会列出当前索引实际提供的值并返回 2。

结果：
  --kw   输出候选摘要表；参数取值域可补充召回相关 API，选定后仍须运行 --name
  --name 输出完整条目正文和 scope/module、page#anchor 来源标记；
         简单成员名可能返回多个 owner，qualified_name 可消歧；重载会全部返回；
         若该 API 有参数取值域，同时显示动态目录解析器和已核实值边界

退出码：
  0  查询完成；--name 无匹配和 --kw 零候选也通过正文提示表达
  1  function_index.tsv/value_domains.json 缺失、格式错误，或候选指向的条目正文找不到
  2  参数不合法（缺少动作、空值、--limit 小于 1、使用短选项或缩写）
"""


def non_empty(value):
    if not value.strip():
        raise argparse.ArgumentTypeError("must not be empty")
    return value


def load_rows(tsv_path):
    lines = tsv_path.read_text(encoding="utf-8").splitlines()
    if not lines:
        return []
    header = lines[0].split("\t")
    missing = [column for column in REQUIRED_COLUMNS if column not in header]
    if missing:
        raise ValueError(f"missing required columns: {', '.join(missing)}")
    if len(header) != len(set(header)):
        raise ValueError("duplicate column names")
    rows = []
    for line_number, line in enumerate(lines[1:], start=2):
        if not line.strip():
            continue
        values = line.split("\t")
        if len(values) != len(header):
            raise ValueError(
                f"line {line_number} has {len(values)} columns; "
                f"expected {len(header)}"
            )
        row = dict(zip(header, values))
        for column in OPTIONAL_COLUMNS:
            row.setdefault(column, "")
        rows.append(row)
    return rows


def normalize(value):
    return unicodedata.normalize("NFKC", value).casefold().strip()


def field_match_score(value, variant, weight):
    normalized = normalize(value)
    if not normalized or not variant:
        return 0
    if normalized == variant:
        return weight + 20
    if variant in normalized:
        return weight
    return 0


def term_score(row, term):
    best = 0
    for field, weight in SEARCH_FIELD_WEIGHTS:
        score = field_match_score(row.get(field, ""), term, weight)
        if score:
            best = max(best, score)
    return best


def field_covers_terms(row, field, terms):
    value = normalize(row.get(field, ""))
    return bool(value) and all(term in value for term in terms)


def keyword_sort_key(row):
    return (
        normalize(row.get("qualified_name", "") or row.get("name", "")),
        normalize(row.get("signature", "")),
        normalize(row.get("scope", "")),
        row.get("page", ""),
        row.get("anchor", ""),
    )


def search_exact(rows, name):
    key = name.casefold()
    return [
        row
        for row in rows
        if key
        in {
            row.get("name", "").casefold(),
            row.get("qualified_name", "").casefold(),
        }
    ]


def search_keyword(rows, terms):
    normalized_terms = [normalize(term) for term in terms]
    ranked = []
    for row in rows:
        scores = [term_score(row, term) for term in normalized_terms]
        if scores and all(scores):
            coverage_bonus = sum(
                bonus
                for field, bonus in SEMANTIC_COVERAGE_BONUSES
                if field_covers_terms(row, field, normalized_terms)
            )
            ranked.append((sum(scores) + coverage_bonus, row))
    ranked.sort(key=lambda item: (-item[0], *keyword_sort_key(item[1])))
    return [row for _, row in ranked]


def slug(text):
    return re.sub(r"[^a-z0-9_]", "", text.casefold())


def fence_flags(lines):
    flags = []
    in_fence = False
    marker = ""
    for line in lines:
        stripped = line.lstrip()
        if not in_fence and FENCE_RE.match(line):
            marker = stripped[:3]
            flags.append(True)
            in_fence = True
            continue
        flags.append(in_fence)
        if in_fence and stripped.startswith(marker):
            in_fence = False
            marker = ""
    return flags


def top_level_header_end(lines, start, flags):
    for index in range(start + 1, len(lines)):
        if flags[index]:
            continue
        if HEADING_RE.match(lines[index]):
            return index
    return len(lines)


def declaration_type(lines, start, end, flags):
    for index in range(start + 1, end):
        if flags[index]:
            continue
        stripped = lines[index].strip()
        if not stripped:
            continue
        match = DECLARATION_RE.fullmatch(stripped)
        return match.group(1).strip().casefold() if match else ""
    return ""


def markdown_heading(
    lines, index, root_kind, unit_class_open, flags
):
    """Return level, visible title, signature, and anchor base for one API."""
    bare = BARE_ENTRY_RE.match(lines[index])
    if not bare:
        return None
    level = len(bare.group(1))
    signature = bare.group(2)
    end = top_level_header_end(lines, index, flags)
    declaration = declaration_type(lines, index, end, flags)
    if declaration == "static function":
        return None
    if root_kind == "class":
        if level != 3 or declaration not in CLASS_MEMBER_LABELS:
            return None
    elif root_kind == "unit":
        if level == 3 and declaration not in UNIT_DIRECT_LABELS:
            return None
        if level == 4 and (
            not unit_class_open or declaration not in CLASS_MEMBER_LABELS
        ):
            return None
        if level not in {3, 4}:
            return None
    else:
        return None
    return level, signature, signature, slug(signature)


def iter_markdown_api_headings(lines):
    flags = fence_flags(lines)
    root_kind = ""
    unit_class_open = False
    for index, line in enumerate(lines):
        if flags[index]:
            continue
        if PLAIN_H2_RE.match(line):
            root_kind = ""
            unit_class_open = False
            top_level = TOP_LEVEL_RE.match(line)
            if not top_level:
                continue
            signature = top_level.group(1)
            end = top_level_header_end(lines, index, flags)
            root_kind = declaration_type(lines, index, end, flags)
            if root_kind:
                yield index, (
                    2,
                    signature,
                    signature,
                    slug(signature.split("(", 1)[0]),
                )
            continue
        if not root_kind:
            continue
        if root_kind == "unit" and H3_RE.match(line):
            unit_class_open = False
        bare = BARE_ENTRY_RE.match(line)
        if root_kind == "unit" and bare and len(bare.group(1)) == 3:
            end = top_level_header_end(lines, index, flags)
            unit_class_open = (
                declaration_type(lines, index, end, flags) == "class"
            )
        heading = markdown_heading(
            lines, index, root_kind, unit_class_open, flags
        )
        if heading:
            yield index, heading


def slice_entry(codegen_root, page, signature, anchor):
    md = codegen_root / page
    if not md.is_file():
        return ""
    lines = md.read_text(encoding="utf-8").splitlines()
    start = None
    seen_anchors = {}
    for idx, heading in iter_markdown_api_headings(lines):
        level, _, entry_signature, base_anchor = heading
        occurrence = seen_anchors.get(base_anchor, 0)
        seen_anchors[base_anchor] = occurrence + 1
        entry_anchor = base_anchor if occurrence == 0 else f"{base_anchor}-{occurrence}"
        if entry_anchor == anchor and entry_signature == signature:
            start = idx
            start_level = level
            break
    if start is None:
        return ""

    end = len(lines)
    flags = fence_flags(lines)
    for idx in range(start + 1, len(lines)):
        if flags[idx]:
            continue
        heading_match = HEADING_RE.match(lines[idx])
        if heading_match and len(heading_match.group(1)) <= start_level:
            end = idx
            break
    block = lines[start:end]
    while block and not block[-1].strip():
        block.pop()
    return "\n".join(block)


def declaration_term(row):
    kind = row.get("kind", "") or "function"
    binding = row.get("binding", "")
    mapped = {
        ("method", "instance"): "function",
        ("method", "class"): "class function",
        ("field", "static"): "static field",
        ("constant", "static"): "static const",
    }.get((kind, binding))
    if mapped:
        return mapped
    return {
        "constant": "const",
        "variable": "var",
    }.get(kind, kind)


def format_row(row):
    return "\t".join(
        [
            row.get("qualified_name", "") or row.get("name", ""),
            declaration_term(row),
            row.get("signature", ""),
            f"{row.get('page', '')}#{row.get('anchor', '')}",
            row.get("summary", ""),
        ]
    )


def row_identity(row):
    return (
        normalize(row.get("qualified_name", "") or row.get("name", "")),
        normalize(row.get("signature", "")),
        normalize(row.get("scope", "")),
        row.get("page", ""),
        row.get("anchor", ""),
    )


def signature_parameters(signature):
    match = re.search(r"\((.*)\)", signature)
    if not match:
        return set()
    parameters = set()
    for raw_parameter in match.group(1).split(","):
        parameter = raw_parameter.strip().strip("[]").strip()
        parameter = re.sub(r"^(?:const|out|var)\s+", "", parameter, flags=re.I)
        parameter = parameter.split("=", maxsplit=1)[0].strip()
        parameter = parameter.split(":", maxsplit=1)[0].strip()
        if parameter:
            parameters.add(normalize(parameter))
    return parameters


def rows_for_hint(rows, hint):
    api = normalize(hint.api)
    parameter = normalize(hint.parameter)
    return [
        row
        for row in rows
        if api
        in {
            normalize(row.get("name", "")),
            normalize(row.get("qualified_name", "")),
        }
        and normalize(row.get("scope", "")) == normalize(hint.scope)
        and normalize(row.get("module", "")) == normalize(hint.module)
        and parameter in signature_parameters(row.get("signature", ""))
    ]


def merge_keyword_matches(rows, normal_matches, hints):
    merged = []
    annotations = {}
    seen = set()
    for hint in hints:
        for row in rows_for_hint(rows, hint):
            key = row_identity(row)
            annotations.setdefault(key, []).append(hint)
            if key not in seen:
                seen.add(key)
                merged.append(row)
    for row in normal_matches:
        key = row_identity(row)
        if key not in seen:
            seen.add(key)
            merged.append(row)
    return merged, annotations


def format_api_hint(hint: ApiHint) -> str:
    parts = [f"  参数取值域：{hint.target}", hint.summary()]
    verification = hint.verification_text()
    if verification:
        parts.append(verification)
    if hint.sources:
        parts.append(f"来源：{', '.join(hint.sources)}")
    return " · ".join(parts)


def top_value_domain_matches(matches, *, score_window=50):
    matches = tuple(matches)
    if not matches:
        return ()
    minimum_score = matches[0].score - score_window
    return tuple(match for match in matches if match.score >= minimum_score)


def value_domain_keyword_matches(catalog, terms):
    if len(terms) == 1 and re.fullmatch(r"\s*-?\d{1,2}\s*", terms[0]):
        return ()
    return top_value_domain_matches(catalog.search(terms))


def codegen_root_for_tsv(tsv_path):
    if tsv_path.parent.name == "data":
        candidate = tsv_path.parent.parent / "references" / "codegen"
        if candidate.is_dir():
            return candidate
    if tsv_path.parent.name == "codegen":
        return tsv_path.parent
    candidate = tsv_path.parent / "references" / "codegen"
    if candidate.is_dir():
        return candidate
    return DEFAULT_CODEGEN_ROOT


def main(argv=None):
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(
        description="检索 TSL API 参考数据，取回 API 的签名、参数与返回值事实。",
        epilog=HELP_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        # 只接受完整拼写的长选项：不提供 -h 短选项，也不接受 --na / --li 这类
        # 缩写。缩写打错时会静默命中另一个选项并以 rc=0 返回，看不出写错了。
        add_help=False,
        allow_abbrev=False,
    )
    parser.add_argument(
        "--help",
        action="help",
        help="显示本帮助并退出（不提供 -h 短选项）",
    )
    group = parser.add_argument_group("动作（必选其一）").add_mutually_exclusive_group(
        required=True
    )
    group.add_argument(
        "--name",
        type=non_empty,
        help="按 API 名称精确取回条目正文；同时匹配简单名称与 qualified_name，"
        "大小写不敏感。简单成员名会返回所有 owner 下的同名 API，"
        "用完整限定名（如 DemoUnit.Document.Save）缩小到指定 class/unit。"
        "这是唯一的事实来源",
    )
    group.add_argument(
        "--kw",
        nargs="+",
        type=non_empty,
        help="按关键词检索候选清单：多个查询词是 AND 关系；"
        "逐词在 name、signature、tags、summary、module、scope、kind、binding、"
        "visibility、owner 和 qualified_name 中做字面子串匹配，按字段加权并稳定排序。"
        "同义词由索引的 tags 字段提供。"
        "大小写不敏感，只返回摘要表格，不含条目正文",
    )
    parser.add_argument(
        "--tsv",
        metavar="PATH",
        help="显式指定 function_index.tsv；默认只使用本 skill 内置的 "
        "data/function_index.tsv，不从工作目录回退查找旧索引",
    )
    parser.add_argument(
        "--value-domains",
        metavar="PATH",
        help="显式指定 value_domains.json；默认使用所选索引同目录的数据，"
        "自定义索引旁没有该文件时不加载参数取值域",
    )
    parser.add_argument(
        "--scope",
        type=non_empty,
        metavar="SCOPE",
        help="只查询指定 scope，大小写不敏感；作用于 --name 和 --kw。"
        "可用值从所选 function_index.tsv 读取并在加载后校验",
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=50,
        metavar="N",
        help="--kw 输出的候选条数上限，取值 >= 1（默认 %(default)s）；"
        "--name 取回正文时不受该上限影响",
    )
    args = parser.parse_args(argv)
    if args.limit < 1:
        parser.error("--limit must be >= 1")

    tsv_path = Path(args.tsv) if args.tsv else DEFAULT_TSV
    if not tsv_path.is_file():
        print(
            "ERROR: function_index.tsv not found; reinstall tsl-api-reference "
            "or pass --tsv PATH",
            file=sys.stderr,
        )
        return 1

    domain_path = (
        Path(args.value_domains)
        if args.value_domains
        else domains_path_for_index(
            tsv_path,
            default_index=DEFAULT_TSV,
            default_domains=DEFAULT_VALUE_DOMAINS,
        )
    )
    try:
        rows = load_rows(tsv_path)
    except (OSError, UnicodeError, ValueError) as error:
        print(f"ERROR: failed to load {tsv_path}: {error}", file=sys.stderr)
        return 1
    try:
        catalog = ValueDomainCatalog.load(domain_path) if domain_path else None
    except (OSError, UnicodeError, ValueError) as error:
        print(f"ERROR: failed to load {domain_path}: {error}", file=sys.stderr)
        return 1
    if not rows:
        print(
            f"ERROR: {tsv_path} has no entries; reinstall tsl-api-reference "
            "or pass --tsv PATH",
            file=sys.stderr,
        )
        return 1
    if args.scope is not None:
        requested_scope = normalize(args.scope)
        available_scopes = sorted(
            {row["scope"] for row in rows if row.get("scope")},
            key=normalize,
        )
        if requested_scope not in {normalize(scope) for scope in available_scopes}:
            print(
                f"ERROR: unknown scope {args.scope!r}; available scopes: "
                f"{', '.join(available_scopes)}",
                file=sys.stderr,
            )
            return 2
        rows = [row for row in rows if normalize(row["scope"]) == requested_scope]
    codegen_root = codegen_root_for_tsv(tsv_path)

    if args.name is not None:
        matches = search_exact(rows, args.name)
        if not matches:
            print(f"No TSL API named '{args.name}'. Try: --kw <中文关键词>")
            return 0
        entries = []
        for row in matches:
            body = slice_entry(
                codegen_root, row["page"], row["signature"], row["anchor"]
            )
            if not body:
                print(
                    f"ERROR: entry body not found: "
                    f"{row['page']}#{row['anchor']} ({row['signature']})",
                    file=sys.stderr,
                )
                return 1
            entries.append((row, body))
        for row, body in entries:
            print(body)
            if catalog:
                domain_text = catalog.describe_api(
                    row.get("qualified_name", "") or row.get("name", ""),
                    scope=row.get("scope", ""),
                    module=row.get("module", ""),
                    parameters=signature_parameters(row.get("signature", "")),
                )
                if domain_text:
                    print(f"\n{domain_text}", end="")
            print(
                f"\n<!-- {row['scope']}/{row['module']} · "
                f"{row['page']}#{row['anchor']} -->\n"
            )
        return 0

    normal_matches = search_keyword(rows, args.kw)
    hints = (
        catalog.api_hints(value_domain_keyword_matches(catalog, args.kw))
        if catalog
        else ()
    )
    matches, annotations = merge_keyword_matches(rows, normal_matches, hints)
    print(f"# {len(matches)} matches for: {' '.join(args.kw)}")
    print("qualified_name\tdeclaration\tsignature\tpage#anchor\tsummary")
    for row in matches[: args.limit]:
        print(format_row(row))
        for hint in annotations.get(row_identity(row), ()):
            print(format_api_hint(hint))
    if len(matches) > args.limit:
        print(f"... {len(matches) - args.limit} more; refine keywords or raise --limit")
    if not matches:
        print("No matching API candidates; do not infer an API name.")
    return 0


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