from __future__ import annotations

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

from scienith_openapi_contract_tooling.openapi.bundle import bundle_openapi_spec_for_docs
from scienith_openapi_contract_tooling.openapi.load import load_openapi_spec


_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


def _ts_string(s: str) -> str:
    return json.dumps(s, ensure_ascii=False)


def _safe_ident(name: str) -> str:
    out = re.sub(r"[^A-Za-z0-9_]", "_", name)
    if out and out[0].isdigit():
        out = "_" + out
    if not out:
        out = "_Schema"
    return out


def _schema_var(name: str) -> str:
    return f"{_safe_ident(name)}Schema"


def _literal(value: Any) -> str:
    if value is None:
        return "z.null()"
    if value is True:
        return "z.literal(true)"
    if value is False:
        return "z.literal(false)"
    if isinstance(value, (int, float)):
        return f"z.literal({value})"
    if isinstance(value, str):
        return f"z.literal({_ts_string(value)})"
    return f"z.literal({json.dumps(value, ensure_ascii=False)})"


def _ref_to_schema_var(ref: str) -> str:
    # after normalize_schema_refs we expect either an internal components ref,
    # or an external ref that still points back to root components.
    prefix = "#/components/schemas/"
    if ref.startswith(prefix):
        return _schema_var(ref[len(prefix) :])
    if "#/components/schemas/" in ref:
        return _schema_var(ref.split("#/components/schemas/", 1)[1])
    raise ValueError(f"不支持的 $ref：{ref}（期望 {prefix}<Name>）")


def _as_schema_dict(node: Any) -> dict[str, Any]:
    if isinstance(node, dict):
        return node
    if node is True:
        return {}
    if node is False:
        return {"not": {}}
    raise ValueError("schema 不是对象/boolean。")


def _apply_common_constraints(expr: str, schema: dict[str, Any]) -> str:
    # string
    if schema.get("type") == "string":
        min_len = schema.get("minLength")
        max_len = schema.get("maxLength")
        if isinstance(min_len, int):
            expr += f".min({min_len})"
        if isinstance(max_len, int):
            expr += f".max({max_len})"
        fmt = schema.get("format")
        if fmt == "uuid":
            expr += ".uuid()"
        elif fmt == "email":
            expr += ".email()"
        elif fmt == "date-time":
            expr += ".datetime()"
        pat = schema.get("pattern")
        if isinstance(pat, str) and pat:
            expr += f".regex(new RegExp({_ts_string(pat)}))"

    # number/integer
    if schema.get("type") in {"number", "integer"}:
        if schema.get("type") == "integer":
            expr += ".int()"
        minimum = schema.get("minimum")
        maximum = schema.get("maximum")
        if isinstance(minimum, (int, float)):
            expr += f".min({minimum})"
        if isinstance(maximum, (int, float)):
            expr += f".max({maximum})"
        ex_min = schema.get("exclusiveMinimum")
        ex_max = schema.get("exclusiveMaximum")
        if isinstance(ex_min, (int, float)):
            expr += f".gt({ex_min})"
        if isinstance(ex_max, (int, float)):
            expr += f".lt({ex_max})"

    # array
    if schema.get("type") == "array":
        min_items = schema.get("minItems")
        max_items = schema.get("maxItems")
        if isinstance(min_items, int):
            expr += f".min({min_items})"
        if isinstance(max_items, int):
            expr += f".max({max_items})"

    return expr


def _wrap_nullable(expr: str, schema: dict[str, Any]) -> str:
    nullable = schema.get("nullable")
    if nullable is True:
        return f"{expr}.nullable()"
    t = schema.get("type")
    if isinstance(t, list) and "null" in t:
        # handled in render_union_types
        return expr
    return expr


def _render_enum(values: list[Any]) -> str:
    if not values:
        return "z.never()"
    if all(isinstance(v, str) for v in values):
        uniq = []
        for v in values:
            if v not in uniq:
                uniq.append(v)
        if len(uniq) >= 1:
            items = ", ".join(_ts_string(v) for v in uniq)
            return f"z.enum([{items}] as const)"
    # fallback: union literals
    items = ", ".join(_literal(v) for v in values)
    if len(values) == 1:
        return _literal(values[0])
    return f"z.union([{items}])"


def _render_union(exprs: list[str]) -> str:
    exprs = [e for e in exprs if e]
    if not exprs:
        return "z.unknown()"
    if len(exprs) == 1:
        return exprs[0]
    return f"z.union([{', '.join(exprs)}])"


def _render_intersection(exprs: list[str]) -> str:
    exprs = [e for e in exprs if e]
    if not exprs:
        return "z.unknown()"
    out = exprs[0]
    for e in exprs[1:]:
        out = f"z.intersection({out}, {e})"
    return out


def _render_schema_expr(schema_any: Any) -> str:
    if schema_any is True:
        return "z.unknown()"
    if schema_any is False:
        return "z.never()"

    schema = _as_schema_dict(schema_any)

    if "$ref" in schema and isinstance(schema["$ref"], str):
        return _ref_to_schema_var(schema["$ref"])

    if "const" in schema:
        return _literal(schema["const"])

    enum = schema.get("enum")
    if isinstance(enum, list):
        return _render_enum(enum)

    all_of = schema.get("allOf")
    if isinstance(all_of, list) and all_of:
        parts = [_render_schema_expr(s) for s in all_of]
        return _wrap_nullable(_render_intersection(parts), schema)

    one_of = schema.get("oneOf")
    if isinstance(one_of, list) and one_of:
        parts = [_render_schema_expr(s) for s in one_of]
        return _wrap_nullable(_render_union(parts), schema)

    any_of = schema.get("anyOf")
    if isinstance(any_of, list) and any_of:
        parts = [_render_schema_expr(s) for s in any_of]
        return _wrap_nullable(_render_union(parts), schema)

    t = schema.get("type")
    if isinstance(t, list) and t:
        # union types
        parts: list[str] = []
        for tt in t:
            if tt == "null":
                parts.append("z.null()")
            else:
                parts.append(_render_schema_expr({**schema, "type": tt}))
        return _render_union(parts)

    if t == "string":
        expr = "z.string()"
        expr = _apply_common_constraints(expr, schema)
        return _wrap_nullable(expr, schema)

    if t == "number":
        expr = "z.number()"
        expr = _apply_common_constraints(expr, schema)
        return _wrap_nullable(expr, schema)

    if t == "integer":
        expr = "z.number()"
        expr = _apply_common_constraints(expr, schema)
        return _wrap_nullable(expr, schema)

    if t == "boolean":
        return _wrap_nullable("z.boolean()", schema)

    if t == "null":
        return "z.null()"

    if t == "array":
        items = schema.get("items", {})
        expr = f"z.array({_render_schema_expr(items)})"
        expr = _apply_common_constraints(expr, schema)
        return _wrap_nullable(expr, schema)

    if t == "object" or "properties" in schema or "additionalProperties" in schema:
        props = schema.get("properties") or {}
        required = schema.get("required") or []
        if not isinstance(props, dict):
            props = {}
        if not isinstance(required, list):
            required = []
        shape_parts: list[str] = []
        for prop_name, prop_schema in props.items():
            key = _ts_string(str(prop_name))
            prop_expr = _render_schema_expr(prop_schema)
            if str(prop_name) not in {str(x) for x in required}:
                prop_expr = f"{prop_expr}.optional()"
            shape_parts.append(f"{key}: {prop_expr}")
        shape = ", ".join(shape_parts)
        expr = f"z.object({{{shape}}})"

        ap = schema.get("additionalProperties", None)
        if ap is False:
            expr += ".strict()"
        elif ap is True or ap is None:
            # default: strict 以便 fail-fast 漂移
            expr += ".strict()"
        elif isinstance(ap, dict) or ap in (True, False):
            expr += f".catchall({_render_schema_expr(ap)})"
        else:
            expr += ".strict()"

        return _wrap_nullable(expr, schema)

    return _wrap_nullable("z.unknown()", schema)


def openapi_to_zod_ts(*, root_path: str, out_path: str, bundle: bool = True) -> None:
    """
    从 OpenAPI 3.1 的 components/schemas 生成 Zod schemas（TypeScript 文件）。

    约束：
    - 仅关注 components/schemas（不生成完整 client）。
    - 默认先对 OpenAPI 做 bundle，再从 bundle 后的 components/schemas 生成，以便稳定处理外部文件 $ref。
    """
    root = Path(root_path).resolve()
    out = Path(out_path)

    if bundle:
        spec = bundle_openapi_spec_for_docs(root_path=root)
        schemas = (((spec.get("components") or {}).get("schemas")) or {})
        if not isinstance(schemas, dict) or not schemas:
            raise ValueError("未找到 components/schemas。")
        schemas = {str(k): _as_schema_dict(v) for k, v in schemas.items()}
    else:
        spec = load_openapi_spec(root)
        schemas = (((spec.get("components") or {}).get("schemas")) or {})
        if not isinstance(schemas, dict) or not schemas:
            raise ValueError("未找到 components/schemas。")
        schemas = {str(k): _as_schema_dict(v) for k, v in schemas.items()}

    lines: list[str] = []
    lines.append("// @ts-nocheck")
    lines.append("// This file is generated by sg-skills (OpenAPI -> Zod).")
    lines.append("// Do not edit manually.")
    lines.append("")
    lines.append('import { z } from "zod";')
    lines.append("")

    for name in sorted(schemas.keys()):
        var = _schema_var(name)
        expr = _render_schema_expr(schemas[name])
        # 用 z.lazy 规避初始化顺序与循环引用
        lines.append(f"export const {var} = z.lazy(() => {expr});")
    lines.append("")
    for name in sorted(schemas.keys()):
        var = _schema_var(name)
        tname = _safe_ident(name)
        if _IDENT_RE.match(tname):
            lines.append(f"export type {tname} = z.infer<typeof {var}>;")
    lines.append("")

    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text("\n".join(lines), encoding="utf-8")
