# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
"""Emitter that builds deterministic Python validator modules from schemas."""

from __future__ import annotations

from typing import Any


def _sanitize_identifier(name: str) -> str:
    normalized = "".join(ch if ch.isalnum() else "_" for ch in name)
    while "__" in normalized:
        normalized = normalized.replace("__", "_")
    normalized = normalized.strip("_")
    return normalized or "schema"


def _resolve_ref(ref: str) -> str:
    return ref.rsplit("/", 1)[-1]


def _ordered(value: Any) -> Any:
    if isinstance(value, dict):
        return {k: _ordered(value[k]) for k in sorted(value)}
    if isinstance(value, list):
        return [_ordered(v) for v in value]
    return value


def _header() -> str:
    return (
        "# pylint: disable=line-too-long,useless-suppression,too-many-lines\n"
        "# coding=utf-8\n"
        "# --------------------------------------------------------------------------\n"
        "# Copyright (c) Microsoft Corporation. All rights reserved.\n"
        "# Licensed under the MIT License. See License.txt in the project root for license information.\n"
        "# Code generated by Microsoft (R) Python Code Generator.\n"
        "# Changes may cause incorrect behavior and will be lost if the code is regenerated.\n"
        "# --------------------------------------------------------------------------\n"
    )


def _schema_kind(schema: dict[str, Any]) -> str | None:
    schema_type = schema.get("type")
    if isinstance(schema_type, str):
        return schema_type
    if "properties" in schema or "additionalProperties" in schema or "discriminator" in schema:
        return "object"
    if "oneOf" in schema or "anyOf" in schema:
        return "union"
    return None


def build_validator_module(schemas: dict[str, dict[str, Any]], roots: list[str]) -> str:
    """Build generated validator module source code without runtime schema blobs."""
    ordered_schemas = _ordered(schemas)
    target_roots = sorted(dict.fromkeys(roots)) if roots else sorted(ordered_schemas)

    lines: list[str] = [_header(), "", "from __future__ import annotations", "", "from typing import Any", ""]
    lines.extend(
        [
            "try:",
            "    from . import _enums as _generated_enums",
            "except Exception:",
            "    _generated_enums = None",
            "",
            "def _append_error(errors: list[dict[str, str]], path: str, message: str) -> None:",
            "    errors.append({'path': path, 'message': message})",
            "",
            "def _type_label(value: Any) -> str:",
            "    if value is None:",
            "        return 'null'",
            "    if isinstance(value, bool):",
            "        return 'boolean'",
            "    if isinstance(value, int):",
            "        return 'integer'",
            "    if isinstance(value, float):",
            "        return 'number'",
            "    if isinstance(value, str):",
            "        return 'string'",
            "    if isinstance(value, dict):",
            "        return 'object'",
            "    if isinstance(value, list):",
            "        return 'array'",
            "    return type(value).__name__",
            "",
            "def _is_type(value: Any, expected: str) -> bool:",
            "    if expected == 'string':",
            "        return isinstance(value, str)",
            "    if expected == 'integer':",
            "        return isinstance(value, int) and not isinstance(value, bool)",
            "    if expected == 'number':",
            "        return (isinstance(value, int) and not isinstance(value, bool)) or isinstance(value, float)",
            "    if expected == 'boolean':",
            "        return isinstance(value, bool)",
            "    if expected == 'object':",
            "        return isinstance(value, dict)",
            "    if expected == 'array':",
            "        return isinstance(value, list)",
            "    return True",
            "",
            "def _append_type_mismatch(errors: list[dict[str, str]], path: str, expected: str, value: Any) -> None:",
            '    _append_error(errors, path, f"Expected {expected}, got {_type_label(value)}")',
            "",
            "def _enum_values(enum_name: str) -> tuple[tuple[str, ...] | None, str | None]:",
            "    if _generated_enums is None:",
            "        return None, f'enum type _enums.{enum_name} is unavailable'",
            "    enum_cls = getattr(_generated_enums, enum_name, None)",
            "    if enum_cls is None:",
            "        return None, f'enum type _enums.{enum_name} is not defined'",
            "    try:",
            "        return tuple(str(member.value) for member in enum_cls), None",
            "    except Exception:",
            "        return None, f'enum type _enums.{enum_name} failed to load values'",
            "",
        ]
    )

    function_schemas: dict[str, dict[str, Any]] = {}
    function_hints: dict[str, str | None] = {}
    function_order: list[str] = []
    anonymous_by_key: dict[str, str] = {}

    def make_unique_function_name(hint: str | None) -> str:
        base = _sanitize_identifier(hint or "branch")
        candidate = f"_validate_{base}"
        if candidate not in function_schemas:
            return candidate

        suffix = 2
        while True:
            candidate = f"_validate_{base}_{suffix}"
            if candidate not in function_schemas:
                return candidate
            suffix += 1

    def ensure_schema_function(schema_name: str) -> str:
        fn_name = f"_validate_{_sanitize_identifier(schema_name)}"
        if fn_name not in function_schemas:
            schema = ordered_schemas.get(schema_name)
            if isinstance(schema, dict):
                function_schemas[fn_name] = schema
                function_hints[fn_name] = schema_name
                function_order.append(fn_name)
        return fn_name

    def ensure_anonymous_function(schema: dict[str, Any], hint: str | None = None) -> str:
        key = repr(_ordered(schema))
        if key in anonymous_by_key:
            existing = anonymous_by_key[key]
            if function_hints.get(existing) is None and hint is not None:
                function_hints[existing] = hint
            return existing
        fn_name = make_unique_function_name(hint)
        anonymous_by_key[key] = fn_name
        function_schemas[fn_name] = schema
        function_hints[fn_name] = hint
        function_order.append(fn_name)
        return fn_name

    for root in target_roots:
        ensure_schema_function(root)

    def emit_line(block: list[str], indent: int, text: str) -> None:
        block.append(("    " * indent) + text)

    def emit_union(
        schema: dict[str, Any],
        block: list[str],
        indent: int,
        value_expr: str,
        path_expr: str,
        errors_expr: str,
        schema_name_hint: str | None,
    ) -> None:
        branches = schema.get("oneOf", schema.get("anyOf", []))
        branch_funcs: list[tuple[str, str]] = []
        expected_labels: list[str] = []
        has_inline_enum_branch = False

        for branch in branches:
            if not isinstance(branch, dict):
                continue

            if "$ref" in branch:
                ref_name = _resolve_ref(str(branch["$ref"]))
                ref_schema = ordered_schemas.get(ref_name)
                if isinstance(ref_schema, dict):
                    branch_funcs.append((ensure_schema_function(ref_name), _schema_kind(ref_schema) or "value"))
                    expected_labels.append(ref_name)
                continue

            if schema_name_hint and "enum" in branch:
                # Keep enum branches tied to the logical schema name so enum-class resolution stays stable.
                branch_hint = schema_name_hint
                has_inline_enum_branch = True
            else:
                raw_type = branch.get("type")
                branch_type = raw_type if isinstance(raw_type, str) else (_schema_kind(branch) or "branch")
                branch_hint = f"{schema_name_hint}_{branch_type}" if schema_name_hint else str(branch_type)
            fn_name = ensure_anonymous_function(branch, hint=branch_hint)
            branch_funcs.append((fn_name, _schema_kind(branch) or "value"))
            label = branch.get("type") if isinstance(branch.get("type"), str) else (_schema_kind(branch) or "value")
            expected_labels.append(str(label))

        if not branch_funcs:
            return

        emit_line(block, indent, "_matched_union = False")
        for idx, (fn_name, kind) in enumerate(branch_funcs):
            condition = "True" if kind in ("value", "union", None) else f"_is_type({value_expr}, {kind!r})"
            emit_line(block, indent, f"if not _matched_union and {condition}:")
            emit_line(block, indent + 1, f"_branch_errors_{idx}: list[dict[str, str]] = []")
            emit_line(block, indent + 1, f"{fn_name}({value_expr}, {path_expr}, _branch_errors_{idx})")
            emit_line(block, indent + 1, f"if not _branch_errors_{idx}:")
            emit_line(block, indent + 2, "_matched_union = True")

        unique_expected_labels = list(dict.fromkeys(expected_labels))
        emit_line(block, indent, "if not _matched_union:")
        if len(unique_expected_labels) == 1:
            only_label = unique_expected_labels[0]
            if schema_name_hint and only_label == "string" and has_inline_enum_branch:
                schema_label = schema_name_hint.rsplit(".", 1)[-1]
                emit_line(
                    block,
                    indent + 1,
                    f"_append_error({errors_expr}, {path_expr}, "
                    f'f"Expected {schema_label} to be a string value, '
                    f'got {{_type_label({value_expr})}}")',
                )
            else:
                emit_line(block, indent + 1, f"_append_error({errors_expr}, {path_expr}, 'Expected {only_label}')")
        else:
            expected = ", ".join(unique_expected_labels) if unique_expected_labels else "valid branch"
            emit_line(
                block,
                indent + 1,
                f"_append_error({errors_expr}, {path_expr}, "
                f'f"Expected one of: {expected}; got {{_type_label({value_expr})}}")',
            )
        emit_line(block, indent + 1, "return")

    def emit_schema_body(
        schema: dict[str, Any],
        block: list[str],
        indent: int,
        value_expr: str,
        path_expr: str,
        errors_expr: str,
        schema_name_hint: str | None = None,
    ) -> None:
        if schema.get("nullable"):
            emit_line(block, indent, f"if {value_expr} is None:")
            emit_line(block, indent + 1, "return")

        if "$ref" in schema:
            ref_name = _resolve_ref(str(schema["$ref"]))
            ref_schema = ordered_schemas.get(ref_name)
            if isinstance(ref_schema, dict):
                fn = ensure_schema_function(ref_name)
                emit_line(block, indent, f"{fn}({value_expr}, {path_expr}, {errors_expr})")
            return

        if "enum" in schema:
            allowed = tuple(schema.get("enum", []))
            enum_class_name = None
            if schema_name_hint:
                hint_schema = ordered_schemas.get(schema_name_hint)
                hint_is_enum_like = False
                if isinstance(hint_schema, dict):
                    if "enum" in hint_schema:
                        hint_is_enum_like = True
                    else:
                        for combo in ("oneOf", "anyOf"):
                            branches = hint_schema.get(combo, [])
                            if isinstance(branches, list) and any(
                                isinstance(b, dict) and "enum" in b for b in branches
                            ):
                                hint_is_enum_like = True
                                break
                if hint_is_enum_like:
                    candidate = schema_name_hint.rsplit(".", 1)[-1]
                    if candidate and candidate[0].isalpha():
                        enum_class_name = candidate

            if enum_class_name:
                emit_line(
                    block,
                    indent,
                    f"_allowed_values, _enum_error = _enum_values({enum_class_name!r})",
                )
                emit_line(block, indent, "if _enum_error is not None:")
                emit_line(block, indent + 1, f"_append_error({errors_expr}, {path_expr}, _enum_error)")
                emit_line(block, indent + 1, "return")
                emit_line(block, indent, "if _allowed_values is None:")
                emit_line(block, indent + 1, "return")
            else:
                emit_line(block, indent, f"_allowed_values = {allowed!r}")
            emit_line(block, indent, f"if {value_expr} not in _allowed_values:")
            emit_line(
                block,
                indent + 1,
                f"_append_error({errors_expr}, {path_expr}, "
                f"f\"Invalid value '{{{value_expr}}}'. "
                f"Allowed: {{', '.join(str(v) for v in _allowed_values)}}\")",
            )

        if "oneOf" in schema or "anyOf" in schema:
            emit_union(schema, block, indent, value_expr, path_expr, errors_expr, schema_name_hint)
            return

        schema_type = schema.get("type")
        effective_type = schema_type if isinstance(schema_type, str) else _schema_kind(schema)

        if isinstance(effective_type, str) and effective_type not in ("value", "union"):
            emit_line(block, indent, f"if not _is_type({value_expr}, {effective_type!r}):")
            emit_line(
                block,
                indent + 1,
                f"_append_type_mismatch({errors_expr}, {path_expr}, {effective_type!r}, {value_expr})",
            )
            emit_line(block, indent + 1, "return")

        if effective_type == "array":
            items = schema.get("items")
            if isinstance(items, dict):
                item_hint = f"{schema_name_hint}_item" if schema_name_hint else "item"
                item_fn = ensure_anonymous_function(items, hint=item_hint)
                emit_line(block, indent, f"for _idx, _item in enumerate({value_expr}):")
                emit_line(block, indent + 1, f'{item_fn}(_item, f"{{{path_expr}}}[{{_idx}}]", {errors_expr})')
            return

        if effective_type == "object":
            properties = schema.get("properties", {})
            required = schema.get("required", [])
            disc = schema.get("discriminator")
            # When a default_discriminator is configured, the discriminator
            # property is no longer strictly required — it defaults instead.
            disc_prop_with_default = None
            if isinstance(disc, dict) and disc.get("defaultValue") is not None:
                disc_prop_with_default = disc.get("propertyName", "type")
            if isinstance(properties, dict):
                for field in required:
                    if field == disc_prop_with_default:
                        continue  # Skip — handled by default discriminator
                    emit_line(block, indent, f"if {field!r} not in {value_expr}:")
                    emit_line(
                        block,
                        indent + 1,
                        f'_append_error({errors_expr}, f"{{{path_expr}}}.{field}", '
                        f"\"Required property '{field}' is missing\")",
                    )

                for field, field_schema in sorted(properties.items()):
                    if not isinstance(field_schema, dict):
                        continue
                    field_hint = f"{schema_name_hint}_{field}" if schema_name_hint else field
                    field_fn = ensure_anonymous_function(field_schema, hint=field_hint)
                    emit_line(block, indent, f"if {field!r} in {value_expr}:")
                    emit_line(
                        block,
                        indent + 1,
                        f'{field_fn}({value_expr}[{field!r}], f"{{{path_expr}}}.{field}", {errors_expr})',
                    )

            addl = schema.get("additionalProperties")
            if isinstance(addl, dict):
                addl_hint = f"{schema_name_hint}_additional_property" if schema_name_hint else "additional_property"
                addl_fn = ensure_anonymous_function(addl, hint=addl_hint)
                known = tuple(sorted(properties.keys())) if isinstance(properties, dict) else tuple()
                emit_line(block, indent, f"for _key, _item in {value_expr}.items():")
                emit_line(block, indent + 1, f"if _key not in {known!r}:")
                emit_line(block, indent + 2, f'{addl_fn}(_item, f"{{{path_expr}}}.{{_key}}", {errors_expr})')

            disc = schema.get("discriminator")
            if isinstance(disc, dict):
                prop = disc.get("propertyName", "type")
                mapping = disc.get("mapping", {})
                default_disc = disc.get("defaultValue")
                if default_disc is not None:
                    # GAP-01: When a default discriminator is configured, use it
                    # as a fallback instead of rejecting missing values.
                    # Mirrors ResolveDefaultDiscriminator.
                    emit_line(block, indent, f"if {prop!r} in {value_expr}:")
                    field_hint = f"{schema_name_hint}_{prop}" if schema_name_hint else prop
                    for fld, fld_schema in sorted(properties.items()):
                        if fld == prop and isinstance(fld_schema, dict):
                            fld_fn = ensure_anonymous_function(fld_schema, hint=field_hint)
                            emit_line(
                                block,
                                indent + 1,
                                f'{fld_fn}({value_expr}[{prop!r}], f"{{{path_expr}}}.{prop}", {errors_expr})',
                            )
                            break
                    emit_line(
                        block,
                        indent,
                        f"_disc_value = {value_expr}.get({prop!r}, {default_disc!r})",
                    )
                    emit_line(block, indent, "if not isinstance(_disc_value, str):")
                    disc_err = f"Required discriminator '{prop}' is missing or invalid"
                    emit_line(
                        block,
                        indent + 1,
                        f'_append_error({errors_expr}, f"{{{path_expr}}}.{prop}", "{disc_err}")',
                    )
                    emit_line(block, indent + 1, "return")
                else:
                    emit_line(block, indent, f"_disc_value = {value_expr}.get({prop!r})")
                    emit_line(block, indent, "if not isinstance(_disc_value, str):")
                    disc_err = f"Required discriminator '{prop}' is missing or invalid"
                    emit_line(
                        block,
                        indent + 1,
                        f'_append_error({errors_expr}, f"{{{path_expr}}}.{prop}", "{disc_err}")',
                    )
                    emit_line(block, indent + 1, "return")

                for disc_value, ref in sorted(mapping.items()):
                    if not isinstance(ref, str):
                        continue
                    ref_name = _resolve_ref(ref)
                    ref_schema = ordered_schemas.get(ref_name)
                    if not isinstance(ref_schema, dict):
                        continue
                    ref_fn = ensure_schema_function(ref_name)
                    emit_line(block, indent, f"if _disc_value == {disc_value!r}:")
                    emit_line(block, indent + 1, f"{ref_fn}({value_expr}, {path_expr}, {errors_expr})")

    rendered_blocks: dict[str, list[str]] = {}
    idx = 0
    while idx < len(function_order):
        fn_name = function_order[idx]
        idx += 1
        schema = function_schemas[fn_name]
        block: list[str] = [f"def {fn_name}(value: Any, path: str, errors: list[dict[str, str]]) -> None:"]
        schema_name_hint = function_hints.get(fn_name)
        emit_schema_body(schema, block, 1, "value", "path", "errors", schema_name_hint=schema_name_hint)
        if len(block) == 1:
            emit_line(block, 1, "return")
        rendered_blocks[fn_name] = block

    for fn_name in function_order:
        lines.extend(rendered_blocks[fn_name])
        lines.append("")

    lines.append("ROOT_SCHEMAS = " + repr(target_roots))
    lines.append("")

    for root in target_roots:
        class_name = f"{_sanitize_identifier(root)}Validator"
        fn_name = f"_validate_{_sanitize_identifier(root)}"
        lines.append(f"class {class_name}:")
        lines.append('    """Generated validator for the root schema."""')
        lines.append("")
        lines.append("    @staticmethod")
        lines.append("    def validate(payload: Any) -> list[dict[str, str]]:")
        lines.append("        errors: list[dict[str, str]] = []")
        lines.append(f"        {fn_name}(payload, '$', errors)")
        lines.append("        return errors")
        lines.append("")

        wrapper_name = f"validate_{_sanitize_identifier(root)}"
        lines.append(f"def {wrapper_name}(payload: Any) -> list[dict[str, str]]:")
        lines.append(f"    return {class_name}.validate(payload)")
        lines.append("")

    if not target_roots:
        lines.append("def validate_payload(payload: Any) -> list[dict[str, str]]:")
        lines.append("    _ = payload")
        lines.append("    return []")
        lines.append("")

    return "\n".join(lines).rstrip() + "\n"
