from __future__ import annotations

import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path

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


def _schema_allows_null(schema: object) -> bool:
    if not isinstance(schema, dict):
        return False
    # OpenAPI 3.0 legacy
    if schema.get("nullable") is True:
        return True
    t = schema.get("type")
    if t == "null":
        return True
    if isinstance(t, list) and any(x == "null" for x in t):
        return True
    for key in ("oneOf", "anyOf"):
        alts = schema.get(key)
        if isinstance(alts, list) and any(_schema_allows_null(x) for x in alts):
            return True
    return False


def _schema_is_array(schema: object) -> bool:
    if not isinstance(schema, dict):
        return False
    t = schema.get("type")
    if t == "array":
        return True
    if isinstance(t, list) and "array" in t:
        return True
    return False


def _collect_optional_non_nullable_array_fields(spec: dict) -> dict[str, set[str]]:
    """Collect fields where OpenAPI says:
    - property is NOT required
    - property is an array
    - property does NOT allow null

    Motivation:
    - datamodel-code-generator tends to generate `list[T] | None = None` for non-required arrays,
      which makes `model_dump()` output `null` unless you remember to `exclude_none=True`.
    - For response models, `null` violates non-nullable array contracts; and it often breaks frontend
      Zod schemas (expects array, receives null).

    Our strategy is conservative:
    - Only patch arrays (not objects/models) to avoid introducing invalid default dict/object values.
    - Skip arrays with `minItems > 0` because default `[]` would violate the constraint.
    """
    components = spec.get("components")
    if not isinstance(components, dict):
        return {}
    schemas = components.get("schemas")
    if not isinstance(schemas, dict):
        return {}

    out: dict[str, set[str]] = {}
    for schema_name, schema in schemas.items():
        if not isinstance(schema_name, str) or not schema_name:
            continue
        if not isinstance(schema, dict):
            continue
        props = schema.get("properties")
        if not isinstance(props, dict) or not props:
            continue
        required = schema.get("required")
        required_set = set(required) if isinstance(required, list) else set()
        for prop_name, prop_schema in props.items():
            if not isinstance(prop_name, str) or not prop_name:
                continue
            if prop_name in required_set:
                continue
            if not _schema_is_array(prop_schema):
                continue
            if _schema_allows_null(prop_schema):
                continue
            if isinstance(prop_schema, dict):
                min_items = prop_schema.get("minItems")
                if isinstance(min_items, int) and min_items > 0:
                    continue
                if "default" in prop_schema:
                    # Respect explicit defaults; do not override.
                    continue
            out.setdefault(schema_name, set()).add(prop_name)
    return out


def _collect_additional_properties_models(spec: dict) -> set[str]:
    """Collect component models where extra top-level JSON properties are allowed."""
    components = spec.get("components")
    if not isinstance(components, dict):
        return set()
    schemas = components.get("schemas")
    if not isinstance(schemas, dict):
        return set()

    out: set[str] = set()
    for schema_name, schema in schemas.items():
        if not isinstance(schema_name, str) or not schema_name:
            continue
        if not isinstance(schema, dict):
            continue
        if schema.get("type") != "object":
            continue
        if not isinstance(schema.get("properties"), dict):
            continue
        additional_properties = schema.get("additionalProperties")
        if additional_properties is True or isinstance(additional_properties, dict):
            out.add(schema_name)
    return out


def _ensure_field_import(text: str) -> str:
    if re.search(r"^from pydantic import .*\bField\b", text, flags=re.MULTILINE):
        return text
    # Add Field to the first `from pydantic import ...` import (generated code style).
    m = re.search(r"^from pydantic import (?P<body>.+)$", text, flags=re.MULTILINE)
    if not m:
        # Fallback: inject a new import after __future__ line.
        future = "from __future__ import annotations\n\n"
        if text.startswith(future):
            return future + "from pydantic import Field\n\n" + text[len(future) :]
        return "from pydantic import Field\n\n" + text
    body = m.group("body")
    if "Field" in body:
        return text
    replaced = f"from pydantic import {body}, Field"
    return text[: m.start()] + replaced + text[m.end() :]


def _patch_optional_array_fields(*, out_path: Path, spec: dict) -> None:
    targets = _collect_optional_non_nullable_array_fields(spec)
    if not targets:
        return

    text = out_path.read_text(encoding="utf-8")
    lines = text.splitlines(keepends=True)

    class_re = re.compile(r"^class\s+(?P<name>[A-Za-z_]\w*)\b")
    field_start_re = re.compile(r"^\s{4}(?P<name>[A-Za-z_]\w*)\s*:\s*")

    # Index class blocks.
    class_starts: list[tuple[str, int]] = []
    for i, line in enumerate(lines):
        m = class_re.match(line)
        if m:
            class_starts.append((m.group("name"), i))
    if not class_starts:
        return

    class_to_range: dict[str, tuple[int, int]] = {}
    for idx, (name, start) in enumerate(class_starts):
        end = class_starts[idx + 1][1] if idx + 1 < len(class_starts) else len(lines)
        class_to_range[name] = (start, end)

    changed = False
    patched_fields = 0

    for class_name, props in targets.items():
        if class_name not in class_to_range:
            continue
        start, end = class_to_range[class_name]

        # First pass: collect statements for fields in this class block.
        statements: list[tuple[str, int, int, str]] = []
        i = start + 1
        while i < end:
            m = field_start_re.match(lines[i])
            if not m:
                i += 1
                continue
            field_name = m.group("name")
            j = i + 1
            while j < end and not field_start_re.match(lines[j]) and not class_re.match(lines[j]):
                j += 1
            stmt = "".join(lines[i:j])
            statements.append((field_name, i, j, stmt))
            i = j

        # Helper: resolve property name -> python field name.
        def _resolve_field_name(prop_name: str) -> str | None:
            for field_name, _s, _e, stmt in statements:
                if field_name == prop_name:
                    return field_name
            # Match by alias if present.
            alias_pat = re.compile(r"alias=['\"](?P<alias>[^'\"]+)['\"]")
            for field_name, _s, _e, stmt in statements:
                for am in alias_pat.finditer(stmt):
                    if am.group("alias") == prop_name:
                        return field_name
            return None

        for prop_name in sorted(props):
            field_name = _resolve_field_name(prop_name)
            if not field_name:
                continue

            for stmt_field, stmt_start, stmt_end, stmt in statements:
                if stmt_field != field_name:
                    continue
                if "list[" not in stmt:
                    continue
                # Only patch `list[...] | None` patterns with `= None` default.
                if not re.search(r"list\[[^\]]+\]\s*\|\s*None", stmt):
                    continue
                if not re.search(r"=\s*None\b", stmt):
                    continue

                new_stmt = stmt
                new_stmt = re.sub(
                    r"list\[([^\]]+)\]\s*\|\s*None",
                    r"list[\1]",
                    new_stmt,
                    count=1,
                )
                new_stmt = re.sub(
                    r"=\s*None\b",
                    r"= Field(default_factory=list)",
                    new_stmt,
                    count=1,
                )

                if new_stmt != stmt:
                    lines[stmt_start:stmt_end] = [new_stmt]
                    changed = True
                    patched_fields += 1
                break

    if not changed:
        return

    new_text = "".join(lines)
    # Ensure `Field` import exists when we inject `Field(default_factory=list)`.
    new_text = _ensure_field_import(new_text)
    out_path.write_text(new_text, encoding="utf-8")
    # Keep output quiet by default; this is a consistency patch.
    # (Callers can rely on git diff / downstream validation to observe changes.)


def _patch_additional_properties_models(*, out_path: Path, spec: dict) -> None:
    targets = _collect_additional_properties_models(spec)
    if not targets:
        return

    text = out_path.read_text(encoding="utf-8")
    lines = text.splitlines(keepends=True)

    class_re = re.compile(r"^class\s+(?P<name>[A-Za-z_]\w*)\b")
    model_config_re = re.compile(r"^\s{4}model_config\s*=\s*ConfigDict\(")

    class_starts: list[tuple[str, int]] = []
    for i, line in enumerate(lines):
        m = class_re.match(line)
        if m:
            class_starts.append((m.group("name"), i))
    if not class_starts:
        return

    changed = False
    for idx, (class_name, start) in enumerate(class_starts):
        if class_name not in targets:
            continue
        end = class_starts[idx + 1][1] if idx + 1 < len(class_starts) else len(lines)
        config_line = next((i for i in range(start + 1, end) if model_config_re.match(lines[i])), None)
        if config_line is not None:
            config_end = config_line + 1
            while config_end < end and not lines[config_end].startswith("    )"):
                config_end += 1
            if config_end < end:
                config_end += 1
            new_config = [
                "    model_config = ConfigDict(\n",
                "        extra='allow',\n",
                "    )\n",
            ]
            if lines[config_line:config_end] != new_config:
                lines[config_line:config_end] = new_config
                changed = True
            continue
        insert_at = start + 1
        lines[insert_at:insert_at] = [
            "    model_config = ConfigDict(\n",
            "        extra='allow',\n",
            "    )\n",
            "\n",
        ]
        changed = True

    if changed:
        out_path.write_text("".join(lines), encoding="utf-8")


def openapi_to_python_models(
    *,
    root_path: str,
    out_path: str,
    bundle: bool,
    output_model_type: str,
    target_python_version: str | None,
    disable_timestamp: bool,
) -> None:
    root = Path(root_path).resolve()
    out = Path(out_path)

    if out.suffix.lower() != ".py":
        raise ValueError("`--out` 必须以 .py 结尾。")

    out.parent.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory(prefix="scienith_openapi_contract_tooling_py_models_") as td:
        tmp_dir = Path(td)
        bundled_path = tmp_dir / "openapi.bundled.json"

        if bundle:
            spec = bundle_openapi_spec_for_docs(root_path=root)
        else:
            spec = load_openapi_spec(root)

        bundled_path.write_text(json.dumps(spec, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

        cmd = [
            sys.executable,
            "-m",
            "datamodel_code_generator",
            "--input",
            str(bundled_path),
            "--input-file-type",
            "openapi",
            "--output",
            str(out),
            "--output-model-type",
            str(output_model_type),
            "--formatters",
            "black",
            "isort",
        ]
        if str(output_model_type).startswith("pydantic_v2."):
            cmd.append("--use-annotated")
        if target_python_version:
            cmd.extend(["--target-python-version", str(target_python_version)])
        if disable_timestamp:
            cmd.append("--disable-timestamp")

        subprocess.run(cmd, check=True)
        # Post-process generated file to avoid emitting `null` for non-nullable optional array fields.
        _patch_optional_array_fields(out_path=out, spec=spec)
        # datamodel-code-generator currently drops object-level additionalProperties
        # when a schema also declares fixed properties. Keep Python models aligned
        # with OpenAPI/TypeScript by explicitly allowing extra fields there.
        _patch_additional_properties_models(out_path=out, spec=spec)
