# SPDX-FileCopyrightText: 2021 Dalibo
#
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

import enum
import typing
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Literal, TypeVar

import psycopg.conninfo
import pydantic
import pydantic_core
from pydantic.v1.utils import lenient_issubclass

from .._compat import NoneType, UnionTypes, assert_never
from ..types import AnsibleArgSpec, AnsibleConfig, Port, StrEnum, field_annotation

ModelType = type[pydantic.BaseModel]
Operation = Literal["create", "update"]


def is_optional(t: Any) -> bool:
    """Return True if the field info is an optional type.

    >>> is_optional(typing.Optional[str])
    True
    >>> is_optional(dict[str, int])
    False
    """
    if (origin := typing.get_origin(t)) is typing.Optional:
        return True
    if origin in UnionTypes:
        args = typing.get_args(t)
        return len(args) == 2 and NoneType in args
    return False


def optional_type(t: Any) -> type:
    """Return the inner type of field, if an Optional.

    >>> optional_type(typing.Optional[str])
    <class 'str'>
    >>> optional_type(dict[str, int])
    Traceback (most recent call last):
        ...
    ValueError: dict[str, int] is not an optional
    """
    if not is_optional(t):
        raise ValueError(f"{t} is not an optional")
    for a in typing.get_args(t):
        if a is not NoneType:
            return a  # type: ignore[no-any-return]
    assert_never(a)


PYDANTIC2ANSIBLE: Mapping[type[Any] | str, AnsibleArgSpec] = {
    bool: {"type": "bool"},
    float: {"type": "float"},
    Port: {"type": "int"},
    int: {"type": "int"},
    str: {"type": "str"},
    pydantic.SecretStr: {"type": "str", "no_log": True},
    datetime: {"type": "str"},
}


def argspec_from_model(model_type: ModelType) -> dict[str, AnsibleArgSpec]:
    """Return the Ansible module argument spec object corresponding to a
    pydantic model class.
    """
    spec = {}

    def description_list(value: str) -> list[str]:
        return list(filter(None, (s.strip() for s in value.rstrip(".").split(". "))))

    for fname, field in model_type.model_fields.items():
        config = field_annotation(field, AnsibleConfig)
        if config is not None and config.hide:
            continue

        ftype = field.annotation
        assert ftype is not None
        assert not isinstance(
            ftype, typing.ForwardRef
        ), f"field {fname!r} of {model_type} is a ForwardRef"
        if is_optional(ftype):
            ftype = optional_type(ftype)

        origin_type = typing.get_origin(ftype)
        if origin_type is typing.Annotated:
            ftype = typing.get_args(ftype)[0]
            assert ftype is not None
        is_model = lenient_issubclass(origin_type or ftype, pydantic.BaseModel)
        is_enum = lenient_issubclass(ftype, enum.Enum)
        is_list = lenient_issubclass(origin_type or ftype, list)
        is_dict = lenient_issubclass(origin_type or ftype, Mapping)

        if config is not None and config.spec is not None:
            arg_spec = config.spec
        else:
            arg_spec = AnsibleArgSpec()
            try:
                arg_spec.update(PYDANTIC2ANSIBLE[ftype])
            except KeyError:
                if is_model:
                    arg_spec = {
                        "type": "dict",
                        "options": argspec_from_model(ftype),
                        "description": description_list(field.description or fname),
                    }
                elif is_enum:
                    if config and config.choices is not None:
                        arg_spec["choices"] = config.choices
                    else:
                        arg_spec["choices"] = [f.value for f in ftype]
                elif origin_type is typing.Literal:  # const or enum
                    if config and config.choices is not None:
                        arg_spec["choices"] = config.choices
                    else:
                        arg_spec["choices"] = list(typing.get_args(ftype))
                elif is_list:
                    arg_spec["type"] = "list"
                    (sub_type,) = typing.get_args(ftype)
                    if typing.get_origin(sub_type) is typing.Annotated:
                        sub_type = typing.get_args(sub_type)[0]
                        assert sub_type is not None
                    if lenient_issubclass(sub_type, pydantic.BaseModel):
                        arg_spec["elements"] = "dict"
                        arg_spec["options"] = argspec_from_model(sub_type)
                    elif lenient_issubclass(sub_type, StrEnum):
                        arg_spec["elements"] = "str"
                    else:
                        arg_spec["elements"] = sub_type.__name__
                elif is_dict:
                    arg_spec["type"] = "dict"
                elif lenient_issubclass(
                    origin_type or ftype,
                    (str, pydantic_core.Url, pydantic_core.MultiHostUrl),
                ):
                    arg_spec["type"] = "str"

        if field.is_required():
            arg_spec.setdefault("required", True)

        if field.default not in (None, pydantic_core.PydanticUndefined):
            default = field.get_default(call_default_factory=True)
            if is_model:
                default = default.model_dump(by_alias=True)
            elif is_enum:
                default = default.value
            arg_spec.setdefault("default", default)

        if field.description:
            arg_spec.setdefault("description", description_list(field.description))
        spec[field.alias or fname] = arg_spec

    return spec


V = TypeVar("V", str, dict[str, Any])


def check_excludes(forbidden: tuple[str, str], value: V) -> V:
    """Make sure that `value` string doesn't contain forbidden string.

    Raises ValueError otherwise.

    >>> check_excludes(["/", "slashes"], "all good")
    'all good'
    >>> check_excludes(["/", "slashes"], "this/is/bad")
    Traceback (most recent call last):
        ...
    ValueError: must not contain slashes
    >>> check_excludes(["password", "a password"], {"password": "paszW0rD"})
    Traceback (most recent call last):
        ...
    ValueError: must not contain a password
    """
    if forbidden[0] in value:
        raise ValueError(f"must not contain {forbidden[1]}")
    return value


def check_conninfo(value: str, exclude: Sequence[tuple[str, str]] = ()) -> str:
    """Check that conninfo string is valid.

    Optionally checks that conninfo doesn't include provided keys.
    Sorts conninfo keys along the way.

    Raise a ValueError if conninfo is invalid.
    """
    try:
        s = psycopg.conninfo.conninfo_to_dict(value)
    except psycopg.ProgrammingError as e:
        raise ValueError(str(e)) from e

    for x in exclude:
        check_excludes(x, s)

    return psycopg.conninfo.make_conninfo(**{k: v for k, v in sorted(s.items())})
