"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""

import functools
import inspect
import re
from collections import defaultdict
from enum import Enum

from django.db.models import Choices
from django.utils.translation import get_language
from drf_spectacular.drainage import error, warn
from drf_spectacular.hooks import postprocess_schema_enum_id_removal
from drf_spectacular.plumbing import (
    ResolvedComponent,
    deep_import_string,
    list_hash,
    safe_ref,
)
from drf_spectacular.settings import spectacular_settings
from inflection import camelize
from structlog.stdlib import get_logger

LOGGER = get_logger()


# See https://github.com/tfranzel/drf-spectacular/blob/master/drf_spectacular/hooks.py
# and https://github.com/tfranzel/drf-spectacular/issues/520
def postprocess_schema_enums(result, generator, **kwargs):  # noqa: PLR0912, PLR0915
    """
    simple replacement of Enum/Choices that globally share the same name and have
    the same choices. Aids client generation to not generate a separate enum for
    every occurrence. only takes effect when replacement is guaranteed to be correct.
    """

    def is_enum_prop(prop_schema):
        return (
            "enum" in prop_schema
            or prop_schema.get("type") == "array"
            and "enum" in prop_schema.get("items", {})
        )

    def iter_field_schemas():
        def iter_prop_containers(schema, component_name=None):
            if not component_name:
                for _component_name, _schema in schema.items():
                    if spectacular_settings.COMPONENT_SPLIT_PATCH:
                        _component_name = re.sub("^Patched(.+)", r"\1", _component_name)
                    if spectacular_settings.COMPONENT_SPLIT_REQUEST:
                        _component_name = re.sub("(.+)Request$", r"\1", _component_name)
                    yield from iter_prop_containers(_schema, _component_name)
            elif isinstance(schema, list):
                for item in schema:
                    yield from iter_prop_containers(item, component_name)
            elif isinstance(schema, dict):
                if schema.get("properties"):
                    yield component_name, schema["properties"]
                yield from iter_prop_containers(schema.get("oneOf", []), component_name)
                yield from iter_prop_containers(schema.get("allOf", []), component_name)
                yield from iter_prop_containers(schema.get("anyOf", []), component_name)

        def iter_path_parameters():
            for path in result.get("paths", {}).values():
                for operation in path.values():
                    for parameter in operation.get("parameters", []):
                        parameter_schema = parameter.get("schema", {})
                        if is_enum_prop(parameter_schema):
                            # Move description into enum schema
                            if "description" in parameter:
                                parameter_schema["description"] = parameter.pop("description")
                        if "name" not in parameter:
                            continue
                        yield "", {parameter["name"]: parameter_schema}

        component_schemas = result.get("components", {}).get("schemas", {})

        yield from iter_prop_containers(component_schemas)
        yield from iter_path_parameters()

    def create_enum_component(name, schema):
        component = ResolvedComponent(
            name=name,
            type=ResolvedComponent.SCHEMA,
            schema=schema,
            object=name,
        )
        generator.registry.register_on_missing(component)
        return component

    def extract_hash(schema):
        if "x-spec-enum-id" in schema:
            # try to use the injected enum hash first as it generated from (name, value) tuples,
            # which prevents collisions on choice sets only differing in labels not values.
            return schema["x-spec-enum-id"]
        else:
            # fall back to actual list hashing when we encounter enums not generated by us.
            # remove blank/null entry for hashing. will be reconstructed in the last step
            return list_hash([(i, i) for i in schema["enum"] if i not in ("", None)])

    overrides = load_enum_name_overrides()

    prop_hash_mapping = defaultdict(set)
    hash_name_mapping = defaultdict(set)
    # collect all enums, their names and choice sets
    for component_name, props in iter_field_schemas():
        for prop_name, prop_schema in props.items():
            _prop_schema = prop_schema
            if prop_schema.get("type") == "array":
                _prop_schema = prop_schema.get("items", {})
            if "enum" not in _prop_schema:
                continue

            prop_enum_cleaned_hash = extract_hash(_prop_schema)
            prop_hash_mapping[prop_name].add(prop_enum_cleaned_hash)
            hash_name_mapping[prop_enum_cleaned_hash].add((component_name, prop_name))

    # get the suffix to be used for enums from settings
    enum_suffix = spectacular_settings.ENUM_SUFFIX

    # traverse all enum properties and generate a name for the choice set. naming collisions
    # are resolved and a warning is emitted. giving a choice set multiple names is technically
    # correct but potentially unwanted. also emit a warning there to make the user aware.
    enum_name_mapping = {}
    for prop_name, prop_hash_set in prop_hash_mapping.items():
        for prop_hash in prop_hash_set:
            if prop_hash in overrides:
                enum_name = overrides[prop_hash]
            elif len(prop_hash_set) == 1:
                # prop_name has been used exclusively for one choice set (best case)
                enum_name = f"{camelize(prop_name)}{enum_suffix}"
            elif len(hash_name_mapping[prop_hash]) == 1:
                # prop_name has multiple choice sets, but each one limited to one component only
                component_name, _ = next(iter(hash_name_mapping[prop_hash]))
                enum_name = f"{camelize(component_name)}{camelize(prop_name)}{enum_suffix}"
            else:
                enum_name = f"{camelize(prop_name)}{prop_hash[:3].capitalize()}{enum_suffix}"
                warn(
                    f"enum naming encountered a non-optimally resolvable collision for fields "
                    f'named "{prop_name}". The same name has been used for multiple choice sets '
                    f'in multiple components. The collision was resolved with "{enum_name}". '
                    f"add an entry to ENUM_NAME_OVERRIDES to fix the naming."
                )
            if enum_name_mapping.get(prop_hash, enum_name) != enum_name:
                warn(
                    f"encountered multiple names for the same choice set ({enum_name}). This "
                    f"may be unwanted even though the generated schema is technically correct. "
                    f"Add an entry to ENUM_NAME_OVERRIDES to fix the naming."
                )
                del enum_name_mapping[prop_hash]
            else:
                enum_name_mapping[prop_hash] = enum_name
            enum_name_mapping[(prop_hash, prop_name)] = enum_name

    # replace all enum occurrences with a enum schema component. cut out the
    # enum, replace it with a reference and add a corresponding component.
    for _, props in iter_field_schemas():
        for prop_name, _prop_schema in props.items():
            prop_schema = _prop_schema
            is_array = prop_schema.get("type") == "array"
            if is_array:
                prop_schema = prop_schema.get("items", {})

            if "enum" not in prop_schema:
                continue

            prop_enum_original_list = prop_schema["enum"]
            prop_schema["enum"] = [i for i in prop_schema["enum"] if i not in ["", None]]
            prop_hash = extract_hash(prop_schema)
            # when choice sets are reused under multiple names, the generated name cannot be
            # resolved from the hash alone. fall back to prop_name and hash for resolution.
            enum_name = enum_name_mapping.get(prop_hash) or enum_name_mapping[prop_hash, prop_name]

            # split property into remaining property and enum component parts
            enum_schema = {k: v for k, v in prop_schema.items() if k in ["type", "enum"]}
            prop_schema = {
                k: v for k, v in prop_schema.items() if k not in ["type", "enum", "x-spec-enum-id"]
            }

            # separate actual description from name-value tuples
            if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION:
                if prop_schema.get("description", "").startswith("*"):
                    enum_schema["description"] = prop_schema.pop("description")
                elif "\n\n*" in prop_schema.get("description", ""):
                    _, _, post = prop_schema["description"].partition("\n\n*")
                    enum_schema["description"] = "*" + post

            components = [create_enum_component(enum_name, schema=enum_schema)]
            if spectacular_settings.ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE:
                if "" in prop_enum_original_list:
                    components.append(
                        create_enum_component(f"Blank{enum_suffix}", schema={"enum": [""]})
                    )
                if None in prop_enum_original_list:
                    if spectacular_settings.OAS_VERSION.startswith("3.1"):
                        components.append(
                            create_enum_component(f"Null{enum_suffix}", schema={"type": "null"})
                        )
                    else:
                        components.append(
                            create_enum_component(f"Null{enum_suffix}", schema={"enum": [None]})
                        )

            # undo OAS 3.1 type list NULL construction as we cover
            # this in a separate component already
            if spectacular_settings.OAS_VERSION.startswith("3.1") and isinstance(
                enum_schema["type"], list
            ):
                enum_schema["type"] = [t for t in enum_schema["type"] if t != "null"][0]

            if len(components) == 1:
                prop_schema.update(components[0].ref)
            else:
                prop_schema.update({"oneOf": [c.ref for c in components]})

            patch_target = props[prop_name]  # noqa: PLR1733
            if is_array:
                patch_target = patch_target["items"]

            # Replace existing schema information with reference
            patch_target.clear()
            patch_target.update(safe_ref(prop_schema))

    # sort again with additional components
    result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)

    # remove remaining ids that were not part of this hook (operation parameters mainly)
    postprocess_schema_enum_id_removal(result, generator)

    return result


# Fixed version of `load_enum_name_overrides()` with a LRU cache based on language
# *and* enum overrides.
# Without this, API generation breaks if there is more than 1 API present (such as in split APIs)
# Original source: drf-spectacular/drf_spectacular/plumbing.py
def load_enum_name_overrides():
    cache_key = get_language() or ""

    for k, v in sorted(spectacular_settings.ENUM_NAME_OVERRIDES.items()):
        cache_key += f";{k}:{v}"

    return _load_enum_name_overrides(cache_key)


# Original source: drf-spectacular/drf_spectacular/plumbing.py
# Only change: cache_key argument instead of language.
@functools.lru_cache
def _load_enum_name_overrides(cache_key):
    overrides = {}
    for name, _choices in spectacular_settings.ENUM_NAME_OVERRIDES.items():
        choices = _choices
        if isinstance(choices, str):
            choices = deep_import_string(choices)
        if not choices:
            warn(
                f"unable to load choice override for {name} from ENUM_NAME_OVERRIDES. "
                f"please check module path string."
            )
            continue
        if inspect.isclass(choices) and issubclass(choices, Choices):
            choices = choices.choices
        if inspect.isclass(choices) and issubclass(choices, Enum):
            choices = [(c.value, c.name) for c in choices]
        normalized_choices = []
        for choice in choices:
            # Allow None values in the simple values list case
            if isinstance(choice, str) or choice is None:
                # TODO warning
                normalized_choices.append((choice, choice))  # simple choice list
            elif isinstance(choice[1], (list, tuple)):
                normalized_choices.extend(choice[1])  # categorized nested choices
            else:
                normalized_choices.append(choice)  # normal 2-tuple form

        # Get all of choice values that should be used in the hash, blank and
        # None values get excluded in the post-processing hook for enum overrides,
        # so we do the same here to ensure the hashes match
        hashable_values = [
            (value, label) for value, label in normalized_choices if value not in ["", None]
        ]
        overrides[list_hash(hashable_values)] = name

    if len(spectacular_settings.ENUM_NAME_OVERRIDES) != len(overrides):
        error(
            "ENUM_NAME_OVERRIDES has duplication issues. Encountered multiple names "
            "for the same choice set. Enum naming might be unexpected."
        )
    return overrides
