"""This module gathers functions/classes to extract openAPI specification
from this API.
"""

import importlib.resources
import inspect
import logging
import re
import sys
from typing import Type

import yaml
from apispec import APISpec, BasePlugin
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec.ext.marshmallow.common import (
    get_unique_schema_name,
    make_schema_key,
    resolve_schema_instance,
)
from flask import current_app as app
from flask_restful import Api, Resource
from marshmallow import Schema, fields

from ..resources import api
from ..resources.utils import type2openapi
from ..schemas import *  # noqa: F403 (import all schemas for processing)


def get_schemas_classes(
    namespace: str | None = None,
) -> dict[str, Type[BaseSchema]]:  # noqa: F405
    """Get all schema classes from :mod:`api`.

    :param namespace:
        namespace from which to extract specification
        Defaults: ``__name__``
    :return: schema classes as a dictionary
    """
    namespace = namespace or __name__
    res = {}
    for name, obj in inspect.getmembers(sys.modules[namespace]):
        if inspect.isclass(obj) and issubclass(obj, Schema) and obj != Schema:
            res[name.replace("Schema", "")] = obj
    return res


def enum_fields2properties(self, field, **kwargs):
    """Helper function to convert enum field to openAPI spec.

    :param field:
    :return: openAPI spec
    """
    res = {}
    if isinstance(field, fields.Enum):
        values = []
        for member in field.enum:
            values.append(member.name)
        res["type"] = "string"
        res["enum"] = values
    return res


def pluck_fields2properties(self, field, **kwargs) -> dict:
    """Helper function to convert pluck field to openAPI spec.

    It uses a reference on the plucked field instead of its type.

    :param field:
    :return: openAPI spec
    """
    res = {}
    if isinstance(field, fields.Pluck):
        nested = (
            field.nested
            if isinstance(field.nested, str)
            else field.nested.__name__
        )
        ref = nested.replace("Schema", "")
        ref_field = f"#/components/schemas/{ref}/properties/{field.only[0]}"
        if field.many:
            res["items"] = res.get("items", {})
            res["items"]["$ref"] = ref_field
        else:
            res["$ref"] = ref_field
    return res


def method_fields2properties(self, field, **kwargs) -> dict:
    """Helper function to convert method field to openAPI spec.

    It uses the `field.field` field as the actual field to convert.

    :param field:
    :return: openAPI spec
    """
    ret = {}
    if isinstance(field, FieldMethod):  # noqa: F405
        ret = self.field2property(field.field)
    return ret


class MyMarshmallowPlugin(MarshmallowPlugin):
    """This class is a modified version of the basic Marshmallow plugin.

    It is capable of handling :class:`api.schemas.OneOfSchema` schemas.
    """

    schema_names = {}

    def schema_helper(self, name, _, schema=None, **kwargs):
        """Definition helper that allows using a marshmallow
        :class:`Schema <marshmallow.Schema>` to provide OpenAPI
        metadata.

        It was modified to handle correctly :class:`api.schemas.OneOfSchema`
        schemas.

        :param type|Schema schema: A marshmallow Schema class or instance.
        """
        if schema is None:
            return None

        schema_instance = resolve_schema_instance(schema)

        schema_key = make_schema_key(schema_instance)
        self.warn_if_schema_already_in_spec(schema_key)
        assert self.converter is not None, "init_spec has not yet been called"
        self.converter.refs[schema_key] = name

        if isinstance(schema_instance, OneOfSchema):  # noqa: F405
            json_schema = {
                "oneOf": [],
                "discriminator": {
                    "propertyName": schema_instance.type_field,
                    "mapping": {},
                },
            }
            for subname, sch_init in schema_instance.type_schemas.items():
                sch = sch_init(openapi=True)
                subsch_key = make_schema_key(sch)
                if subsch_key not in self.converter.refs:
                    new_subname = self.schema_names[sch.__class__]
                    self.spec.components.schema(
                        get_unique_schema_name(
                            self.spec.components, new_subname
                        ),
                        schema=sch,
                    )
                sch_ref = (
                    f"#/components/schemas/{self.converter.refs[subsch_key]}"
                )
                json_schema["oneOf"].append({"$ref": sch_ref})
                json_schema["discriminator"]["mapping"][subname] = sch_ref
            return json_schema

        return self.converter.schema2jsonschema(schema_instance)


def deep_update(dico: dict, new_dico: dict):
    """Perform a deep update on the first dictionnary with content of second.

    It handles nested dictionary structures, and lists.
    Everything else is updated the regular way.

    :param dico: dictionnary to update
    :param new_dico: updated data as a dictionnary
    """
    for k, v in new_dico.items():
        if k not in dico:
            dico[k] = v
        elif (  # noqa: E721
            type(dico[k]) == list and type(v) == list
        ):  # Make sure to exclude strings
            dico[k] += v
        elif isinstance(dico[k], dict) and isinstance(v, dict):
            deep_update(dico[k], v)
        else:
            dico[k] = v


class FlaskRestfulPlugin(BasePlugin):
    """This class is a :mod:`apispec` plugin made to handle
    :class:`flask_restful.Resource`.
    """

    def init_spec(self, spec: APISpec) -> None:
        """Initialize plugin with APISpec object

        :param APISpec spec: APISpec object this plugin instance is attached to
        """
        self.spec = spec

    def path_helper(
        self,
        path: str | None = None,
        operations: dict | None = None,
        parameters: list[dict] | None = None,
        *,
        resource: Type[Resource] | None = None,
        api: Api | None,
    ):
        """Add path to openAPI specification.

        :param api: ref to API
        :param path: endpoint of path (unused here)
        :param operations: operations statically defined on path
        :param parameters: parameters statically defined on path
        :param resource: API resource
        :raises ValueError: if `resource` is not found in `api`
        :return: openAPI path specification
        """
        assert resource is not None
        assert api is not None

        parameters = [] or parameters
        operations = {} or operations

        for klass, endpoints, _ in api.resources:
            if klass == resource:
                res = self.resource2openapi(klass, endpoints[0])
                operations.update(res["operations"])
                parameters += res.get("parameters", [])
                return res["path"]
        raise ValueError(f"Resource {resource.__name__} not found in API")

    @staticmethod
    def extract_doc_strings(func):
        """Extract any openAPI useful information from operation doc-string.

        :param func: operation function (get, put, post, etc.)
        :return: operation openAPI specification
        """
        res = {}
        doc = func.__doc__
        if doc is None:
            return res
        res["description"] = ""
        lines = iter(doc.splitlines())
        # Read description
        while (line := next(lines, None)) is not None:
            line = line.lstrip()
            if line.startswith(":"):
                break
            else:
                res["description"] += "\n" + line
        res["description"] = res["description"].strip()
        # Read rest of doc string
        tree = []
        current_node = None
        while line is not None:
            line = line.strip()
            if line == "---":
                # Reaching yaml part => abort
                break
            if re.match(r"^:", line):
                header = re.findall(r"^:[^:]*:", line)[0]
                if header.startswith(":param"):
                    # Parsing a path parameter
                    description = line[len(header) :].strip()
                    type_, arg = (
                        re.sub(r"^:param ", "", header)
                        .rstrip()[:-1]
                        .split(" ")
                    )
                    current_node = ["param", arg, type_, description]
                elif header.startswith(":status"):
                    # Parsing a response status
                    description = line[len(header) :].strip()
                    status = re.findall(r"\d+", header)[0]
                    current_node = ["status", status, description]
                else:
                    current_node = None
                tree.append(current_node)
            elif re.match(r"^.. :quickref: ", line):
                header = re.findall(r"^.. :quickref: ", line)[0]
                quickref = line[len(header) :]
                tag, description = quickref.split(";")[:2]
                current_node = ["quickref", tag, description]
                tree.append(current_node)
            elif current_node is not None:
                current_node[-1] += " " + line
            line = next(lines, None)

        for node in tree:
            match node:
                case None:
                    continue
                case ["param", arg, type_, description]:
                    res["parameters"] = res.get("parameters", [])
                    res["parameters"].append(
                        {
                            "name": arg,
                            "in": "path",
                            "required": True,
                            "schema": {"type": type2openapi(type_)},
                            "description": description.rstrip().lstrip(),
                        }
                    )
                case ["status", status, description]:
                    res["responses"] = res.get("responses", {})
                    res["responses"][str(status)] = {
                        "description": description.lstrip().rstrip()
                    }
                case ["quickref", tag, description]:
                    res["tags"] = res.get("tags", [])
                    res["tags"].append(tag)
                    res["summary"] = description.rstrip().lstrip()
        return res

    def resource2openapi(self, resource: Type[Resource], endpoint: str):
        """Convert API resource to openAPI specication.

        It extract spec from doc-string and special decorators
        (those modifying `func.__apispec__`).

        :param resource:
        :param endpoint: path endpoint
        :return: _description_
        """
        res = {"path": endpoint, "operations": {}}
        for method in resource.methods:
            func = getattr(resource, method.lower())
            operation = FlaskRestfulPlugin.extract_doc_strings(func)
            deep_update(operation, self.apispec_attr2openapi(func) or {})
            operation["responses"] = operation.get("responses", {})
            if "200" not in operation["responses"]:
                operation["responses"]["200"] = {"description": ""}
            res["operations"][method.lower()] = operation

        groups = re.findall(r"<[^<]*>", endpoint)
        if len(groups) == 0:
            return res
        res["parameters"] = []
        for group in groups:
            type_, arg = re.sub(r"[<>]", "", group).split(":")
            res["parameters"].append(
                {
                    "name": arg,
                    "in": "path",
                    "required": True,
                    "schema": {"type": type2openapi(type_)},
                }
            )
            res["path"] = res["path"].replace(group, f"{{{arg}}}")
        return res

    @property
    def marshmallow_plugin(self) -> MarshmallowPlugin | None:
        """Return :class:`MarshmallowPlugin` plugin if it is used to build
        the specification.

        :return:
        """
        for plugin in self.spec.plugins:
            if isinstance(plugin, MarshmallowPlugin):
                return plugin
        return None

    def _find_schema(self, schema: Schema) -> str | None:
        """Find name under which a schema is registered.

        :param schema:
        :return: registered name or ``None``
        """
        key = make_schema_key(schema)
        return self.marshmallow_plugin.converter.refs.get(key, None)

    def apispec_attr2openapi(self, func) -> dict:
        """Extract operation openAPI spec from function decorators.

        It extract spec from special decorators
        (those modifying `func.__apispec__`).

        :param func: operation function (get, put, post, etc.)
        :return: operation openAPI spec
        """
        return {
            **self.apispec_parameters2openapi(func),
            **self.apispec_responses2openapi(func),
            **self.apispec_requestBody2openapi(func),
            **self.apispec_security2openapi(func),
        }

    def apispec_security2openapi(self, func) -> dict:
        """Extract operation security openAPI spec from function decorators.

        It extract spec from `func.__apispec__['security']`.

        :param func: operation function (get, put, post, etc.)
        :return: operation openAPI spec
        """
        if not hasattr(func, "__apispec__"):
            return {}
        apispec_attr: dict = func.__apispec__
        if "security" not in apispec_attr:
            return {}
        return {"security": apispec_attr.get("security", [])}

    def apispec_parameters2openapi(self, func) -> dict:
        """Extract operation parameters openAPI spec from function decorators.

        It extract spec from `func.__apispec__['parameters']`.

        :param func: operation function (get, put, post, etc.)
        :return: operation openAPI spec
        """
        if not hasattr(func, "__apispec__"):
            return {}
        apispec_attr: dict = func.__apispec__
        if "parameters" not in apispec_attr:
            return {}
        return {"parameters": apispec_attr.get("parameters", [])}

    def apispec_requestBody2openapi(self, func) -> dict:
        """Extract operation request body openAPI spec from function
        decorators.

        It extract spec from `func.__apispec__['requestBody']`.

        :param func: operation function (get, put, post, etc.)
        :return: operation openAPI spec
        """
        if not hasattr(func, "__apispec__"):
            return {}
        apispec_attr: dict = func.__apispec__
        if "requestBody" not in apispec_attr:
            return {}
        request = apispec_attr.get("requestBody", {})

        res = {}
        res["requestBody"] = {
            "description": request.get("description", ""),
            "content": request.get("content", {}),
            "required": request.get("required", True),
        }
        schema = request.get("schema", None)
        if isinstance(schema, str):
            deep_update(
                res["requestBody"]["content"],
                {"application/json": {"schema": {"$ref": schema}}},
            )
        elif isinstance(schema, list):
            names = []
            if self.marshmallow_plugin:
                for sch in schema:
                    schema_instance = resolve_schema_instance(sch)
                    name = self._find_schema(schema_instance)
                    if name is None:
                        name = get_unique_schema_name(
                            self.spec.components,
                            type(schema_instance).__name__,
                        )
                        self.spec.components.schema(
                            name, schema=schema_instance
                        )
                    names.append(name)
            deep_update(
                res["requestBody"]["content"],
                {
                    "application/json": {
                        "schema": {
                            "oneOf": [
                                {"$ref": f"#/components/schemas/{name}"}
                                for name in names
                            ]
                        }
                    }
                },
            )
        elif isinstance(schema, type) or isinstance(schema, Schema):
            schema_instance = resolve_schema_instance(schema)
            if self.marshmallow_plugin:
                name = self._find_schema(schema_instance)
                if name is None:
                    name = get_unique_schema_name(
                        self.spec.components, type(schema_instance).__name__
                    )
                    self.spec.components.schema(name, schema=schema_instance)
                if isinstance(schema, Schema) and schema.many:
                    deep_update(
                        res["requestBody"]["content"],
                        {
                            "application/json": {
                                "schema": {
                                    "type": "array",
                                    "items": {
                                        "$ref": f"#/components/schemas/{name}"
                                    },
                                }
                            }
                        },
                    )
                else:
                    deep_update(
                        res["requestBody"]["content"],
                        {
                            "application/json": {
                                "schema": {
                                    "$ref": f"#/components/schemas/{name}"
                                }
                            }
                        },
                    )
        return res

    def apispec_responses2openapi(self, func) -> dict:
        """Extract operation responses openAPI spec from function decorators.

        It extract spec from `func.__apispec__['responses']`.

        :param func: operation function (get, put, post, etc.)
        :return: operation openAPI spec
        """
        if not hasattr(func, "__apispec__"):
            return {}
        apispec_attr: dict = func.__apispec__
        if "responses" not in apispec_attr:
            return {}

        res = {}
        res["responses"] = {}
        for status, response in apispec_attr.get("responses", {}).items():
            res["responses"][str(status)] = {
                "description": response.get("description", ""),
                "content": response.get("content", {}),
            }
            schema = response.get("schema", None)
            if isinstance(schema, str):
                deep_update(
                    res["responses"][str(status)]["content"],
                    {"application/json": {"schema": {"$ref": schema}}},
                )
            elif isinstance(schema, list):
                names = []
                if self.marshmallow_plugin:
                    for sch in schema:
                        schema_instance = resolve_schema_instance(sch)
                        name = self._find_schema(schema_instance)
                        if name is None:
                            name = get_unique_schema_name(
                                self.spec.components,
                                type(schema_instance).__name__,
                            )
                            self.spec.components.schema(
                                name, schema=schema_instance
                            )
                        names.append(name)
                deep_update(
                    res["responses"][str(status)]["content"],
                    {
                        "application/json": {
                            "schema": {
                                "oneOf": [
                                    {"$ref": f"#/components/schemas/{name}"}
                                    for name in names
                                ]
                            }
                        }
                    },
                )
            elif isinstance(schema, type) or isinstance(schema, Schema):
                schema_instance = resolve_schema_instance(schema)
                if self.marshmallow_plugin:
                    name = self._find_schema(schema_instance)
                    if name is None:
                        name = get_unique_schema_name(
                            self.spec.components,
                            type(schema_instance).__name__,
                        )
                        self.spec.components.schema(
                            name, schema=schema_instance
                        )
                    if isinstance(schema, Schema) and schema.many:
                        deep_update(
                            res["responses"][str(status)]["content"],
                            {
                                "application/json": {
                                    "schema": {
                                        "type": "array",
                                        "items": {
                                            "$ref": (
                                                f"#/components/schemas/{name}"
                                            )
                                        },
                                    }
                                }
                            },
                        )
                    else:
                        deep_update(
                            res["responses"][str(status)]["content"],
                            {
                                "application/json": {
                                    "schema": {
                                        "$ref": f"#/components/schemas/{name}"
                                    }
                                }
                            },
                        )

        return res


def extract_openapi(namespace: str | None = None) -> APISpec:
    """Extract OpenAPI specification.

    :param namespace: namespace to limit to when importing schemas
    :return: specification
    """
    # Retrieve swagger config from app config
    swagger_kwargs = app.config.get("SWAGGER")
    API_ROOT = app.config.get("API_ROOT", "/api")
    SERVERS = [
        {
            "url": API_ROOT,
            "description": "Backend server",
        }
    ]
    TITLE = swagger_kwargs.get("title", "DESEASION API docs")
    VERSION = swagger_kwargs.get("version")
    OPENAPI_VERSION = swagger_kwargs.get("openapi", "3.0.2")

    logging.info("Extract openAPI specification")
    spec_path = importlib.resources.files(
        "deseasion.backend.openapi"
    ).joinpath("spec_geo_json.yml")
    with spec_path.open("r") as f:
        geo_json_spec = yaml.safe_load(f)

    ma_plugin = MyMarshmallowPlugin()
    plugins = [ma_plugin, FlaskRestfulPlugin()]
    spec = APISpec(
        TITLE,
        VERSION,
        OPENAPI_VERSION,
        servers=SERVERS,
        components=geo_json_spec.get("components", {}),
        plugins=plugins,
    )
    ma_plugin.converter.add_attribute_function(enum_fields2properties)
    ma_plugin.converter.add_attribute_function(method_fields2properties)

    schema_classes = get_schemas_classes(namespace)
    ma_plugin.schema_names = {klass: k for k, klass in schema_classes.items()}

    for k, klass in schema_classes.items():
        schema_instance = resolve_schema_instance(klass)
        schema_key = make_schema_key(schema_instance)
        if schema_key not in ma_plugin.converter.refs:
            spec.components.schema(
                get_unique_schema_name(spec.components, k), schema=klass
            )
    logging.debug(
        f"Schemas added:{len(spec.components.schemas)} found: "
        f"{len(schema_classes)}"
    )

    spec.components.security_scheme(
        "bearerAuth",
        {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"},
    )
    spec.components.security_scheme(
        "basicAuth",
        {
            "type": "http",
            "scheme": "basic",
            "description": "Provide access token",
        },
    )
    spec.components.security_scheme(
        "refreshAuth",
        {
            "type": "http",
            "scheme": "bearer",
            "bearerFormat": "JWT",
            "description": "Provide refresh token",
        },
    )

    for klass, _, _ in api.resources:
        spec.path(resource=klass, api=api)

    return spec
