from __future__ import annotations

import re
import uuid
from datetime import date, datetime
from enum import Enum
from numbers import Number
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    Generic,
    List,
    Optional,
    Tuple,
    Type,
    TypeVar,
    Union,
)

import dateutil
from typing_extensions import TypeAlias, TypedDict

from great_expectations.compatibility.pydantic import (
    BaseModel,
    Field,
    ValidationError,
    create_model,
    root_validator,
    validator,
)
from great_expectations.compatibility.pydantic import generics as pydantic_generics
from great_expectations.compatibility.typing_extensions import override
from great_expectations.core import (
    ExpectationValidationResult,  # noqa: TC001 # FIXME CoP
)
from great_expectations.expectations.expectation_configuration import (
    ExpectationConfiguration,  # noqa: TC001 # FIXME CoP
)
from great_expectations.render.exceptions import RendererConfigurationError
from great_expectations.render.renderer.observed_value_renderer import ObservedValueRenderState

if TYPE_CHECKING:
    from great_expectations.compatibility.pydantic.typing import (
        AbstractSetIntStr,
        DictStrAny,
        MappingIntStrAny,
    )


class RendererValueType(str, Enum):
    """Type used in renderer param json schema dictionary."""

    ARRAY = "array"
    BOOLEAN = "boolean"
    DATETIME = "datetime"
    NUMBER = "number"
    OBJECT = "object"
    STRING = "string"

    @classmethod
    def from_value(cls, value: Any) -> RendererValueType:  # noqa: PLR0911
        if value is None:
            return RendererValueType.STRING

        if isinstance(value, list):
            return RendererValueType.ARRAY
        elif isinstance(value, bool):
            return RendererValueType.BOOLEAN
        elif isinstance(value, (date, datetime)):
            return RendererValueType.DATETIME
        elif isinstance(value, Number):
            return RendererValueType.NUMBER
        elif isinstance(value, dict):
            return RendererValueType.OBJECT
        elif isinstance(value, (str, uuid.UUID)):
            return RendererValueType.STRING
        else:
            raise TypeError


class RendererSchema(TypedDict):
    """Json schema for values found in renderers."""

    type: RendererValueType


class _RendererValueBase(BaseModel):
    """
    _RendererValueBase is the base for renderer classes that need to override the default pydantic dict behavior.
    """  # noqa: E501 # FIXME CoP

    class Config:
        validate_assignment = True
        arbitrary_types_allowed = True

    @override
    def dict(  # noqa: PLR0913 # FIXME CoP
        self,
        include: Optional[Union[AbstractSetIntStr, MappingIntStrAny]] = None,
        exclude: Optional[Union[AbstractSetIntStr, MappingIntStrAny]] = None,
        by_alias: bool = True,
        skip_defaults: Optional[bool] = None,
        exclude_unset: bool = False,
        exclude_defaults: bool = False,
        exclude_none: bool = True,
    ) -> DictStrAny:
        """
        Override BaseModel dict to make the defaults:
            - by_alias=True because we have an existing attribute named schema, and schema is already a Pydantic
              BaseModel attribute.
            - exclude_none=True to ensure that None values aren't included in the json dict.

        In practice this means the renderer implementer doesn't need to use .dict(by_alias=True, exclude_none=True)
        everywhere.
        """  # noqa: E501 # FIXME CoP
        return super().dict(
            include=include,
            exclude=exclude,
            by_alias=by_alias,
            skip_defaults=skip_defaults,
            exclude_unset=exclude_unset,
            exclude_defaults=exclude_defaults,
            exclude_none=exclude_none,
        )


RendererParams = TypeVar("RendererParams", bound=_RendererValueBase)

RendererValueTypes: TypeAlias = Union[RendererValueType, List[RendererValueType]]

AddParamArgs: TypeAlias = Tuple[Tuple[str, RendererValueTypes], ...]


class RendererTableValue(_RendererValueBase):
    """Represents each value within a row of a header_row or a table."""

    renderer_schema: RendererSchema = Field(alias="schema")
    value: Optional[Any]


class MetaNotesFormat(str, Enum):
    """Possible formats that can be rendered via MetaNotes."""

    STRING = "string"
    MARKDOWN = "markdown"


class MetaNotes(TypedDict):
    """Notes that can be added to the meta field of an Expectation."""

    format: MetaNotesFormat
    content: List[str]


class CodeBlockLanguage(str, Enum):
    JSON = "json"
    PYTHON = "python"
    SQL = "sql"
    YAML = "yaml"


class CodeBlock(TypedDict):
    """A code block of a specified language to be rendered.
    The code_template_str uses $variable substitution of RendererConfiguration.params.
    e.g. RendererConfiguration.params: python_code_block_1, python_code_block_2

    CodeBlock(
        code_template_str="$python_code_block_1\n\n$python_code_block_2",
        language=CodeBlockLanguage.PYTHON,
    )
    """

    code_template_str: str
    language: CodeBlockLanguage


def make_exception_msg(v_type: RendererValueType, input: Any) -> str:
    return f"Param type: <{v_type}> does not match value: <{input}>."


def make_str_exception_msg(v_type: RendererValueType, input: Any) -> str:
    message = f"Value was unable to be represented as a {v_type}"
    try:
        str(input)
    except Exception as e:
        message = f"{message}: {e}"
    return message


def safe_str(input: Any) -> bool:
    try:
        str(input)
        return True
    except Exception:
        return False


def safe_date(input: Any) -> bool:
    try:
        dateutil.parser.parse(input)
        return True
    except Exception:
        return False


PARAM_TYPE_TESTERS: Dict[
    RendererValueType,
    Tuple[
        Callable[[Any], bool],
        Callable[[RendererValueType, Any], str],
    ],
] = {
    RendererValueType.ARRAY: (
        lambda x: isinstance(x, (list, tuple, set)),
        make_exception_msg,
    ),
    RendererValueType.BOOLEAN: (
        lambda x: isinstance(x, bool),
        make_exception_msg,
    ),
    RendererValueType.DATETIME: (
        lambda x: isinstance(x, (date, datetime)) or safe_date(x),
        make_exception_msg,
    ),
    RendererValueType.NUMBER: (
        lambda x: isinstance(x, Number),
        make_exception_msg,
    ),
    RendererValueType.OBJECT: (lambda x: True, make_exception_msg),
    RendererValueType.STRING: (
        lambda x: safe_str(x),
        make_str_exception_msg,
    ),
}


class RendererConfiguration(pydantic_generics.GenericModel, Generic[RendererParams]):
    """
    Configuration object built for each renderer. Operations to be performed strictly on this object at the renderer
        implementation-level.
    """  # noqa: E501 # FIXME CoP

    configuration: Optional[ExpectationConfiguration] = Field(None, allow_mutation=False)
    result: Optional[ExpectationValidationResult] = Field(None, allow_mutation=False)
    runtime_configuration: Optional[dict] = Field({}, allow_mutation=False)
    expectation_type: str = Field("", allow_mutation=False)
    kwargs: dict = Field({}, allow_mutation=False)
    meta_notes: MetaNotes = Field(
        MetaNotes(format=MetaNotesFormat.STRING, content=[]), allow_mutation=False
    )
    template_str: Optional[str] = Field(None, allow_mutation=True)
    code_block: CodeBlock = Field({}, allow_mutation=True)
    header_row: List[RendererTableValue] = Field([], allow_mutation=True)
    table: List[List[RendererTableValue]] = Field([], allow_mutation=True)
    graph: dict = Field({}, allow_mutation=True)
    include_column_name: bool = Field(True, allow_mutation=False)
    _raw_kwargs: dict = Field({}, allow_mutation=False)
    _row_condition: str = Field("", allow_mutation=False)
    params: RendererParams = Field(..., allow_mutation=True)

    class Config:
        validate_assignment = True
        arbitrary_types_allowed = True

    # TODO: Reintroduce this constraint once legacy renderers are deprecated/removed
    # @root_validator(pre=True)
    def _validate_configuration_or_result(cls, values: dict) -> dict:
        if ("configuration" not in values or values["configuration"] is None) and (
            "result" not in values or values["result"] is None
        ):
            raise RendererConfigurationError(  # noqa: TRY003 # FIXME CoP
                "RendererConfiguration must be passed either configuration or result."
            )
        return values

    def __init__(self, **values) -> None:
        values["params"] = _RendererValueBase()
        super().__init__(**values)

    class _RequiredRendererParamArgs(TypedDict):
        """Used for building up a dictionary that is unpacked into RendererParams upon initialization."""  # noqa: E501 # FIXME CoP

        schema: RendererSchema
        value: Any

    class _RendererParamArgs(_RequiredRendererParamArgs, total=False):
        """Used for building up a dictionary that is unpacked into RendererParams upon initialization."""  # noqa: E501 # FIXME CoP

        suite_parameter: Dict[str, Any]

    class _RendererParamBase(_RendererValueBase):
        """
        _RendererParamBase is the base for a param that is added to RendererParams. It contains the validation logic,
            but it is dynamically renamed in order for the RendererParams attribute to have the same name as the param.
        """  # noqa: E501 # FIXME CoP

        renderer_schema: RendererSchema = Field(alias="schema", allow_mutation=False)
        value: Any = Field(allow_mutation=False)
        suite_parameter: Optional[Dict[str, Any]] = Field(allow_mutation=False)
        render_state: Optional[ObservedValueRenderState] = Field(allow_mutation=True)

        class Config:
            validate_assignment = True
            arbitrary_types_allowed = True

        @root_validator(pre=True)
        def _validate_param_type_matches_value(cls, values: dict) -> dict:
            """
            This root_validator ensures that a value can be parsed by its RendererValueType.
            If RendererValueType.OBJECT is passed, it is treated as valid for any value.
            """
            # if render_state is in values, this root_validator is being called
            # on assignment rather than during instantiation
            if not values.get("render_state"):
                param_type: RendererValueType = values["schema"]["type"]
                value: Any = values["value"]

                (tester, error) = PARAM_TYPE_TESTERS[param_type]
                if not tester(value):
                    raise RendererConfigurationError(error(param_type, value))
            return values

        @override
        def __eq__(self, other: object) -> bool:
            if isinstance(other, BaseModel):
                return self.dict() == other.dict()
            elif isinstance(other, dict):
                return self.dict() == other
            else:
                return NotImplemented

        @override
        def __hash__(self) -> int:
            return hash(tuple(sorted(self.dict().items())))

    @staticmethod
    def _get_renderer_value_base_model_type(
        name: str,
    ) -> Type[BaseModel]:
        return create_model(
            name,
            renderer_schema=(
                RendererSchema,
                Field(..., alias="schema"),
            ),
            value=(Union[Any, None], ...),
            __base__=RendererConfiguration._RendererParamBase,
        )

    @staticmethod
    def _get_suite_parameter_params_from_raw_kwargs(
        raw_kwargs: Dict[str, Any],
    ) -> Dict[str, RendererConfiguration._RendererParamArgs]:
        renderer_params_args = {}
        for kwarg_name, value in raw_kwargs.items():
            renderer_params_args[kwarg_name] = RendererConfiguration._RendererParamArgs(
                schema=RendererSchema(type=RendererValueType.OBJECT),
                value=None,
                suite_parameter={
                    "schema": RendererSchema(type=RendererValueType.OBJECT),
                    "value": value,
                },
            )
        return renderer_params_args

    @root_validator()
    def _validate_and_set_renderer_attrs(cls, values: dict) -> dict:
        if (
            "result" in values
            and values["result"] is not None
            and values["result"].expectation_config is not None
        ):
            expectation_configuration: ExpectationConfiguration = values[
                "result"
            ].expectation_config
            values["expectation_type"] = expectation_configuration.type
            values["kwargs"] = expectation_configuration.kwargs
            raw_configuration: ExpectationConfiguration = (
                expectation_configuration.get_raw_configuration()
            )
            if "_raw_kwargs" not in values:
                values["_raw_kwargs"] = {
                    key: value
                    for key, value in raw_configuration.kwargs.items()
                    if (key, value) not in values["kwargs"].items()
                }
                renderer_params_args: Dict[str, RendererConfiguration._RendererParamArgs] = (
                    RendererConfiguration._get_suite_parameter_params_from_raw_kwargs(
                        raw_kwargs=values["_raw_kwargs"]
                    )
                )
                values["_params"] = (
                    {**values["_params"], **renderer_params_args}
                    if values.get("_params")
                    else renderer_params_args
                )
        elif "configuration" in values and values["configuration"] is not None:
            values["expectation_type"] = values["configuration"].type
            values["kwargs"] = values["configuration"].kwargs
            # description is the template_str override
            if values["configuration"].description:
                values["template_str"] = values["configuration"].description

        return values

    @root_validator()
    def _validate_for_include_column_name(cls, values: dict) -> dict:
        if values.get("runtime_configuration"):
            values["include_column_name"] = (
                values["runtime_configuration"].get("include_column_name") is not False
            )
        return values

    @staticmethod
    def _get_row_condition_params(
        row_condition_str: str,
    ) -> Dict[str, RendererConfiguration._RendererParamArgs]:
        row_condition_str = RendererConfiguration._parse_row_condition_str(
            row_condition_str=row_condition_str
        )
        row_conditions_list: List[str] = (
            RendererConfiguration._get_row_conditions_list_from_row_condition_str(
                row_condition_str=row_condition_str
            )
        )
        renderer_params_args = {}
        for idx, condition in enumerate(row_conditions_list):
            name = f"row_condition__{idx!s}"
            value = condition.replace(" NOT ", " not ")
            renderer_params_args[name] = RendererConfiguration._RendererParamArgs(
                schema=RendererSchema(type=RendererValueType.STRING), value=value
            )
        return renderer_params_args

    @root_validator()
    def _validate_for_row_condition(cls, values: dict) -> dict:
        kwargs: Dict[str, Any]
        if (
            "result" in values
            and values["result"] is not None
            and values["result"].expectation_config is not None
        ):
            kwargs = values["result"].expectation_config.kwargs
        else:
            kwargs = values["configuration"].kwargs

        values["_row_condition"] = kwargs.get("row_condition", "")
        if values["_row_condition"]:
            renderer_params_args: Dict[str, RendererConfiguration._RendererParamArgs] = (
                RendererConfiguration._get_row_condition_params(
                    row_condition_str=values["_row_condition"],
                )
            )
            values["_params"] = (
                {**values["_params"], **renderer_params_args}
                if values.get("_params")
                else renderer_params_args
            )
        return values

    @root_validator()
    def _validate_for_meta_notes(cls, values: dict) -> dict:
        meta_notes: Optional[dict[str, Optional[dict[str, list[str] | tuple[str] | str]]]]
        if (
            "result" in values
            and values["result"] is not None
            and values["result"].expectation_config is not None
        ):
            meta_notes = values["result"].expectation_config.meta.get("notes")
        else:
            meta_notes = values["configuration"].meta.get("notes")

        if meta_notes and isinstance(meta_notes, dict):
            meta_notes_content = meta_notes.get("content")

            if isinstance(meta_notes_content, (list, tuple)):
                meta_notes["content"] = list(meta_notes_content)
            elif isinstance(meta_notes_content, str):
                meta_notes["content"] = [meta_notes_content]
            values["meta_notes"] = meta_notes
        elif meta_notes and isinstance(meta_notes, str):
            values["meta_notes"] = {
                "content": [meta_notes],
                "format": MetaNotesFormat.STRING,
            }

        return values

    @root_validator()
    def _validate_for_params(cls, values: dict) -> dict:
        _params: Optional[Dict[str, Dict[str, Union[str, Dict[str, RendererValueType]]]]] = (
            values.get("_params")
        )
        # this logic loads the params that exist
        # upon RendererConfiguration instantiation, e.g. row_condition
        if _params and len(values["params"].__dict__) == 0:
            renderer_param_definitions: Dict[str, Any] = {}
            for name in _params:
                renderer_param_type: Type[BaseModel] = (
                    RendererConfiguration._get_renderer_value_base_model_type(name=name)
                )
                renderer_param_definitions[name] = (
                    Optional[renderer_param_type],
                    ...,
                )
            renderer_params: Type[BaseModel] = create_model(
                "RendererParams",
                **renderer_param_definitions,
                __base__=_RendererValueBase,
            )
            values["params"] = renderer_params(**_params)

        return values

    @staticmethod
    def _get_row_conditions_list_from_row_condition_str(
        row_condition_str: str,
    ) -> List[str]:
        # extract the variables to be substituted
        row_conditions_list = re.split(r"AND|OR|NOT(?! in)|col\(\"|\"\)", row_condition_str)
        row_conditions_list = [
            condition.strip() for condition in row_conditions_list if condition.strip()
        ]
        return row_conditions_list

    @staticmethod
    def _parse_row_condition_str(row_condition_str: str) -> str:
        if not row_condition_str:
            row_condition_str = "True"

        row_condition_str = (
            row_condition_str.replace("&", " AND ")
            .replace(" and ", " AND ")
            .replace("|", " OR ")
            .replace(" or ", " OR ")
            .replace("~", " NOT ")
            .replace(" not ", " NOT ")
        )
        row_condition_str = " ".join(row_condition_str.split())

        # replace tuples of values by lists of values
        tuples_list = re.findall(r"\([^()]*,[^()]*\)", row_condition_str)
        for value_tuple in tuples_list:
            value_list = value_tuple.replace("(", "[").replace(")", "]")
            row_condition_str = row_condition_str.replace(value_tuple, value_list)

        return row_condition_str

    @staticmethod
    def _get_row_condition_string(row_condition_str: str) -> str:
        row_condition_str = RendererConfiguration._parse_row_condition_str(
            row_condition_str=row_condition_str
        )
        row_conditions_list: List[str] = (
            RendererConfiguration._get_row_conditions_list_from_row_condition_str(
                row_condition_str=row_condition_str
            )
        )
        for idx, condition in enumerate(row_conditions_list):
            row_condition_str = row_condition_str.replace(condition, f"$row_condition__{idx!s}")
        row_condition_str = row_condition_str.lower()
        return f"If {row_condition_str}, then "

    @validator("template_str")
    def _set_template_str(cls, v: str, values: dict) -> str:
        if values.get("_row_condition"):
            row_condition_str: str = RendererConfiguration._get_row_condition_string(
                row_condition_str=values["_row_condition"]
            )
            v = row_condition_str + v

        return v

    @staticmethod
    def _choose_param_type_for_value(
        param_types: List[RendererValueType], value: Any
    ) -> RendererValueType:
        for param_type in param_types:
            try:
                renderer_param: Type[BaseModel] = (
                    RendererConfiguration._get_renderer_value_base_model_type(name="try_param")
                )
                renderer_param(schema=RendererSchema(type=param_type), value=value)
                return param_type
            except ValidationError:
                pass

        raise RendererConfigurationError(  # noqa: TRY003 # FIXME CoP
            f"None of the param_types: {[param_type.value for param_type in param_types]} match the value: {value}"  # noqa: E501 # FIXME CoP
        )

    def add_param(
        self,
        name: str,
        param_type: Optional[RendererValueTypes] = None,
        value: Optional[Any] = None,
    ) -> None:
        """Adds a param that can be substituted into a template string during rendering.

        Attributes:
            name (str): A name for the attribute to be added to the params of this RendererConfiguration instance.
            param_type (one or a list of RendererValueTypes): The possible types for the value being substituted. If
                zero or more than one param_type is passed, inference based on param value will be performed,
                and the first param_type to match the value will be selected.
                    One of:
                     - array
                     - boolean
                     - date
                     - number
                     - string
            value (Optional[Any]): The value to be substituted into the template string. If no value is
                provided, a value lookup will be attempted in RendererConfiguration.kwargs using the
                provided name.

        Returns:
            None
        """  # noqa: E501 # FIXME CoP
        renderer_param: Type[BaseModel] = RendererConfiguration._get_renderer_value_base_model_type(
            name=name
        )

        if value is None:
            value = self.kwargs.get(name)

        if param_type is None:
            param_type = sorted(
                RendererValueType,
                key=lambda x: (
                    # in order to infer type correctly
                    # object must be last in the list
                    # as it is permissive to any value
                    x.value == "object",
                    # and string must be second to last
                    # as it is permissive to string-able value
                    x.value == "string",
                    x.value,
                ),
            )

        if isinstance(value, dict) and "$PARAMETER" in value:
            param_type = RendererValueType.OBJECT
        elif isinstance(param_type, list) and value is not None:
            param_type = RendererConfiguration._choose_param_type_for_value(
                param_types=param_type, value=value
            )

        if value is None:
            self.params.__dict__[name] = None
            return

        assert isinstance(param_type, RendererValueType)

        # if we already moved the suite parameter raw_kwargs to a param,
        # we need to combine the param passed to add_param() with those existing raw_kwargs
        if name in self.params.__dict__ and self.params.__dict__[name]["suite_parameter"]:
            self.params.__dict__[name] = renderer_param(
                schema=RendererSchema(type=param_type),
                value=value,
                suite_parameter=self.params.__dict__[name]["suite_parameter"],
            )
        else:
            self.params.__dict__[name] = renderer_param(
                schema=RendererSchema(type=param_type),
                value=value,
            )
