#!/usr/bin/env python3
"""Load and query bundled TSL API parameter value domains."""

from __future__ import annotations

import json
import re
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence


DOMAIN_MODES = {
    "catalog",
    "enum",
    "pattern",
    "runtime_catalog",
    "versioned_catalog",
}
DOMAIN_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")


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


def _fold_value(value: str) -> str:
    """Normalize a domain value without conflating negative and positive numbers."""
    normalized = unicodedata.normalize("NFKC", value).casefold()
    return "".join(
        character
        for character in normalized
        if character in {"-", "_", "."}
        or unicodedata.category(character)[0] in {"L", "N"}
    )


def _required_string(value: object, where: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"{where} must be a non-empty string")
    return value.strip()


def _optional_string(value: object, where: str) -> str:
    if value is None:
        return ""
    if not isinstance(value, str):
        raise ValueError(f"{where} must be a string")
    return value.strip()


def _string_list(value: object, where: str, *, required: bool = False) -> tuple[str, ...]:
    if not isinstance(value, list):
        raise ValueError(f"{where} must be a list")
    items = tuple(_required_string(item, f"{where}[]") for item in value)
    if required and not items:
        raise ValueError(f"{where} must not be empty")
    if len({_fold(item) for item in items}) != len(items):
        raise ValueError(f"{where} contains duplicate values")
    return items


def _mapping(value: object, where: str) -> dict:
    if not isinstance(value, dict):
        raise ValueError(f"{where} must be an object")
    return value


def _list(value: object, where: str) -> list:
    if not isinstance(value, list):
        raise ValueError(f"{where} must be a list")
    return value


def _reject_unknown(value: dict, allowed: set[str], where: str) -> None:
    unknown = sorted(set(value) - allowed)
    if unknown:
        raise ValueError(f"{where} contains unknown fields: {', '.join(unknown)}")


@dataclass(frozen=True)
class Binding:
    scope: str
    module: str
    api: str
    parameter: str
    role: str


@dataclass(frozen=True)
class Resolver:
    scope: str
    module: str
    api: str
    parameter: str
    catalog: str
    source: str


@dataclass(frozen=True)
class RelatedTable:
    table_id: str
    name: str
    scope: str
    page: str


@dataclass(frozen=True)
class Verification:
    status: str
    scope: str
    module: str
    resolver: str
    parameter: str
    argument: str


@dataclass(frozen=True)
class Relation:
    kind: str
    domain_id: str
    value: str
    verification: Verification
    sources: tuple[str, ...]


@dataclass(frozen=True)
class DomainValue:
    value: str
    label: str
    aliases: tuple[str, ...]
    parent: str
    parent_label: str
    level: int | None
    valid_from: str
    valid_to: str
    note: str
    related_table: str
    as_of: str
    sources: tuple[str, ...]
    relations: tuple[Relation, ...]


@dataclass(frozen=True)
class ValueDomain:
    domain_id: str
    label: str
    mode: str
    complete: bool
    bindings: tuple[Binding, ...]
    resolvers: tuple[Resolver, ...]
    related_tables: tuple[RelatedTable, ...]
    values: tuple[DomainValue, ...]
    sources: tuple[str, ...]
    as_of: str

    def related_table(self, table_id: str) -> RelatedTable | None:
        return next(
            (table for table in self.related_tables if table.table_id == table_id),
            None,
        )


@dataclass(frozen=True)
class ValueMatch:
    domain: ValueDomain
    value: DomainValue
    score: int
    exact: bool


@dataclass(frozen=True)
class ApiHint:
    scope: str
    module: str
    api: str
    parameter: str
    role: str
    domain: ValueDomain
    value: str
    label: str
    verification: Verification | None
    sources: tuple[str, ...]

    @property
    def target(self) -> str:
        return f"{self.api}.{self.parameter}"

    def summary(self) -> str:
        if self.role == "historical_components":
            text = f"历史分类代码：{self.value}"
            if self.label and self.label != self.value:
                text += f"（{self.label}）"
            return text
        if self.role == "historical_market_components":
            text = f"历史市场板块代码：{self.value}"
            if self.label and self.label != self.value:
                text += f"（{self.label}）"
            return text
        if self.role == "historical_named_components":
            return f"历史市场板块名：{self.value}"
        if self.role == "current_components":
            prefix = (
                "当前板块候选"
                if self.verification
                and self.verification.status == "runtime_required"
                else "当前板块名"
            )
            return f"{prefix}：{self.value}"
        if self.role == "catalog_selector":
            return f"目录选择器：{self.value}"
        if self.role == "child_codes":
            return f"下级分类代码模式：{self.value}"
        text = f"参数值：{self.value}"
        if self.label and self.label != self.value:
            text += f"（{self.label}）"
        return text

    def verification_text(self) -> str:
        if not self.verification:
            return ""
        call = self.verification.resolver
        if self.verification.argument:
            call += f"('{self.verification.argument}')"
        if self.verification.status == "runtime_required":
            return f"运行时核验：{call}"
        if self.verification.status == "documented":
            return "文档已确认"
        return self.verification.status


class ValueDomainCatalog:
    def __init__(self, domains: Sequence[ValueDomain]):
        self.domains = tuple(domains)
        self._by_id = {domain.domain_id: domain for domain in self.domains}

    @classmethod
    def load(cls, path: Path) -> "ValueDomainCatalog":
        return cls(_parse_document(json.loads(path.read_text(encoding="utf-8"))))

    def search(self, terms: str | Iterable[str]) -> tuple[ValueMatch, ...]:
        raw_terms = [terms] if isinstance(terms, str) else list(terms)
        normalized_terms = tuple(
            _fold_value(term) for term in raw_terms if _fold_value(term)
        )
        if not normalized_terms:
            return ()

        matches = []
        for domain in self.domains:
            for value in domain.values:
                scored = _score_value(domain, value, normalized_terms)
                if scored:
                    score, exact = scored
                    matches.append(ValueMatch(domain, value, score, exact))
        matches.sort(
            key=lambda match: (
                -match.score,
                _fold(match.domain.domain_id),
                _fold(match.value.value),
            )
        )
        return tuple(matches)

    def exact_domains(self, query: str) -> tuple[ValueDomain, ...]:
        query_key = _fold_value(query)
        if not query_key:
            return ()
        return tuple(
            domain
            for domain in self.domains
            if query_key
            in {
                _fold_value(domain.domain_id),
                _fold_value(domain.label),
            }
        )

    def recorded_candidates(
        self, domain: ValueDomain
    ) -> tuple[tuple[str, str], ...]:
        candidates = [(value.value, value.label) for value in domain.values]
        candidates.extend(
            (relation.value, value.label)
            for source_domain in self.domains
            for value in source_domain.values
            for relation in value.relations
            if relation.domain_id == domain.domain_id
        )
        unique = []
        seen = set()
        for value, label in candidates:
            key = _fold_value(value)
            if key in seen:
                continue
            seen.add(key)
            unique.append((value, label))
        return tuple(unique)

    def api_hints(self, matches: Iterable[ValueMatch]) -> tuple[ApiHint, ...]:
        hints = []
        seen = set()
        for match in matches:
            for binding in match.domain.bindings:
                hint = ApiHint(
                    scope=binding.scope,
                    module=binding.module,
                    api=binding.api,
                    parameter=binding.parameter,
                    role=binding.role,
                    domain=match.domain,
                    value=match.value.value,
                    label=match.value.label,
                    verification=None,
                    sources=match.value.sources,
                )
                key = _hint_key(hint)
                if key not in seen:
                    seen.add(key)
                    hints.append(hint)
            for relation in match.value.relations:
                target = self._by_id[relation.domain_id]
                for binding in target.bindings:
                    hint = ApiHint(
                        scope=binding.scope,
                        module=binding.module,
                        api=binding.api,
                        parameter=binding.parameter,
                        role=binding.role,
                        domain=target,
                        value=relation.value,
                        label=match.value.label,
                        verification=relation.verification,
                        sources=relation.sources,
                    )
                    key = _hint_key(hint)
                    if key not in seen:
                        seen.add(key)
                        hints.append(hint)
        hints.sort(
            key=lambda hint: (
                _fold(hint.api),
                _fold(hint.parameter),
                hint.summary(),
            )
        )
        return tuple(hints)

    def describe_api(
        self,
        api: str,
        *,
        scope: str = "",
        module: str = "",
        parameters: Iterable[str] | None = None,
    ) -> str:
        api_key = _fold(api)
        scope_key = _fold(scope)
        module_key = _fold(module)
        parameter_keys = (
            {_fold(parameter) for parameter in parameters}
            if parameters is not None
            else None
        )
        bound = [
            (domain, binding)
            for domain in self.domains
            for binding in domain.bindings
            if _fold(binding.api) == api_key
            and (not scope_key or _fold(binding.scope) == scope_key)
            and (not module_key or _fold(binding.module) == module_key)
            and (
                parameter_keys is None
                or _fold(binding.parameter) in parameter_keys
            )
        ]
        if not bound:
            return ""

        lines = ["### 参数取值域", ""]
        for domain, binding in bound:
            completeness = "完整" if domain.complete else "静态记录不完整"
            lines.append(
                f"- `{binding.parameter}` -> `{domain.domain_id}`（{domain.label}；"
                f"`{domain.mode}`，{completeness}）"
            )
            lines.append(f"  - 快照日期：`{domain.as_of}`")
            sources = ", ".join(f"`{source}`" for source in domain.sources)
            lines.append(f"  - 来源：{sources}")
            for resolver in domain.resolvers:
                catalog = {"system": "系统", "user": "用户"}.get(
                    resolver.catalog, resolver.catalog
                )
                lines.append(
                    f"  - 运行时解析器：`{resolver.api}`（{catalog}目录；"
                    f"来源 `{resolver.source}`）"
                )
            recorded_count = len(self.recorded_candidates(domain))
            if domain.complete and domain.mode in {"enum", "pattern"}:
                for value in domain.values:
                    label = f" — {value.label}" if value.label != value.value else ""
                    note = f"；{value.note}" if value.note else ""
                    lines.append(f"  - `{value.value}`{label}{note}")
            else:
                lines.append(f"  - 已记录值或候选：{recorded_count} 条（此处不展开）")
                lines.append(
                    "  - 按值查询：`dictionary_lookup.py --query "
                    "\"<域 ID、名称、代码或模式>\"`"
                )
        return "\n".join(lines) + "\n"


def domains_path_for_index(
    index_path: Path, *, default_index: Path, default_domains: Path
) -> Path | None:
    if index_path.parent.name == "data":
        sibling = index_path.parent / default_domains.name
        if sibling.is_file():
            return sibling
    try:
        is_default = index_path.resolve() == default_index.resolve()
    except OSError:
        is_default = index_path == default_index
    return default_domains if is_default else None


def _parse_document(document: object) -> tuple[ValueDomain, ...]:
    root = _mapping(document, "value domain document")
    _reject_unknown(root, {"version", "domains"}, "value domain document")
    if root.get("version") != 1:
        raise ValueError("value domain document version must be 1")
    domain_items = _list(root.get("domains"), "domains")
    if not domain_items:
        raise ValueError("domains must not be empty")
    domains = tuple(
        _parse_domain(item, f"domains[{index}]")
        for index, item in enumerate(domain_items)
    )
    ids = [domain.domain_id for domain in domains]
    if len(set(ids)) != len(ids):
        raise ValueError("domains contains duplicate ids")
    known = set(ids)
    by_id = {domain.domain_id: domain for domain in domains}
    for domain in domains:
        table_ids = {table.table_id for table in domain.related_tables}
        for value in domain.values:
            if value.related_table and value.related_table not in table_ids:
                raise ValueError(
                    f"domain {domain.domain_id!r} value {value.value!r} references "
                    f"unknown related table {value.related_table!r}"
                )
            for relation in value.relations:
                if relation.domain_id not in known:
                    raise ValueError(
                        f"domain {domain.domain_id!r} value {value.value!r} references "
                        f"unknown domain {relation.domain_id!r}"
                    )
                target = by_id[relation.domain_id]
                verification_key = (
                    _fold(relation.verification.scope),
                    _fold(relation.verification.module),
                    _fold(relation.verification.resolver),
                    _fold(relation.verification.parameter),
                )
                resolver_keys = {
                    (
                        _fold(resolver.scope),
                        _fold(resolver.module),
                        _fold(resolver.api),
                        _fold(resolver.parameter),
                    )
                    for resolver in target.resolvers
                }
                if verification_key not in resolver_keys:
                    raise ValueError(
                        f"domain {domain.domain_id!r} value {value.value!r} "
                        f"references a resolver not owned by {relation.domain_id!r}"
                    )
    return domains


def _parse_domain(value: object, where: str) -> ValueDomain:
    item = _mapping(value, where)
    _reject_unknown(
        item,
        {
            "id",
            "label",
            "mode",
            "complete",
            "bindings",
            "resolvers",
            "related_tables",
            "values",
            "sources",
            "as_of",
        },
        where,
    )
    domain_id = _required_string(item.get("id"), f"{where}.id")
    if not DOMAIN_ID_RE.fullmatch(domain_id):
        raise ValueError(f"{where}.id must use lowercase snake_case")
    mode = _required_string(item.get("mode"), f"{where}.mode")
    if mode not in DOMAIN_MODES:
        raise ValueError(f"{where}.mode must be one of {', '.join(sorted(DOMAIN_MODES))}")
    complete = item.get("complete")
    if not isinstance(complete, bool):
        raise ValueError(f"{where}.complete must be a boolean")
    if mode == "enum" and not complete:
        raise ValueError(f"{where}.complete must be true for mode enum")
    if mode == "runtime_catalog" and complete:
        raise ValueError(f"{where}.complete must be false for mode runtime_catalog")
    bindings = tuple(
        _parse_binding(binding, f"{where}.bindings[{index}]")
        for index, binding in enumerate(_list(item.get("bindings"), f"{where}.bindings"))
    )
    if not bindings:
        raise ValueError(f"{where}.bindings must not be empty")
    resolvers = tuple(
        _parse_resolver(resolver, f"{where}.resolvers[{index}]")
        for index, resolver in enumerate(_list(item.get("resolvers"), f"{where}.resolvers"))
    )
    if mode == "runtime_catalog" and not resolvers:
        raise ValueError(f"{where}.resolvers must not be empty for mode runtime_catalog")
    tables = tuple(
        _parse_table(table, f"{where}.related_tables[{index}]")
        for index, table in enumerate(
            _list(item.get("related_tables"), f"{where}.related_tables")
        )
    )
    values = tuple(
        _parse_value(domain_value, f"{where}.values[{index}]")
        for index, domain_value in enumerate(_list(item.get("values"), f"{where}.values"))
    )
    value_keys = [_fold_value(domain_value.value) for domain_value in values]
    if len(set(value_keys)) != len(value_keys):
        raise ValueError(f"{where}.values contains duplicate values")
    if mode != "runtime_catalog" and not values:
        raise ValueError(f"{where}.values must not be empty for mode {mode}")
    return ValueDomain(
        domain_id=domain_id,
        label=_required_string(item.get("label"), f"{where}.label"),
        mode=mode,
        complete=complete,
        bindings=bindings,
        resolvers=resolvers,
        related_tables=tables,
        values=values,
        sources=_string_list(item.get("sources"), f"{where}.sources", required=True),
        as_of=_required_string(item.get("as_of"), f"{where}.as_of"),
    )


def _parse_binding(value: object, where: str) -> Binding:
    item = _mapping(value, where)
    _reject_unknown(item, {"scope", "module", "api", "parameter", "role"}, where)
    return Binding(
        scope=_required_string(item.get("scope"), f"{where}.scope"),
        module=_required_string(item.get("module"), f"{where}.module"),
        api=_required_string(item.get("api"), f"{where}.api"),
        parameter=_required_string(item.get("parameter"), f"{where}.parameter"),
        role=_required_string(item.get("role"), f"{where}.role"),
    )


def _parse_resolver(value: object, where: str) -> Resolver:
    item = _mapping(value, where)
    _reject_unknown(
        item,
        {"scope", "module", "api", "parameter", "catalog", "source"},
        where,
    )
    return Resolver(
        scope=_required_string(item.get("scope"), f"{where}.scope"),
        module=_required_string(item.get("module"), f"{where}.module"),
        api=_required_string(item.get("api"), f"{where}.api"),
        parameter=_required_string(item.get("parameter"), f"{where}.parameter"),
        catalog=_required_string(item.get("catalog"), f"{where}.catalog"),
        source=_required_string(item.get("source"), f"{where}.source"),
    )


def _parse_table(value: object, where: str) -> RelatedTable:
    item = _mapping(value, where)
    _reject_unknown(item, {"id", "name", "scope", "page"}, where)
    return RelatedTable(
        table_id=_required_string(item.get("id"), f"{where}.id"),
        name=_required_string(item.get("name"), f"{where}.name"),
        scope=_required_string(item.get("scope"), f"{where}.scope"),
        page=_required_string(item.get("page"), f"{where}.page"),
    )


def _parse_value(value: object, where: str) -> DomainValue:
    item = _mapping(value, where)
    _reject_unknown(
        item,
        {
            "value",
            "label",
            "aliases",
            "parent",
            "parent_label",
            "level",
            "valid_from",
            "valid_to",
            "note",
            "related_table",
            "as_of",
            "sources",
            "relations",
        },
        where,
    )
    level = item.get("level")
    if level is not None and (not isinstance(level, int) or level < 0):
        raise ValueError(f"{where}.level must be a non-negative integer")
    return DomainValue(
        value=_required_string(item.get("value"), f"{where}.value"),
        label=_required_string(item.get("label"), f"{where}.label"),
        aliases=_string_list(item.get("aliases", []), f"{where}.aliases"),
        parent=_optional_string(item.get("parent"), f"{where}.parent"),
        parent_label=_optional_string(item.get("parent_label"), f"{where}.parent_label"),
        level=level,
        valid_from=_optional_string(item.get("valid_from"), f"{where}.valid_from"),
        valid_to=_optional_string(item.get("valid_to"), f"{where}.valid_to"),
        note=_optional_string(item.get("note"), f"{where}.note"),
        related_table=_optional_string(
            item.get("related_table"), f"{where}.related_table"
        ),
        as_of=_optional_string(item.get("as_of"), f"{where}.as_of"),
        sources=_string_list(item.get("sources"), f"{where}.sources", required=True),
        relations=tuple(
            _parse_relation(relation, f"{where}.relations[{index}]")
            for index, relation in enumerate(
                _list(item.get("relations", []), f"{where}.relations")
            )
        ),
    )


def _parse_relation(value: object, where: str) -> Relation:
    item = _mapping(value, where)
    _reject_unknown(item, {"kind", "domain", "value", "verification", "sources"}, where)
    verification_item = _mapping(item.get("verification"), f"{where}.verification")
    _reject_unknown(
        verification_item,
        {"status", "scope", "module", "resolver", "parameter", "argument"},
        f"{where}.verification",
    )
    status = _required_string(
        verification_item.get("status"), f"{where}.verification.status"
    )
    if status not in {"documented", "runtime_required"}:
        raise ValueError(
            f"{where}.verification.status must be documented or runtime_required"
        )
    return Relation(
        kind=_required_string(item.get("kind"), f"{where}.kind"),
        domain_id=_required_string(item.get("domain"), f"{where}.domain"),
        value=_required_string(item.get("value"), f"{where}.value"),
        verification=Verification(
            status=status,
            scope=_required_string(
                verification_item.get("scope"), f"{where}.verification.scope"
            ),
            module=_required_string(
                verification_item.get("module"), f"{where}.verification.module"
            ),
            resolver=_required_string(
                verification_item.get("resolver"), f"{where}.verification.resolver"
            ),
            parameter=_required_string(
                verification_item.get("parameter"),
                f"{where}.verification.parameter",
            ),
            argument=_optional_string(
                verification_item.get("argument"), f"{where}.verification.argument"
            ),
        ),
        sources=_string_list(item.get("sources"), f"{where}.sources", required=True),
    )


def _score_value(
    domain: ValueDomain, value: DomainValue, terms: Sequence[str]
) -> tuple[int, bool] | None:
    fields = (
        (value.value, 600),
        (value.label, 560),
        *((alias, 520) for alias in value.aliases),
        (value.parent, 260),
        (value.parent_label, 300),
        (domain.label, 180),
        (domain.domain_id, 160),
    )
    folded_fields = tuple(
        (_fold_value(text), weight) for text, weight in fields if _fold_value(text)
    )
    scores = []
    exact = False
    for term in terms:
        best = 0
        for field, weight in folded_fields:
            if term == field:
                best = max(best, weight + 500)
                exact = exact or len(terms) == 1
            elif len(term) < 2:
                continue
            elif term in field:
                best = max(best, weight)
            elif len(field) >= 2 and field in term:
                best = max(best, weight - 80)
        if not best:
            return None
        scores.append(best)
    same_field_bonus = 300 if any(all(term in field for term in terms) for field, _ in folded_fields) else 0
    return sum(scores) + same_field_bonus, exact


def _hint_key(hint: ApiHint) -> tuple[str, ...]:
    return (
        _fold(hint.scope),
        _fold(hint.module),
        _fold(hint.api),
        _fold(hint.parameter),
        _fold(hint.value),
        hint.role,
        hint.verification.status if hint.verification else "",
    )
