#!/usr/bin/env python3
"""Query the independent Tinysoft table and field dictionary index."""

from __future__ import annotations

import argparse
import csv
import json
import re
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Mapping, Sequence

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
    RelatedTable,
    ValueDomainCatalog,
    ValueMatch,
    domains_path_for_index,
)


SKILL_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_TSV = SKILL_ROOT / "data" / "dictionary_index.tsv"
DEFAULT_LEXICON = SKILL_ROOT / "data" / "dictionary_lexicon.json"
DEFAULT_VALUE_DOMAINS = SKILL_ROOT / "data" / "value_domains.json"
REQUIRED_COLUMNS = (
    "kind",
    "scope",
    "table_id",
    "table_name",
    "table_alias",
    "field_id",
    "field_name",
    "field_alias",
    "data_type",
    "unit",
    "description",
    "extract_method",
    "access_code",
    "api_name",
    "page",
    "tags",
)

HELP_EPILOG = """\
按中文业务含义查询表、数据源、字段或参数取值域：
  dictionary_lookup.py --query "股票现金流指标销售现金比率"
  dictionary_lookup.py --query "市值" --scope fund
  dictionary_lookup.py --query "买一量" --table TradeTable
  dictionary_lookup.py --query "9900700" --field 销售现金比率
  dictionary_lookup.py --query "申万煤炭"

输出第一行是 in-band 状态：
  status: ok         最高分候选唯一
  status: ambiguous  多个同分候选；必须补充 scope、table 或 field 范围
  status: no_match   表/字段/参数取值域均无匹配，不等于 API not found

参数取值域结果会标明值、名称、绑定 API、关联表和来源；动态目录只提供已知样例，
完整名称必须使用结果中的运行时解析器重新确认。

退出码：
  0  查询完成；ok、ambiguous、no_match 均由 status 表达
  1  dictionary_index.tsv、词表或 value_domains.json 缺失、损坏、格式错误
  2  参数不合法
"""


@dataclass(frozen=True)
class QueryItem:
    kind: str
    scope: str
    table_id: str
    table_name: str
    field_id: str
    field_name: str
    field_alias: str
    data_type: str
    unit: str
    description: str
    extract_method: str
    access_code: str
    api_name: str
    page: str
    score: int


@dataclass(frozen=True)
class ValueDomainItem:
    kind: str
    domain_id: str
    domain_label: str
    value: str
    label: str
    parent: str
    parent_label: str
    level: int | None
    valid_from: str
    valid_to: str
    note: str
    related_tables: tuple[str, ...]
    bindings: tuple[str, ...]
    mode: str
    complete: bool
    as_of: str
    sources: tuple[str, ...]
    resolver_text: str


@dataclass(frozen=True)
class ValueDomainSummaryItem:
    kind: str
    domain_id: str
    label: str
    related_tables: tuple[str, ...]
    bindings: tuple[str, ...]
    mode: str
    complete: bool
    as_of: str
    sources: tuple[str, ...]
    resolvers: tuple[str, ...]
    candidates: tuple[str, ...]


@dataclass(frozen=True)
class QueryResult:
    status: str
    items: tuple[QueryItem | ValueDomainItem | ValueDomainSummaryItem, ...]


@dataclass(frozen=True)
class _ScoredRow:
    score: int
    field_match: int
    table_match: int
    row: Mapping[str, str]


def _compact(value: str) -> str:
    normalized = unicodedata.normalize("NFKC", value).casefold()
    return "".join(
        character
        for character in normalized
        if character == "_" or unicodedata.category(character)[0] in {"L", "N"}
    )


def _terms(value: str) -> tuple[str, ...]:
    terms = []
    for item in value.split("|"):
        compact = _compact(item)
        if compact and compact not in terms:
            terms.append(compact)
    return tuple(terms)


def _row_terms(row: Mapping[str, str], name: str, alias: str) -> tuple[str, ...]:
    values = []
    for value in (_compact(row.get(name, "")), *_terms(row.get(alias, ""))):
        if value and value not in values:
            values.append(value)
    return tuple(values)


def validate_lexicon(value: object) -> dict[str, list[str]]:
    if not isinstance(value, dict):
        raise ValueError("dictionary lexicon must be a JSON object")
    result = {}
    for canonical, aliases in value.items():
        if not isinstance(canonical, str) or not canonical.strip():
            raise ValueError("dictionary lexicon contains an empty canonical term")
        if not isinstance(aliases, list) or not all(isinstance(alias, str) and alias.strip() for alias in aliases):
            raise ValueError(f"aliases for {canonical!r} must be non-empty strings")
        result[canonical] = list(dict.fromkeys(aliases))
    return result


def load_lexicon(path: Path) -> dict[str, list[str]]:
    return validate_lexicon(json.loads(path.read_text(encoding="utf-8")))


def load_rows(path: Path) -> list[dict[str, str]]:
    with path.open(encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, delimiter="\t")
        if reader.fieldnames is None:
            raise ValueError("dictionary index has no header")
        missing = [column for column in REQUIRED_COLUMNS if column not in reader.fieldnames]
        if missing:
            raise ValueError(f"dictionary index is missing columns: {', '.join(missing)}")
        if len(reader.fieldnames) != len(set(reader.fieldnames)):
            raise ValueError("dictionary index has duplicate columns")
        return [dict(row) for row in reader]


def _query_variants(query: str, lexicon: Mapping[str, Sequence[str]]) -> tuple[str, ...]:
    base = _compact(query)
    variants = {base}
    for canonical, aliases in lexicon.items():
        canonical_key = _compact(canonical)
        if not canonical_key:
            continue
        for alias in aliases:
            alias_key = _compact(alias)
            if alias_key and alias_key in base:
                variants.add(base.replace(alias_key, canonical_key))
    return tuple(sorted(variant for variant in variants if variant))


def _name_score(query: str, terms: Iterable[str], *, exact: int, contained: int) -> int:
    score = 0
    for term in terms:
        if query == term:
            score = max(score, exact)
        elif term and term in query:
            score = max(score, contained + min(len(term), 100))
    return score


def _score_row(row: Mapping[str, str], query_variants: Sequence[str]) -> _ScoredRow:
    table_terms = _row_terms(row, "table_name", "table_alias")
    field_terms = _row_terms(row, "field_name", "field_alias")
    field_id = _compact(row.get("field_id", ""))
    table_id = _compact(row.get("table_id", ""))
    api_name = _compact(row.get("api_name", ""))
    description = _compact(row.get("description", ""))
    tags = _terms(row.get("tags", ""))
    best = _ScoredRow(0, 0, 0, row)
    for query in query_variants:
        table_match = _name_score(query, table_terms, exact=1000, contained=600)
        field_match = _name_score(query, field_terms, exact=2000, contained=1300)
        if field_id and query == field_id:
            field_match = max(field_match, 3000)
        elif field_id and len(field_id) >= 3 and field_id in query:
            field_match = max(field_match, 2600)
        if table_id and query == table_id:
            table_match = max(table_match, 2400)
        elif table_id and len(table_id) >= 3 and table_id in query:
            table_match = max(table_match, 2100)
        if api_name and query == api_name:
            field_match = max(field_match, 2200)
        elif api_name and api_name in query:
            field_match = max(field_match, 1700 + min(len(api_name), 100))
        fallback = 0
        if not field_match and not table_match:
            if description and query in description:
                fallback = 200
            else:
                fallback = _name_score(query, tags, exact=180, contained=120)
        score = table_match + field_match + fallback
        if score > best.score:
            best = _ScoredRow(score, field_match, table_match, row)
    return best


def _matches_filter(row: Mapping[str, str], value: str, name: str, alias: str) -> bool:
    key = _compact(value)
    return any(key == term or key in term or term in key for term in _row_terms(row, name, alias))


def _item(scored: _ScoredRow) -> QueryItem:
    row = scored.row
    return QueryItem(
        kind=row.get("kind", ""),
        scope=row.get("scope", ""),
        table_id=row.get("table_id", ""),
        table_name=row.get("table_name", ""),
        field_id=row.get("field_id", ""),
        field_name=row.get("field_name", ""),
        field_alias=row.get("field_alias", ""),
        data_type=row.get("data_type", ""),
        unit=row.get("unit", ""),
        description=row.get("description", ""),
        extract_method=row.get("extract_method", ""),
        access_code=row.get("access_code", ""),
        api_name=row.get("api_name", ""),
        page=row.get("page", ""),
        score=scored.score,
    )


def _value_item(
    catalog: ValueDomainCatalog,
    match: ValueMatch,
    related_tables: Sequence[RelatedTable] | None = None,
) -> ValueDomainItem:
    value = match.value
    hints = catalog.api_hints((match,))
    bindings = tuple(dict.fromkeys(hint.target for hint in hints))
    verification = []
    for hint in hints:
        if not hint.verification:
            continue
        if hint.verification.status == "runtime_required":
            call = hint.verification.resolver
            if hint.verification.argument:
                call += f"('{hint.verification.argument}')"
            verification.append(f"{call}（运行时）")
        elif hint.verification.status == "documented":
            verification.append("文档已确认")
    resolver_text = "; ".join(dict.fromkeys(verification))
    table = match.domain.related_table(value.related_table)
    selected_tables = tuple(related_tables) if related_tables is not None else ()
    if not selected_tables:
        selected_tables = (table,) if table else match.domain.related_tables
    rendered_tables = tuple(
        f"{related.table_id} {related.name}"
        for related in selected_tables
        if related is not None
    )
    return ValueDomainItem(
        kind="parameter_value",
        domain_id=match.domain.domain_id,
        domain_label=match.domain.label,
        value=value.value,
        label=value.label,
        parent=value.parent,
        parent_label=value.parent_label,
        level=value.level,
        valid_from=value.valid_from,
        valid_to=value.valid_to,
        note=value.note,
        related_tables=rendered_tables,
        bindings=bindings,
        mode=match.domain.mode,
        complete=match.domain.complete,
        as_of=value.as_of or match.domain.as_of,
        sources=value.sources,
        resolver_text=resolver_text,
    )


def _domain_summary_items(
    catalog: ValueDomainCatalog,
    query: str,
    *,
    scope: str | None,
    table: str | None,
    limit: int,
) -> tuple[ValueDomainSummaryItem, ...]:
    scope_key = _compact(scope or "")
    table_key = _compact(table or "")
    items = []
    for domain in catalog.exact_domains(query):
        related_tables = domain.related_tables
        if scope_key:
            related_tables = tuple(
                related
                for related in related_tables
                if _compact(related.scope) == scope_key
            )
        if table_key:
            related_tables = tuple(
                related
                for related in related_tables
                if table_key in _compact(related.name)
                or table_key in _compact(related.table_id)
            )
        if (scope_key or table_key) and not related_tables:
            continue
        bindings = tuple(
            f"{binding.api}.{binding.parameter}" for binding in domain.bindings
        )
        resolvers = []
        for resolver in domain.resolvers:
            catalog_label = {
                "system": "系统",
                "user": "用户",
            }.get(resolver.catalog, resolver.catalog)
            resolvers.append(
                f"{resolver.api}({resolver.parameter})（{catalog_label}目录）"
            )
        candidates = tuple(
            value if not label or label == value else f"{value}（{label}）"
            for value, label in catalog.recorded_candidates(domain)
        )
        items.append(
            ValueDomainSummaryItem(
                kind="parameter_domain",
                domain_id=domain.domain_id,
                label=domain.label,
                related_tables=tuple(
                    f"{related.table_id} {related.name}" for related in related_tables
                ),
                bindings=bindings,
                mode=domain.mode,
                complete=domain.complete,
                as_of=domain.as_of,
                sources=domain.sources,
                resolvers=tuple(resolvers),
                candidates=candidates,
            )
        )
    return tuple(items[:limit])


def _value_matches(
    catalog: ValueDomainCatalog,
    query: str,
    *,
    scope: str | None,
    table: str | None,
    field: str | None,
    limit: int,
    strong_only: bool,
) -> tuple[ValueDomainItem, ...]:
    if field:
        return ()
    query_terms = tuple(query.split())
    matches = catalog.search(query_terms) if len(query_terms) > 1 else ()
    if not matches:
        matches = catalog.search(query)
    if re.fullmatch(r"\s*-?\d{1,2}\s*", query):
        matches = tuple(
            match
            for match in matches
            if not re.fullmatch(r"-?\d{1,2}", match.value.value)
        )
    if strong_only:
        query_key = _compact(query)
        intent_markers = ("代码", "分类", "属性", "取值", "枚举")
        matches = tuple(
            match
            for match in matches
            if match.exact
            or (
                len(_compact(match.value.value)) >= 4
                and any(
                    character.isascii() and character.isalpha()
                    for character in match.value.value
                )
                and any(character.isdigit() for character in match.value.value)
                and _compact(match.value.value) in query_key
            )
            or (
                match.domain.mode == "versioned_catalog"
                and _compact(match.value.label) in query_key
                and any(marker in query for marker in intent_markers)
            )
            or (
                match.domain.mode == "enum"
                and _compact(match.domain.label) in query_key
                and _compact(match.value.value) in query_key
            )
        )
    filtered = []
    scope_key = _compact(scope or "")
    table_key = _compact(table or "")
    for match in matches:
        related_tables = match.domain.related_tables
        if match.value.related_table:
            related = match.domain.related_table(match.value.related_table)
            related_tables = (related,) if related else ()
        if scope_key:
            related_tables = tuple(
                related
                for related in related_tables
                if _compact(related.scope) == scope_key
            )
        if table_key:
            related_tables = tuple(
                related
                for related in related_tables
                if table_key in _compact(related.name)
                or table_key in _compact(related.table_id)
            )
        if (scope_key or table_key) and not related_tables:
            continue
        filtered.append((match, related_tables))
    matches_with_tables = tuple(filtered)
    if not matches_with_tables:
        return ()
    top_score = matches_with_tables[0][0].score
    top = [item for item in matches_with_tables if item[0].score == top_score]
    return tuple(
        _value_item(catalog, match, related_tables)
        for match, related_tables in top[:limit]
    )


def query_index(
    rows: Iterable[Mapping[str, str]],
    query: str,
    *,
    scope: str | None = None,
    table: str | None = None,
    field: str | None = None,
    limit: int = 10,
    lexicon: Mapping[str, Sequence[str]] | None = None,
) -> QueryResult:
    if not query.strip():
        raise ValueError("query must not be empty")
    if limit < 1:
        raise ValueError("limit must be at least 1")
    rows = list(rows)
    variants = _query_variants(query, lexicon or {})
    scope_key = _compact(scope or "")
    scored = []
    for row in rows:
        if scope_key and _compact(row.get("scope", "")) != scope_key:
            continue
        if table and not _matches_filter(row, table, "table_name", "table_alias"):
            continue
        if field and not _matches_filter(row, field, "field_name", "field_alias"):
            continue
        candidate = _score_row(row, variants)
        if candidate.score:
            scored.append(candidate)
    if not scored:
        return QueryResult("no_match", ())

    field_candidates = [item for item in scored if item.field_match]
    if field_candidates:
        scored = field_candidates
    else:
        entity_candidates = [item for item in scored if item.row.get("kind") in {"table", "source"}]
        if entity_candidates:
            scored = entity_candidates
    scored.sort(
        key=lambda item: (
            -item.score,
            _compact(item.row.get("table_name", "")),
            _compact(item.row.get("field_name", "")),
            item.row.get("field_id", ""),
        )
    )
    top_score = scored[0].score
    top = [candidate for candidate in scored if candidate.score == top_score]
    unique = []
    seen = set()
    for candidate in top:
        row = candidate.row
        key = (
            row.get("table_name", "").casefold(),
            row.get("field_id", ""),
            row.get("field_name", "").casefold(),
            row.get("page", ""),
        )
        if key not in seen:
            seen.add(key)
            unique.append(_item(candidate))
    items = tuple(unique[:limit])
    status = "ambiguous" if len(unique) > 1 else "ok"
    return QueryResult(status, items)


def _direct_dictionary_items(result: QueryResult, query: str) -> tuple[QueryItem, ...]:
    query_key = _compact(query)
    if not query_key:
        return ()
    direct = []
    for item in result.items:
        if not isinstance(item, QueryItem):
            continue
        names = (_compact(item.table_name), _compact(item.field_name))
        identifiers = (_compact(item.table_id), _compact(item.field_id))
        access_code = _compact(item.access_code)
        if (
            any(name and (query_key == name or name in query_key) for name in names)
            or any(identifier and query_key == identifier for identifier in identifiers)
            or (len(query_key) >= 3 and query_key in access_code)
        ):
            direct.append(item)
    return tuple(direct)


def _render_item(index: int, item: QueryItem) -> list[str]:
    return [
        f"[{index}]",
        f"类型：{item.kind}",
        f"范围：{item.scope}",
        f"表或数据源：{item.table_name}",
        f"表 ID：{item.table_id}",
        f"字段：{item.field_name}",
        f"字段别名或中文名：{item.field_alias}",
        f"字段 ID：{item.field_id}",
        f"字段类型：{item.data_type}",
        f"单位：{item.unit}",
        f"提取方式：{item.extract_method}",
        f"访问代码：{item.access_code}",
        f"对应 API：{item.api_name}",
        f"说明：{item.description}",
        f"页面：{item.page}",
    ]


def _render_value_item(index: int, item: ValueDomainItem) -> list[str]:
    validity = ""
    if item.valid_from or item.valid_to:
        validity = f"{item.valid_from or '不限'} 至 {item.valid_to or '今'}"
    parent = item.parent or "无"
    if item.parent_label:
        parent = f"{parent}（{item.parent_label}）"
    return [
        f"[{index}]",
        "类型：参数取值",
        f"参数域：{item.domain_id}（{item.domain_label}）",
        f"取值：{item.value}",
        f"名称：{item.label}",
        f"父级：{parent}",
        f"层级：{item.level if item.level is not None else ''}",
        f"有效期：{validity}",
        f"备注：{item.note}",
        f"关联表：{'; '.join(item.related_tables)}",
        f"绑定 API：{'; '.join(item.bindings)}",
        f"模式：{item.mode}",
        f"完整性：{'完整' if item.complete else '静态记录不完整'}",
        f"快照日期：{item.as_of}",
        f"核验：{item.resolver_text}",
        f"来源：{', '.join(item.sources)}",
    ]


def _render_domain_summary_item(
    index: int, item: ValueDomainSummaryItem
) -> list[str]:
    return [
        f"[{index}]",
        "类型：参数取值域",
        f"参数域：{item.domain_id}",
        f"名称：{item.label}",
        f"关联表：{'; '.join(item.related_tables)}",
        f"绑定 API：{'; '.join(item.bindings)}",
        f"模式：{item.mode}",
        f"完整性：{'完整' if item.complete else '静态记录不完整'}",
        f"快照日期：{item.as_of}",
        f"运行时解析器：{'; '.join(item.resolvers)}",
        f"已记录值或候选：{'; '.join(item.candidates)}",
        f"来源：{', '.join(item.sources)}",
    ]


def render_result(result: QueryResult) -> str:
    lines = [f"status: {result.status}"]
    if result.status == "no_match":
        lines.append("未找到匹配的数据字典条目")
    elif result.status == "ambiguous":
        lines.append("存在多个同分候选，请补充 scope、表、数据源或字段范围")
    for index, item in enumerate(result.items, start=1):
        if isinstance(item, ValueDomainSummaryItem):
            renderer = _render_domain_summary_item
        elif isinstance(item, ValueDomainItem):
            renderer = _render_value_item
        else:
            renderer = _render_item
        lines.extend(["", *renderer(index, item)])
    return "\n".join(lines) + "\n"


def _positive_int(value: str) -> int:
    try:
        parsed = int(value)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be an integer") from error
    if parsed < 1:
        raise argparse.ArgumentTypeError("must be at least 1")
    return parsed


def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="查询天软表、数据源、字段及 API 参数取值域。",
        epilog=HELP_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        allow_abbrev=False,
    )
    parser.add_argument("--query", required=True, help="中文业务描述、名称或 ID")
    parser.add_argument("--scope", help="按业务范围过滤")
    parser.add_argument("--table", help="按表名或数据源名过滤")
    parser.add_argument("--field", help="按字段名或字段 ID 过滤")
    parser.add_argument("--limit", type=_positive_int, default=10, help="候选条数上限")
    parser.add_argument("--tsv", type=Path, default=DEFAULT_TSV, help="字典索引路径")
    parser.add_argument(
        "--lexicon", type=Path, default=DEFAULT_LEXICON, help="受控同义词词表路径"
    )
    parser.add_argument(
        "--value-domains",
        type=Path,
        help="显式指定 value_domains.json；默认使用所选索引同目录的数据，"
        "自定义索引旁没有该文件时不加载参数取值域",
    )
    return parser.parse_args(argv)


def main(argv: Sequence[str] | None = None) -> int:
    args = parse_args(argv)
    domain_path = (
        args.value_domains
        if args.value_domains
        else domains_path_for_index(
            args.tsv,
            default_index=DEFAULT_TSV,
            default_domains=DEFAULT_VALUE_DOMAINS,
        )
    )
    try:
        rows = load_rows(args.tsv)
        lexicon = load_lexicon(args.lexicon)
        catalog = ValueDomainCatalog.load(domain_path) if domain_path else None
        dictionary_result = query_index(
            rows,
            args.query,
            scope=args.scope,
            table=args.table,
            field=args.field,
            limit=args.limit,
            lexicon=lexicon,
        )
        domain_items = (
            _domain_summary_items(
                catalog,
                args.query,
                scope=args.scope,
                table=args.table,
                limit=args.limit,
            )
            if catalog and not args.field
            else ()
        )
        value_items = (
            _value_matches(
                catalog,
                args.query,
                scope=args.scope,
                table=args.table,
                field=args.field,
                limit=args.limit,
                strong_only=True,
            )
            if catalog
            else ()
        )
        if domain_items:
            result = QueryResult(
                "ambiguous" if len(domain_items) > 1 else "ok",
                domain_items,
            )
        elif value_items:
            dictionary_items = _direct_dictionary_items(dictionary_result, args.query)
            combined = (*value_items, *dictionary_items)[: args.limit]
            result = QueryResult(
                "ambiguous" if len(value_items) > 1 or dictionary_items else "ok",
                combined,
            )
        else:
            result = dictionary_result
            if result.status == "no_match" and catalog:
                fallback_items = _value_matches(
                    catalog,
                    args.query,
                    scope=args.scope,
                    table=args.table,
                    field=args.field,
                    limit=args.limit,
                    strong_only=False,
                )
                if fallback_items:
                    result = QueryResult(
                        "ambiguous" if len(fallback_items) > 1 else "ok",
                        fallback_items,
                    )
    except (OSError, ValueError, json.JSONDecodeError) as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 1
    print(render_result(result), end="")
    return 0


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