from __future__ import annotations

"""OpenAPI request-body schema based fuzzing.

This module is intentionally lightweight (no hypothesis/faker deps).
It generates:
  - schema-valid "edge" payloads (still validate)
  - schema-invalid payloads (should violate validation)

Used only when an OpenAPI spec is attached to the request.
"""

from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class FuzzCase:
    name: str
    body: Any
    is_valid: bool
    details: dict | None = None


def _get_operation(spec: dict, *, path: str, method: str) -> dict | None:
    paths = spec.get("paths") or {}
    op = (paths.get(path) or {}).get(str(method).lower())
    return op if isinstance(op, dict) else None


def get_request_body_schema(*, spec: dict, path: str, method: str) -> dict | None:
    op = _get_operation(spec, path=path, method=method)
    if not op:
        return None

    rb = op.get("requestBody")
    if not isinstance(rb, dict):
        return None

    content = rb.get("content") or {}
    if not isinstance(content, dict):
        return None

    # Prefer json
    app_json = content.get("application/json") or content.get("application/*+json")
    if not isinstance(app_json, dict):
        return None

    schema = app_json.get("schema")
    if isinstance(schema, dict):
        return schema
    return None


# ----------------------------
# Minimal schema resolution
# ----------------------------

def _resolve_ref(schema: dict, *, resolver) -> dict:
    if "$ref" not in schema:
        return schema
    ref = schema.get("$ref")
    try:
        # jsonschema.RefResolver API
        with resolver.resolving(ref) as resolved:  # type: ignore[attr-defined]
            if isinstance(resolved, dict):
                return resolved
    except Exception:
        return schema
    return schema


def _choose_subschema(schema: dict) -> dict:
    """Handle oneOf/anyOf/allOf in a deterministic best-effort manner."""
    for key in ("oneOf", "anyOf"):
        v = schema.get(key)
        if isinstance(v, list) and v and isinstance(v[0], dict):
            return v[0]
    all_of = schema.get("allOf")
    if isinstance(all_of, list) and all_of:
        merged: dict = {}
        for s in all_of:
            if isinstance(s, dict):
                # naive merge
                merged.update({k: v for k, v in s.items() if k not in {"allOf"}})
        return merged or schema
    return schema


def _schema_type(schema: dict) -> str | None:
    t = schema.get("type")
    if isinstance(t, str):
        return t
    if isinstance(t, list) and t:
        # choose first
        if isinstance(t[0], str):
            return t[0]
    # Infer common
    if "properties" in schema:
        return "object"
    if "items" in schema:
        return "array"
    return None


def _string_example(schema: dict) -> str:
    if "enum" in schema and isinstance(schema.get("enum"), list) and schema["enum"]:
        return str(schema["enum"][0])
    if "default" in schema:
        return str(schema["default"])
    if "example" in schema:
        return str(schema["example"])

    min_len = schema.get("minLength")
    try:
        min_len_i = int(min_len) if min_len is not None else 1
    except Exception:
        min_len_i = 1

    fmt = str(schema.get("format") or "")
    if fmt == "date-time":
        base = "2020-01-01T00:00:00Z"
    elif fmt == "date":
        base = "2020-01-01"
    elif fmt == "uuid":
        base = "00000000-0000-0000-0000-000000000000"
    elif fmt == "email":
        base = "a@example.com"
    else:
        base = "x"

    if len(base) >= min_len_i:
        return base
    return base + ("x" * (min_len_i - len(base)))


def _number_example(schema: dict, *, is_int: bool) -> int | float:
    for k in ("default", "example"):
        if k in schema:
            v = schema.get(k)
            if isinstance(v, (int, float)):
                return int(v) if is_int else float(v)

    minimum = schema.get("minimum")
    exclusive_minimum = schema.get("exclusiveMinimum")

    if isinstance(exclusive_minimum, (int, float)):
        x = exclusive_minimum + 1
        return int(x) if is_int else float(x)

    if isinstance(minimum, (int, float)):
        return int(minimum) if is_int else float(minimum)

    return 0


def build_minimal_valid_instance(schema: dict, *, resolver=None, max_depth: int = 6):
    if max_depth <= 0:
        return None

    if resolver and isinstance(schema, dict) and "$ref" in schema:
        schema = _resolve_ref(schema, resolver=resolver)

    if not isinstance(schema, dict):
        return None

    schema = _choose_subschema(schema)

    if "const" in schema:
        return schema["const"]

    t = _schema_type(schema)

    if t == "object":
        props = schema.get("properties") or {}
        req = schema.get("required") or []
        out: dict[str, Any] = {}

        if isinstance(req, list):
            for k in req:
                if k in props and isinstance(props.get(k), dict):
                    out[str(k)] = build_minimal_valid_instance(props[k], resolver=resolver, max_depth=max_depth - 1)

        # If nothing required, pick the first property to make something non-empty
        if not out and isinstance(props, dict) and props:
            first = next(iter(props.keys()))
            if isinstance(props.get(first), dict):
                out[str(first)] = build_minimal_valid_instance(props[first], resolver=resolver, max_depth=max_depth - 1)

        return out

    if t == "array":
        items = schema.get("items")
        if not isinstance(items, dict):
            return []
        min_items = schema.get("minItems")
        try:
            n = int(min_items) if min_items is not None else 1
        except Exception:
            n = 1
        n = max(0, min(n, 3))
        return [build_minimal_valid_instance(items, resolver=resolver, max_depth=max_depth - 1) for _ in range(n)]

    if t == "string":
        return _string_example(schema)

    if t == "integer":
        return int(_number_example(schema, is_int=True))

    if t == "number":
        return float(_number_example(schema, is_int=False))

    if t == "boolean":
        return True

    if t == "null":
        return None

    # unknown schema, best effort
    if "enum" in schema and isinstance(schema.get("enum"), list) and schema["enum"]:
        return schema["enum"][0]

    return None


# ----------------------------
# Case generation
# ----------------------------

def _validate(schema: dict, instance, *, spec: dict) -> bool:
    try:
        import jsonschema  # type: ignore

        resolver = jsonschema.RefResolver.from_schema(spec)  # type: ignore[attr-defined]
        cls = jsonschema.validators.validator_for(schema)  # type: ignore[attr-defined]
        cls.check_schema(schema)
        v = cls(schema, resolver=resolver)
        v.validate(instance)
        return True
    except Exception:
        return False


def generate_request_body_fuzz_cases(
    *,
    spec: dict,
    path: str,
    method: str,
    max_valid: int = 8,
    max_invalid: int = 8,
    max_depth: int = 6,
) -> list[FuzzCase]:
    """Generate fuzz cases for an operation requestBody schema.

    Requires optional dependency `jsonschema` (installed via ai-testing-swarm[openapi]).
    Returns [] if schema missing or jsonschema not available.
    """

    try:
        import jsonschema  # type: ignore
    except ImportError:
        return []

    schema = get_request_body_schema(spec=spec, path=path, method=method)
    if not schema:
        return []

    resolver = jsonschema.RefResolver.from_schema(spec)  # type: ignore[attr-defined]

    base = build_minimal_valid_instance(schema, resolver=resolver, max_depth=max_depth)
    cases: list[FuzzCase] = []

    # Always include a minimal valid payload
    if _validate(schema, base, spec=spec):
        cases.append(FuzzCase(name="openapi_valid_minimal", body=base, is_valid=True))

    # Valid edge-ish variants
    def add_valid(name: str, body, details: dict | None = None):
        if len([c for c in cases if c.is_valid]) >= max_valid:
            return
        if _validate(schema, body, spec=spec):
            cases.append(FuzzCase(name=name, body=body, is_valid=True, details=details))

    def add_invalid(name: str, body, details: dict | None = None):
        if len([c for c in cases if not c.is_valid]) >= max_invalid:
            return
        # Ensure invalid (best-effort)
        if not _validate(schema, body, spec=spec):
            cases.append(FuzzCase(name=name, body=body, is_valid=False, details=details))

    # Only attempt structured mutations for object bodies
    schema2 = _choose_subschema(_resolve_ref(schema, resolver=resolver) if isinstance(schema, dict) else {})
    if _schema_type(schema2) == "object" and isinstance(base, dict):
        props = schema2.get("properties") or {}
        required = schema2.get("required") or []
        additional = schema2.get("additionalProperties")

        # Required field removal (invalid)
        if isinstance(required, list) and required:
            k = str(required[0])
            if k in base:
                b2 = dict(base)
                b2.pop(k, None)
                add_invalid("openapi_invalid_missing_required", b2, {"missing": k})

        # Extra field when additionalProperties is false (invalid)
        if additional is False:
            b2 = dict(base)
            b2["_unexpected"] = "x"
            add_invalid("openapi_invalid_additional_property", b2)

        # Per-property boundary tweaks
        if isinstance(props, dict):
            for pk, ps in list(props.items())[: max(1, (max_valid + max_invalid))]:
                if not isinstance(ps, dict):
                    continue
                ps = _choose_subschema(_resolve_ref(ps, resolver=resolver))
                t = _schema_type(ps)

                if t == "string":
                    min_len = ps.get("minLength")
                    max_len = ps.get("maxLength")
                    enum = ps.get("enum")

                    if isinstance(enum, list) and enum:
                        add_valid(f"openapi_valid_enum_{pk}", {**base, pk: enum[-1]})

                    if isinstance(min_len, int) and min_len >= 0:
                        add_valid(f"openapi_valid_minLength_{pk}", {**base, pk: "x" * min_len})
                        if min_len > 0:
                            add_invalid(f"openapi_invalid_below_minLength_{pk}", {**base, pk: ""})

                    if isinstance(max_len, int) and max_len >= 0:
                        add_valid(f"openapi_valid_maxLength_{pk}", {**base, pk: "x" * max_len})
                        add_invalid(f"openapi_invalid_above_maxLength_{pk}", {**base, pk: "x" * (max_len + 1)})

                    # wrong type
                    add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: 123})

                elif t in {"integer", "number"}:
                    minimum = ps.get("minimum")
                    maximum = ps.get("maximum")
                    if isinstance(minimum, (int, float)):
                        add_valid(f"openapi_valid_min_{pk}", {**base, pk: minimum})
                        add_invalid(f"openapi_invalid_below_min_{pk}", {**base, pk: minimum - 1})
                    if isinstance(maximum, (int, float)):
                        add_valid(f"openapi_valid_max_{pk}", {**base, pk: maximum})
                        add_invalid(f"openapi_invalid_above_max_{pk}", {**base, pk: maximum + 1})
                    add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "x"})

                elif t == "boolean":
                    add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "true"})

                elif t == "array":
                    items = ps.get("items") if isinstance(ps.get("items"), dict) else None
                    min_items = ps.get("minItems")
                    max_items = ps.get("maxItems")
                    if items:
                        item_ex = build_minimal_valid_instance(items, resolver=resolver, max_depth=max_depth - 1)
                    else:
                        item_ex = "x"

                    if isinstance(min_items, int) and min_items > 0:
                        add_valid(f"openapi_valid_minItems_{pk}", {**base, pk: [item_ex for _ in range(min_items)]})
                        add_invalid(f"openapi_invalid_below_minItems_{pk}", {**base, pk: []})

                    if isinstance(max_items, int) and max_items >= 0:
                        add_valid(f"openapi_valid_maxItems_{pk}", {**base, pk: [item_ex for _ in range(max_items)]})
                        add_invalid(
                            f"openapi_invalid_above_maxItems_{pk}",
                            {**base, pk: [item_ex for _ in range(max_items + 1)]},
                        )

                    add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "not-an-array"})

        # Ensure we have at least one invalid case
        add_invalid("openapi_invalid_body_null", None)

    else:
        # For non-object bodies, do a couple generic cases
        add_invalid("openapi_invalid_body_null", None)
        add_invalid("openapi_invalid_wrong_type", {"_": "x"} if not isinstance(base, dict) else "x")

    # De-dupe by name
    seen: set[str] = set()
    out: list[FuzzCase] = []
    for c in cases:
        if c.name in seen:
            continue
        seen.add(c.name)
        out.append(c)

    # Cap total
    valid = [c for c in out if c.is_valid][:max_valid]
    invalid = [c for c in out if not c.is_valid][:max_invalid]
    return valid + invalid
