"""Mini JSON Schema validator for versioned final-report data.json records.

This is a deliberately narrow JSON Schema implementation that supports
exactly the keywords used by ``schemas/final-report-v2.0.schema.json``:

  type, required, properties, additionalProperties, enum, const, pattern,
  minLength, minItems, maxItems, uniqueItems, prefixItems, items, minimum, maximum,
  boolean schemas, $ref ($defs),
  oneOf, allOf, if/then/else, contains, minContains, maxContains, not

We do NOT depend on the ``jsonschema`` PyPI package because its
dependency tree (``referencing``, ``rpds-py``) includes a Rust C
extension which we would have to vendor or ship pre-built per platform.
The ~250 lines below cover everything the final-report schema needs;
strict spec compliance is not a goal — strict validation of OUR schema is.

Error messages include the JSON pointer of the failing field so report
assembly can return the defect to the input owner.
"""
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Mapping

class SchemaError(ValueError):
    """Raised when a schema itself is malformed (e.g. a $ref points
    nowhere). Distinct from a data validation failure.
    """


def _format_path(path: tuple[str | int, ...]) -> str:
    if not path:
        return "<root>"
    parts: list[str] = []
    for p in path:
        if isinstance(p, int):
            parts.append(f"[{p}]")
        else:
            parts.append(f".{p}" if parts else p)
    return "".join(parts)


_TYPE_PYTHON: dict[str, tuple[type, ...]] = {
    "object": (dict,),
    "array": (list,),
    "string": (str,),
    "integer": (int,),
    "number": (int, float),
    "boolean": (bool,),
    "null": (type(None),),
}


def _check_type(value: Any, expected: str) -> bool:
    if expected == "integer":
        # JSON Schema treats booleans as not integer.
        return isinstance(value, int) and not isinstance(value, bool)
    if expected == "number":
        return isinstance(value, (int, float)) and not isinstance(value, bool)
    return isinstance(value, _TYPE_PYTHON.get(expected, ()))


def _json_schema_equal(left: Any, right: Any) -> bool:
    if isinstance(left, bool) or isinstance(right, bool):
        return isinstance(left, bool) and isinstance(right, bool) and left == right
    if isinstance(left, (int, float)) or isinstance(right, (int, float)):
        return (
            isinstance(left, (int, float))
            and isinstance(right, (int, float))
            and left == right
        )
    if isinstance(left, list) or isinstance(right, list):
        return (
            isinstance(left, list)
            and isinstance(right, list)
            and len(left) == len(right)
            and all(_json_schema_equal(a, b) for a, b in zip(left, right))
        )
    if isinstance(left, dict) or isinstance(right, dict):
        return (
            isinstance(left, dict)
            and isinstance(right, dict)
            and left.keys() == right.keys()
            and all(_json_schema_equal(left[key], right[key]) for key in left)
        )
    return type(left) is type(right) and left == right


def _resolve_ref(ref: str, root: dict) -> dict:
    """Resolve a ``#/$defs/Name`` style reference against ``root``."""
    if not ref.startswith("#/"):
        raise SchemaError(f"only local refs are supported, got: {ref}")
    parts = ref[2:].split("/")
    node: Any = root
    for part in parts:
        if not isinstance(node, dict) or part not in node:
            raise SchemaError(f"$ref target not found: {ref}")
        node = node[part]
    if not isinstance(node, dict):
        raise SchemaError(f"$ref target is not a schema: {ref}")
    return node


_BRANCH_ROOT_PREFIX = f"{_format_path(())}: "
"""분기 probe 의 경로는 분기 루트 기준이라 `<root>:` 가 매 줄 반복된다.
바깥 오류가 이미 실제 경로를 싣고 있으므로 안쪽에서는 뗀다."""

_BRANCH_ERROR_LIMIT = 8
"""분기 하나당 실을 오류 수. 나머지는 개수만 적는다 — 열세 개 짜리 목록도
앞의 여덟이면 어느 블록을 안 썼는지 판정된다."""


class _Validator:
    def __init__(self, root_schema: dict):
        self.root = root_schema
        self.errors: list[str] = []

    def validate(
        self,
        instance: Any,
        schema: dict | bool,
        path: tuple[str | int, ...],
    ) -> None:
        if schema is True:
            return
        if schema is False:
            self._err(path, "value is not allowed by the false schema")
            return

        # Handle $ref first — everything else is composed under the
        # resolved schema.
        if "$ref" in schema:
            schema = _resolve_ref(schema["$ref"], self.root)

        # const / enum: strict equality / membership.
        if "const" in schema and instance != schema["const"]:
            self._err(path, f"value is not equal to const {schema['const']!r}")
        if "enum" in schema and instance not in schema["enum"]:
            self._err(path, f"value {instance!r} is not in enum {schema['enum']!r}")

        # type
        type_keyword = schema.get("type")
        if type_keyword is not None:
            types = type_keyword if isinstance(type_keyword, list) else [type_keyword]
            if not any(_check_type(instance, t) for t in types):
                self._err(
                    path,
                    f"value of type {type(instance).__name__} is not of expected type(s) {types}",
                )
                return  # further checks would compound the error

        # String constraints
        if isinstance(instance, str):
            min_length = schema.get("minLength")
            if min_length is not None and len(instance) < min_length:
                self._err(path, f"string length {len(instance)} < minLength {min_length}")
            pattern = schema.get("pattern")
            if pattern is not None and not re.search(pattern, instance):
                self._err(path, f"string does not match pattern {pattern!r}")

        # Numeric constraints
        if isinstance(instance, (int, float)) and not isinstance(instance, bool):
            minimum = schema.get("minimum")
            if minimum is not None and instance < minimum:
                self._err(path, f"value {instance} < minimum {minimum}")
            maximum = schema.get("maximum")
            if maximum is not None and instance > maximum:
                self._err(path, f"value {instance} > maximum {maximum}")

        # Object constraints
        if isinstance(instance, dict):
            self._validate_object(instance, schema, path)

        # Array constraints
        if isinstance(instance, list):
            self._validate_array(instance, schema, path)

        # Composition keywords
        for sub in schema.get("allOf", []):
            self.validate(instance, sub, path)
            # `if/then/else` lives inside an allOf entry in our schemas.
            if "if" in sub:
                self._validate_conditional(instance, sub, path)
        if "if" in schema:
            self._validate_conditional(instance, schema, path)
        if "oneOf" in schema:
            self._validate_one_of(instance, schema["oneOf"], path)
        if "not" in schema:
            self._validate_not(instance, schema["not"], path)

    def _validate_object(self, instance: dict, schema: dict, path: tuple[str | int, ...]) -> None:
        properties = schema.get("properties") or {}
        required = schema.get("required") or []
        for name in required:
            if name not in instance:
                self._err(path, f"required property '{name}' is missing")
        for name, value in instance.items():
            if name in properties:
                self.validate(value, properties[name], path + (name,))
            elif schema.get("additionalProperties") is False:
                # Don't fire for keys we know are part of the conditional
                # branches (we still validate values when they appear).
                self._err(
                    path,
                    f"additional property '{name}' is not allowed"
                    + self._misplaced_property_hint(name, path),
                )

    def _misplaced_property_hint(
        self, name: str, path: tuple[str | int, ...],
    ) -> str:
        """동명 필수 필드의 위치만 알린다. 이름 일치는 값의 의미를 증명하지 않는다."""
        found = self._required_property_owners(name) - {_format_path(path)}
        if not found:
            return ""
        owners = (
            ["<root>"] if "<root>" in found else []
        ) + sorted(found - {"<root>"})
        return (
            f" — '{name}' is also required at {', '.join(owners[:3])}; "
            "a shared name does not establish where this value belongs. "
            "Check the target field's schema and meaning before moving any value"
        )

    def _required_property_owners(self, name: str) -> set[str]:
        """`name` 을 **필수**로 요구하는 객체들의 이름.

        정의만 가진 자리까지 세면(예: `summary` 는 8곳에서 정의된다) 힌트가
        이름 목록이 돼 아무것도 가리키지 못한다. 지우면 다음 라운드에서
        `required property ... is missing` 이 나오는 자리, 즉 필수인 곳만 센다.
        """
        owners: set[str] = set()

        def walk(node: Any, label: str) -> None:
            if isinstance(node, list):
                for item in node:
                    walk(item, label)
                return
            if not isinstance(node, dict):
                return
            required = node.get("required")
            if isinstance(required, list) and name in required:
                owners.add(label)
            for key, value in node.items():
                if key == "properties" and isinstance(value, dict):
                    for child, sub in value.items():
                        walk(sub, f"{label}.{child}" if label else child)
                elif key in ("definitions", "$defs") and isinstance(value, dict):
                    for child, sub in value.items():
                        walk(sub, child)
                elif key in ("items", "allOf", "oneOf", "anyOf", "then", "else"):
                    walk(value, label)

        walk(self.root, "<root>")
        return owners

    def _validate_array(self, instance: list, schema: dict, path: tuple[str | int, ...]) -> None:
        min_items = schema.get("minItems")
        if min_items is not None and len(instance) < min_items:
            self._err(path, f"array length {len(instance)} < minItems {min_items}")
        max_items = schema.get("maxItems")
        if max_items is not None and len(instance) > max_items:
            self._err(path, f"array length {len(instance)} > maxItems {max_items}")
        if schema.get("uniqueItems") is True and any(
            _json_schema_equal(item, previous)
            for index, item in enumerate(instance)
            for previous in instance[:index]
        ):
            self._err(path, "array items are not unique")
        prefix_items = schema.get("prefixItems") or []
        for i, item_schema in enumerate(prefix_items[: len(instance)]):
            self.validate(instance[i], item_schema, path + (i,))

        items = schema.get("items")
        if items is not None:
            remaining_items = instance[len(prefix_items) :]
            for i, value in enumerate(remaining_items, start=len(prefix_items)):
                self.validate(value, items, path + (i,))
        contains = schema.get("contains")
        if contains is not None:
            matches = sum(self._matches(value, contains) for value in instance)
            minimum = schema.get("minContains", 1)
            maximum = schema.get("maxContains")
            if matches < minimum:
                self._err(
                    path,
                    f"array contains {matches} matching item(s), below minContains {minimum}",
                )
            if maximum is not None and matches > maximum:
                self._err(
                    path,
                    f"array contains {matches} matching item(s), above maxContains {maximum}",
                )

    def _validate_conditional(self, instance: Any, schema: dict, path: tuple[str | int, ...]) -> None:
        if_schema = schema.get("if")
        if if_schema is None:
            return
        if self._matches(instance, if_schema):
            then_schema = schema.get("then")
            if then_schema is not None:
                self.validate(instance, then_schema, path)
        else:
            else_schema = schema.get("else")
            if else_schema is not None:
                self.validate(instance, else_schema, path)

    def _validate_one_of(
        self,
        instance: Any,
        branches: list[dict | bool],
        path: tuple[str | int, ...],
    ) -> None:
        per_branch = [self._branch_errors(instance, branch) for branch in branches]
        matches = sum(1 for errors in per_branch if not errors)
        if matches == 1:
            return
        if matches == 0:
            self._err(
                path,
                "oneOf matched no branch (expected exactly 1); "
                + self._branch_diagnosis(per_branch),
            )
            return
        self._err(
            path,
            f"oneOf matched {matches} branches (expected exactly 1); "
            f"branches: {[self._summarise(b) for b in branches]}",
        )

    @staticmethod
    def _branch_diagnosis(per_branch: list[list[str]]) -> str:
        """왜 각 분기가 안 맞았는지. 분기 스키마를 인라인하지 않는다.

        종전에는 `oneOf` 실패가 분기 스키마 두 개를 통째로 찍었다. 1,500자
        한 줄이면서 **어느 필드가 빠졌는지는 말하지 않아서**, 읽는 사람이
        narrative 의 키 목록을 뽑아 스키마의 `required` 와 손으로 대조해야
        했다. 분기가 실제로 낸 오류가 그 대조의 답이므로 그것을 싣는다.
        """
        parts: list[str] = []
        for index, errors in enumerate(per_branch, start=1):
            shown = [
                error[len(_BRANCH_ROOT_PREFIX):]
                if error.startswith(_BRANCH_ROOT_PREFIX)
                else error
                for error in errors[:_BRANCH_ERROR_LIMIT]
            ]
            remainder = len(errors) - len(shown)
            if remainder > 0:
                shown.append(f"(+{remainder} more)")
            parts.append(f"branch {index}: " + "; ".join(shown))
        return " | ".join(parts)

    def _branch_errors(self, instance: Any, schema: dict | bool) -> list[str]:
        probe = _Validator(self.root)
        probe.validate(instance, schema, ())
        return probe.errors

    def _validate_not(
        self,
        instance: Any,
        sub_schema: dict | bool,
        path: tuple[str | int, ...],
    ) -> None:
        if self._matches(instance, sub_schema):
            self._err(path, f"value must NOT match {self._summarise(sub_schema)}")

    def _matches(self, instance: Any, schema: dict | bool) -> bool:
        """Cheap 'does this validate' probe used by if/contains.
        Returns True iff the sub-schema produces zero errors. Does not
        mutate ``self.errors``. `oneOf` keeps the errors themselves
        (`_branch_errors`) because it has to say why every branch failed.
        """
        return not self._branch_errors(instance, schema)

    @staticmethod
    def _summarise(schema: dict | bool) -> str:
        if isinstance(schema, bool):
            return str(schema).lower()
        keys = sorted(k for k in schema if k not in ("description", "$comment"))
        return "{" + ", ".join(f"{k}={schema[k]!r}" for k in keys[:3]) + "}"

    def _err(self, path: tuple[str | int, ...], message: str) -> None:
        self.errors.append(f"{_format_path(path)}: {message}")


def follow_up_task_rules(schema: Mapping[str, Any], task_type: str) -> tuple[str, ...]:
    """이 task type 의 `followUpTasks` 에 스키마가 못 박은 행 규칙, 저작 문장으로.

    `allOf` 의 if/then 가지 하나가 비종결 task type 에 phase-continuation 행
    하나를 요구하고(`minItems`, `contains.origin.const`), `FollowUpRow` 의
    가지가 그 행의 `autoSpawn` 을 `no` 로 고정한다. 작성자는 둘 다 읽지 못해
    빈 배열을 냈고 조립이 `array length 0 < minItems 1` 로 거절했다(2026-09-09
    실측, dev-10642 requirements-discovery: 이 계열로만 라운드 3회). 가지가
    이 task type 에 없으면 빈 튜플이다 — 제약이 없다는 뜻이지 실패가 아니다.
    """
    def _task_matches(condition: Any) -> bool:
        if not isinstance(condition, dict):
            return False
        header = (condition.get("properties") or {}).get("header") or {}
        selector = (header.get("properties") or {}).get("taskType") or {}
        allowed = selector.get("enum")
        if allowed is None and "const" in selector:
            allowed = [selector["const"]]
        return isinstance(allowed, list) and task_type in allowed

    rules: list[str] = []
    for branch in schema.get("allOf") or []:
        if not isinstance(branch, dict) or not _task_matches(branch.get("if")):
            continue
        follow_up = ((branch.get("then") or {}).get("properties") or {}).get("followUpTasks")
        if not isinstance(follow_up, dict):
            continue
        min_items = follow_up.get("minItems")
        origin = (
            ((follow_up.get("contains") or {}).get("properties") or {}).get("origin") or {}
        ).get("const")
        if isinstance(min_items, int) and min_items > 0:
            rules.append(
                f"`Follow Up Tasks`: at least {min_items} `- Item N` row(s) for task "
                f"type `{task_type}`; an empty list is refused."
            )
        if isinstance(origin, str) and origin:
            rules.append(
                f"`Follow Up Tasks`: one row must carry `Origin` `{origin}` — the "
                "next phase of this task."
            )
            row_schema = (schema.get("$defs") or {}).get("FollowUpRow") or {}
            for row_branch in row_schema.get("allOf") or []:
                if not isinstance(row_branch, dict):
                    continue
                condition = ((row_branch.get("if") or {}).get("properties") or {}).get("origin") or {}
                if condition.get("const") != origin:
                    continue
                pinned = ((row_branch.get("then") or {}).get("properties") or {})
                for key, value in pinned.items():
                    if isinstance(value, dict) and "const" in value:
                        rules.append(
                            f"`Follow Up Tasks`: the `{origin}` row's `{key}` "
                            f"(schema key; write its Title Case label) is exactly "
                            f"`{value['const']}`."
                        )
    return tuple(rules)


def verdict_token_rule(schema: Mapping[str, Any], task_type: str) -> tuple[str, ...]:
    """이 task type 의 `finalVerdict.verdictToken` 에 스키마가 허용하는 값.

    스키마는 `allOf` 의 if/then 가지로 세 묶음을 나눈다(분석형 enum,
    final-verification enum, 나머지 `not-applicable` const). 작성기는 그 가지를
    읽지 못해 `analysis-complete` 를 error-analysis 에 썼고(2026-09-02 실측),
    조립이 아니라 HTML 렌더에서야 거절됐다. 여기서 뽑아 저작 계약에 싣는다.
    가지가 없으면 빈 튜플이다 — 제약이 없다는 뜻이지 실패가 아니다.
    """
    def _walk(node: Any) -> tuple[str, ...]:
        if isinstance(node, dict):
            condition = node.get("if")
            then = node.get("then")
            if isinstance(condition, dict) and isinstance(then, dict):
                header = (condition.get("properties") or {}).get("header") or {}
                selector = (header.get("properties") or {}).get("taskType") or {}
                allowed = selector.get("enum")
                if allowed is None and "const" in selector:
                    allowed = [selector["const"]]
                if isinstance(allowed, list) and task_type in allowed:
                    verdict = (then.get("properties") or {}).get("finalVerdict") or {}
                    token = (verdict.get("properties") or {}).get("verdictToken") or {}
                    if "const" in token:
                        return (str(token["const"]),)
                    if isinstance(token.get("enum"), list):
                        return tuple(str(value) for value in token["enum"])
            for value in node.values():
                found = _walk(value)
                if found:
                    return found
        elif isinstance(node, list):
            for value in node:
                found = _walk(value)
                if found:
                    return found
        return ()

    return _walk(dict(schema))


def task_block_rules(schema: Mapping[str, Any], block_key: str) -> tuple[str, ...]:
    """최상위 필드(`properties[block_key]`)가 요구하는 모양을 한
    객체당 한 줄의 저작 계약 문장으로 편다.

    리드는 report-writer 지시문을 쓸 때 블록 안쪽의 필수 필드·식별자 패턴·
    정수 필드·고정 길이 배열을 알 길이 프롬프트 경로에 없어 스키마 JSON 을
    손으로 파싱했고, 그래도 놓친 만큼 같은 리포트를 다시 썼다(2026-09-03 실측,
    dev-10626 implementation-option-selection: 네 회차). 여기서 뽑아 합성 묶음의
    저작 계약에 싣는다. 필드가 없으면 빈 튜플이다.

    경로는 스키마 키다(`rankedOptions[]` 는 `- Item N` 목록의 각 항목). 같은
    정의(`$ref`)가 두 경로에 나오면 두 번째는 첫 경로를 가리킨다 — 랭킹 옵션과
    감사 항목이 하위 구조 여섯 개를 공유한다.
    """
    root = dict(schema)
    block = (root.get("properties") or {}).get(block_key)
    if not isinstance(block, dict):
        return ()
    lines: list[str] = []
    described: dict[str, str] = {}
    resolved, _ = _resolved(root, block)
    if resolved.get("type") == "object" or "properties" in resolved:
        _describe_object(root, block, block_key, lines, described)
    else:
        nested: list[tuple[str, Any]] = []
        constraint = _describe_property(root, block, block_key, nested)
        if constraint:
            lines.append(f"`{block_key}`: {constraint}")
        for child_path, child in nested:
            _describe_object(root, child, child_path, lines, described)
    return tuple(lines)


def _resolved(root: dict, node: Any) -> tuple[dict, str | None]:
    """`$ref` 를 풀고, allOf 가지의 properties/required 를 얕게 합친다.

    반환값의 둘째는 이 노드가 가리킨 정의 이름 — 같은 정의를 두 경로에서
    두 번 펴지 않기 위해 쓴다.
    """
    if not isinstance(node, dict):
        return {}, None
    ref_name: str | None = None
    if "$ref" in node:
        ref_name = str(node["$ref"]).rsplit("/", 1)[-1]
        node = _resolve_ref(node["$ref"], root)
    merged = dict(node)
    for branch in node.get("allOf") or []:
        resolved, branch_ref = _resolved(root, branch)
        if ref_name is None:
            ref_name = branch_ref
        properties = dict(merged.get("properties") or {})
        for key, value in (resolved.get("properties") or {}).items():
            existing = properties.get(key)
            properties[key] = {**existing, **value} if isinstance(existing, dict) else value
        merged["properties"] = properties
        merged["required"] = list(
            dict.fromkeys([*(merged.get("required") or []), *(resolved.get("required") or [])])
        )
        for keyword in ("type", "pattern", "const", "enum", "minItems", "maxItems", "items", "prefixItems"):
            if keyword in resolved and keyword not in merged:
                merged[keyword] = resolved[keyword]
    return merged, ref_name


def _describe_object(
    root: dict, node: Any, path: str, lines: list[str], described: dict[str, str],
) -> None:
    schema, ref_name = _resolved(root, node)
    if ref_name and ref_name in described:
        lines.append(f"`{path}`: same shape as `{described[ref_name]}`")
        return
    if ref_name:
        described[ref_name] = path
    properties = schema.get("properties") or {}
    required = [key for key in (schema.get("required") or []) if isinstance(key, str)]
    optional = [key for key in properties if key not in required]
    parts: list[str] = []
    if required:
        parts.append("required " + ", ".join(f"`{key}`" for key in required))
    if optional:
        parts.append("optional " + ", ".join(f"`{key}`" for key in optional))
    nested: list[tuple[str, Any]] = []
    for key, value in properties.items():
        constraint = _describe_property(root, value, f"{path}.{key}", nested)
        if constraint:
            parts.append(f"`{key}` {constraint}")
    lines.append(f"`{path}`: " + "; ".join(parts))
    for child_path, child in nested:
        _describe_object(root, child, child_path, lines, described)


def _describe_property(
    root: dict, node: Any, path: str, nested: list[tuple[str, Any]],
) -> str:
    """한 속성의 제약을 한 구절로. 객체 자식은 `nested` 에 넣어 자기 줄을 받는다."""
    schema, _ref = _resolved(root, node)
    branches = schema.get("oneOf") or schema.get("anyOf") or []
    nullable = False
    if branches:
        resolved_branches = [_resolved(root, branch)[0] for branch in branches]
        nullable = any(branch.get("type") == "null" for branch in resolved_branches)
        others = [branch for branch in branches if _resolved(root, branch)[0].get("type") != "null"]
        if len(others) == 1:
            schema = _resolved(root, others[0])[0] | {"__branch": others[0]}
    types = schema.get("type")
    types = types if isinstance(types, list) else ([types] if types else [])
    if "null" in types:
        nullable = True
        types = [item for item in types if item != "null"]
    suffix = " or `_none_`" if nullable else ""
    if "const" in schema:
        return f"exactly `{schema['const']}`{suffix}"
    if isinstance(schema.get("enum"), list):
        return "one of " + ", ".join(f"`{value}`" for value in schema["enum"]) + suffix
    if "properties" in schema or types == ["object"]:
        nested.append((path, schema.get("__branch", node)))
        return f"object (see `{path}`){suffix}"
    if types == ["array"] or "items" in schema or "prefixItems" in schema:
        return _describe_array(root, schema, path, nested) + suffix
    if types == ["integer"]:
        return "integer" + _range(schema) + suffix
    if types == ["number"]:
        return "number" + _range(schema) + suffix
    if types == ["string"]:
        pattern = schema.get("pattern")
        return (f"matches `{pattern}`" if pattern else "string") + suffix
    if types == ["boolean"]:
        return "`true` or `false`" + suffix
    return ""


def _describe_array(
    root: dict, schema: dict, path: str, nested: list[tuple[str, Any]],
) -> str:
    minimum = schema.get("minItems")
    maximum = schema.get("maxItems")
    if maximum == 0:
        return "must be empty (`> _none_`)"
    if minimum is not None and minimum == maximum:
        size = f"exactly {maximum} items"
    else:
        bounds = [
            text
            for text in (
                f"at least {minimum}" if minimum else "",
                f"at most {maximum}" if maximum is not None else "",
            )
            if text
        ]
        size = "list" + (f" ({', '.join(bounds)})" if bounds else "")
    prefix_items = schema.get("prefixItems")
    if isinstance(prefix_items, list) and prefix_items:
        order = _prefix_order(root, prefix_items)
        first, _ = _resolved(root, prefix_items[0])
        if "properties" in first:
            nested.append((f"{path}[]", prefix_items[0]))
            return f"{size} in this order: {order}; each item (see `{path}[]`)"
        return f"{size} in this order: {order}"
    items = schema.get("items")
    item_schema, _ = _resolved(root, items) if items is not None else ({}, None)
    if "properties" in item_schema:
        nested.append((f"{path}[]", items))
        return f"{size} of objects (see `{path}[]`)"
    if "pattern" in item_schema:
        return f"{size} of strings matching `{item_schema['pattern']}`"
    if isinstance(item_schema.get("enum"), list):
        return f"{size}, each one of " + ", ".join(f"`{value}`" for value in item_schema["enum"])
    if item_schema.get("type"):
        return f"{size} of {item_schema['type']}"
    return size


def _prefix_order(root: dict, prefix_items: list) -> str:
    """고정 순서 배열의 각 자리가 못 박은 값 — `criterion` 처럼 상수인 속성."""
    labels: list[str] = []
    for item in prefix_items:
        merged, _ = _resolved(root, item)
        consts = [
            f"`{key}`=`{value['const']}`"
            for key, value in (merged.get("properties") or {}).items()
            if isinstance(value, dict) and "const" in value
        ]
        labels.append(", ".join(consts) if consts else "item")
    return "; ".join(labels)


def _range(schema: dict) -> str:
    low, high = schema.get("minimum"), schema.get("maximum")
    if low is not None and high is not None:
        return f" {low}..{high}"
    if low is not None:
        return f" >= {low}"
    if high is not None:
        return f" <= {high}"
    return ""


def validate(data: Any, schema: dict) -> list[str]:
    """Validate ``data`` against ``schema``. Returns the list of human-
    readable error messages (empty when the data is valid)."""
    v = _Validator(schema)
    v.validate(data, schema, ())
    return v.errors


SCHEMA_FILENAMES = {
    "2.0": "final-report-v2.0.schema.json",
    "3.0": "final-report-v3.0.schema.json",
}


def load_named_schema(filename: str, start: Path | None = None) -> dict:
    here = (start or Path(__file__)).resolve()
    if here.is_file():
        here = here.parent
    for parent in [here, *here.parents]:
        candidate = parent / "schemas" / filename
        if candidate.is_file():
            return json.loads(candidate.read_text(encoding="utf-8"))
    raise SchemaError(f"could not locate schemas/{filename}")


def load_schema_version(version: str, start: Path | None = None) -> dict:
    """Load the final-report schema identified by its data version."""
    try:
        filename = SCHEMA_FILENAMES[version]
    except KeyError as exc:
        raise SchemaError(f"unsupported final-report schemaVersion: {version}") from exc
    return load_named_schema(filename, start=start)


def load_schema_for_data(data: dict, start: Path | None = None) -> dict:
    """Select a schema from the explicit ``schemaVersion`` in *data*."""
    version = data.get("schemaVersion")
    if not isinstance(version, str) or not version:
        raise SchemaError("final-report data has no schemaVersion")
    return load_schema_version(version, start=start)


def load_schema(schema_path: Path | None = None) -> dict:
    """Load the compatibility schema used by historical direct callers.

    New write paths select contract 3.0 explicitly. Readers select from the
    record's ``schemaVersion`` through :func:`load_schema_for_data`.
    """
    if schema_path is not None:
        return json.loads(Path(schema_path).read_text(encoding="utf-8"))
    return load_schema_version("2.0")
