#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program.  If not, see [http://www.gnu.org/licenses/].
import inspect
import re
from datetime import datetime
from types import FunctionType
from typing import Any, Callable, ForwardRef, Sequence, get_args, get_origin

import httpx
import pytest
from bs4 import BeautifulSoup, PageElement, Tag

import telegram
from telegram._utils.defaultvalue import DefaultValue
from telegram._utils.types import FileInput, ODVInput
from telegram.ext import Defaults
from tests.auxil.envvars import RUN_TEST_OFFICIAL

IGNORED_OBJECTS = ("ResponseParameters", "CallbackGame")
GLOBALLY_IGNORED_PARAMETERS = {
    "self",
    "read_timeout",
    "write_timeout",
    "connect_timeout",
    "pool_timeout",
    "bot",
    "api_kwargs",
}

# Types for certain parameters accepted by PTB but not in the official API
ADDITIONAL_TYPES = {
    "photo": ForwardRef("PhotoSize"),
    "video": ForwardRef("Video"),
    "video_note": ForwardRef("VideoNote"),
    "audio": ForwardRef("Audio"),
    "document": ForwardRef("Document"),
    "animation": ForwardRef("Animation"),
    "voice": ForwardRef("Voice"),
    "sticker": ForwardRef("Sticker"),
}

# Exceptions to the "Array of" types, where we accept more types than the official API
# key: parameter name, value: type which must be present in the annotation
ARRAY_OF_EXCEPTIONS = {
    "results": "InlineQueryResult",  # + Callable
    "commands": "BotCommand",  # + tuple[str, str]
    "keyboard": "KeyboardButton",  # + sequence[sequence[str]]
    "reaction": "ReactionType",  # + str
    # TODO: Deprecated and will be corrected (and removed) in next major PTB version:
    "file_hashes": "list[str]",
}

# Special cases for other parameters that accept more types than the official API, and are
# too complex to compare/predict with official API:
COMPLEX_TYPES = {  # (param_name, is_class (i.e appears in a class?)): reduced form of annotation
    ("correct_option_id", False): int,  # actual: Literal
    ("file_id", False): str,  # actual: Union[str, objs_with_file_id_attr]
    ("invite_link", False): str,  # actual: Union[str, ChatInviteLink]
    ("provider_data", False): str,  # actual: Union[str, obj]
    ("callback_data", True): str,  # actual: Union[str, obj]
    ("media", True): str,  # actual: Union[str, InputMedia*, FileInput]
    ("data", True): str,  # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress]
}

# These are param names ignored in the param type checking in classes for the `tg.Defaults` case.
IGNORED_DEFAULTS_PARAM_NAMES = {
    "quote",
    "link_preview_options",
}

# These classes' params are all ODVInput, so we ignore them in the defaults type checking.
IGNORED_DEFAULTS_CLASSES = {"LinkPreviewOptions"}


def _get_params_base(object_name: str, search_dict: dict[str, set[Any]]) -> set[Any]:
    """Helper function for the *_params functions below.
    Given an object name and a search dict, goes through the keys of the search dict and checks if
    the object name matches any of the regexes (keys). The union of all the sets (values) of the
    matching regexes is returned. `object_name` may be a CamelCase or snake_case name.
    """
    out = set()
    for regex, params in search_dict.items():
        if re.fullmatch(regex, object_name):
            out.update(params)
        # also check the snake_case version
        snake_case_name = re.sub(r"(?<!^)(?=[A-Z])", "_", object_name).lower()
        if re.fullmatch(regex, snake_case_name):
            out.update(params)
    return out


# Arguments *added* to the official API
PTB_EXTRA_PARAMS = {
    "send_contact": {"contact"},
    "send_location": {"location"},
    "edit_message_live_location": {"location"},
    "send_venue": {"venue"},
    "answer_inline_query": {"current_offset"},
    "send_media_group": {"caption", "parse_mode", "caption_entities"},
    "send_(animation|audio|document|photo|video(_note)?|voice)": {"filename"},
    "InlineQueryResult": {"id", "type"},  # attributes common to all subclasses
    "ChatMember": {"user", "status"},  # attributes common to all subclasses
    "BotCommandScope": {"type"},  # attributes common to all subclasses
    "MenuButton": {"type"},  # attributes common to all subclasses
    "PassportFile": {"credentials"},
    "EncryptedPassportElement": {"credentials"},
    "PassportElementError": {"source", "type", "message"},
    "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"},
    "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"},
    "InputFile": {"attach", "filename", "obj"},
    "MaybeInaccessibleMessage": {"date", "message_id", "chat"},  # attributes common to all subcls
    "ChatBoostSource": {"source"},  # attributes common to all subclasses
    "MessageOrigin": {"type", "date"},  # attributes common to all subclasses
    "ReactionType": {"type"},  # attributes common to all subclasses
    "InputTextMessageContent": {"disable_web_page_preview"},  # convenience arg, here for bw compat
}


def ptb_extra_params(object_name: str) -> set[str]:
    return _get_params_base(object_name, PTB_EXTRA_PARAMS)


# Arguments *removed* from the official API
# Mostly due to the value being fixed anyway
PTB_IGNORED_PARAMS = {
    r"InlineQueryResult\w+": {"type"},
    r"ChatMember\w+": {"status"},
    r"PassportElementError\w+": {"source"},
    "ForceReply": {"force_reply"},
    "ReplyKeyboardRemove": {"remove_keyboard"},
    r"BotCommandScope\w+": {"type"},
    r"MenuButton\w+": {"type"},
    r"InputMedia\w+": {"type"},
    "InaccessibleMessage": {"date"},
    r"MessageOrigin\w+": {"type"},
    r"ChatBoostSource\w+": {"source"},
    r"ReactionType\w+": {"type"},
}


def ptb_ignored_params(object_name: str) -> set[str]:
    return _get_params_base(object_name, PTB_IGNORED_PARAMS)


IGNORED_PARAM_REQUIREMENTS = {
    # Ignore these since there's convenience params in them (eg. Venue)
    # <----
    "send_location": {"latitude", "longitude"},
    "edit_message_live_location": {"latitude", "longitude"},
    "send_venue": {"latitude", "longitude", "title", "address"},
    "send_contact": {"phone_number", "first_name"},
    # ---->
}


def ignored_param_requirements(object_name: str) -> set[str]:
    return _get_params_base(object_name, IGNORED_PARAM_REQUIREMENTS)


# Arguments that are optional arguments for now for backwards compatibility
BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = {
    # Deprecated by Bot API 7.0, kept for now for bw compat:
    "KeyboardButton": {"request_user"},
    "Message": {
        "forward_from",
        "forward_signature",
        "forward_sender_name",
        "forward_date",
        "forward_from_chat",
        "forward_from_message_id",
        "user_shared",
    },
    "(send_message|edit_message_text)": {
        "disable_web_page_preview",
        "reply_to_message_id",
        "allow_sending_without_reply",
    },
    r"copy_message|send_\w+": {"allow_sending_without_reply", "reply_to_message_id"},
}


def backwards_compat_kwargs(object_name: str) -> set[str]:
    return _get_params_base(object_name, BACKWARDS_COMPAT_KWARGS)


IGNORED_PARAM_REQUIREMENTS.update(BACKWARDS_COMPAT_KWARGS)


def find_next_sibling_until(tag: Tag, name: str, until: Tag) -> PageElement | None:
    for sibling in tag.next_siblings:
        if sibling is until:
            return None
        if sibling.name == name:
            return sibling
    return None


def parse_table(h4: Tag) -> list[list[str]]:
    """Parses the Telegram doc table and has an output of a 2D list."""
    table = find_next_sibling_until(h4, "table", h4.find_next_sibling("h4"))
    if not table:
        return []
    return [[td.text for td in tr.find_all("td")] for tr in table.find_all("tr")[1:]]


def check_method(h4: Tag) -> None:
    name = h4.text  # name of the method in telegram's docs.
    method: FunctionType | None = getattr(telegram.Bot, name, None)  # Retrieve our lib method
    if not method:
        raise AssertionError(f"Method {name} not found in telegram.Bot")

    table = parse_table(h4)

    # Check arguments based on source
    sig = inspect.signature(method, follow_wrapped=True)
    checked = []
    for tg_parameter in table:  # Iterates through each row in the table
        # Check if parameter is present in our method
        param = sig.parameters.get(
            tg_parameter[0]  # parameter[0] is first element (the param name)
        )
        if param is None:
            raise AssertionError(f"Parameter {tg_parameter[0]} not found in {method.__name__}")

        # Check if type annotation is present and correct
        if param.annotation is inspect.Parameter.empty:
            raise AssertionError(
                f"Param {param.name!r} of {method.__name__!r} should have a type annotation"
            )
        if not check_param_type(param, tg_parameter, method):
            raise AssertionError(
                f"Param {param.name!r} of {method.__name__!r} should be {tg_parameter[1]} or "
                "something else!"
            )

        # Now check if the parameter is required or not
        if not check_required_param(tg_parameter, param, method.__name__):
            raise AssertionError(
                f"Param {param.name!r} of method {method.__name__!r} requirement mismatch!"
            )

        # Now we will check that we don't pass default values if the parameter is not required.
        if param.default is not inspect.Parameter.empty:  # If there is a default argument...
            default_arg_none = check_defaults_type(param)  # check if it's None
            if not default_arg_none:
                raise AssertionError(f"Param {param.name!r} of {method.__name__!r} should be None")
        checked.append(tg_parameter[0])

    expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy()
    expected_additional_args |= ptb_extra_params(name)
    expected_additional_args |= backwards_compat_kwargs(name)

    unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args
    if unexpected_args != set():
        raise AssertionError(
            f"In {method.__qualname__}, unexpected args were found: {unexpected_args}."
        )

    kw_or_positional_args = [
        p.name for p in sig.parameters.values() if p.kind != inspect.Parameter.KEYWORD_ONLY
    ]
    non_kw_only_args = set(kw_or_positional_args).difference(checked).difference(["self"])
    non_kw_only_args -= backwards_compat_kwargs(name)
    if non_kw_only_args != set():
        raise AssertionError(
            f"In {method.__qualname__}, extra args should be keyword only "
            f"(compared to {name} in API)"
        )


def check_object(h4: Tag) -> None:
    name = h4.text
    obj = getattr(telegram, name)
    table = parse_table(h4)

    # Check arguments based on source. Makes sure to only check __init__'s signature & nothing else
    sig = inspect.signature(obj.__init__, follow_wrapped=True)

    checked = set()
    fields_removed_by_ptb = ptb_ignored_params(name)
    for tg_parameter in table:
        field: str = tg_parameter[0]  # From telegram docs

        if field in fields_removed_by_ptb:
            continue

        if field == "from":
            field = "from_user"

        param = sig.parameters.get(field)
        if param is None:
            raise AssertionError(f"Attribute {field} not found in {obj.__name__}")
        # Check if type annotation is present and correct
        if param.annotation is inspect.Parameter.empty:
            raise AssertionError(
                f"Param {param.name!r} of {obj.__name__!r} should have a type annotation"
            )
        if not check_param_type(param, tg_parameter, obj):
            raise AssertionError(
                f"Param {param.name!r} of {obj.__name__!r} should be {tg_parameter[1]} or "
                "something else!"
            )
        if not check_required_param(tg_parameter, param, obj.__name__):
            raise AssertionError(f"{obj.__name__!r} parameter {param.name!r} requirement mismatch")

        if param.default is not inspect.Parameter.empty:  # If there is a default argument...
            default_arg_none = check_defaults_type(param)  # check if its None
            if not default_arg_none:
                raise AssertionError(f"Param {param.name!r} of {obj.__name__!r} should be `None`")

        checked.add(field)

    expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy()
    expected_additional_args |= ptb_extra_params(name)
    expected_additional_args |= backwards_compat_kwargs(name)

    unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args
    if unexpected_args != set():
        raise AssertionError(f"In {name}, unexpected args were found: {unexpected_args}.")


def is_parameter_required_by_tg(field: str) -> bool:
    if field in {"Required", "Yes"}:
        return True
    return field.split(".", 1)[0] != "Optional"  # splits the sentence and extracts first word


def check_required_param(
    param_desc: list[str], param: inspect.Parameter, method_or_obj_name: str
) -> bool:
    """Checks if the method/class parameter is a required/optional param as per Telegram docs.

    Returns:
        :obj:`bool`: The boolean returned represents whether our parameter's requirement (optional
        or required) is the same as Telegram's or not.
    """
    is_ours_required = param.default is inspect.Parameter.empty
    telegram_requires = is_parameter_required_by_tg(param_desc[2])
    # Handle cases where we provide convenience intentionally-
    if param.name in ignored_param_requirements(method_or_obj_name):
        return True
    return telegram_requires is is_ours_required


def check_defaults_type(ptb_param: inspect.Parameter) -> bool:
    return DefaultValue.get_value(ptb_param.default) is None


def check_param_type(
    ptb_param: inspect.Parameter, tg_parameter: list[str], obj: FunctionType | type
) -> bool:
    """This function checks whether the type annotation of the parameter is the same as the one
    specified in the official API. It also checks for some special cases where we accept more types

    Args:
        ptb_param (inspect.Parameter): The parameter object from our methods/classes
        tg_parameter (list[str]): The table row corresponding to the parameter from official API.
        obj (object): The object (method/class) that we are checking.

    Returns:
        :obj:`bool`: The boolean returned represents whether our parameter's type annotation is the
        same as Telegram's or not.
    """
    # PRE-PROCESSING:
    # In order to evaluate the type annotation, we need to first have a mapping of the types
    # specified in the official API to our types. The keys are types in the column of official API.
    TYPE_MAPPING: dict[str, set[Any]] = {
        "Integer or String": {int | str},
        "Integer": {int},
        "String": {str},
        r"Boolean|True": {bool},
        r"Float(?: number)?": {float},
        # Distinguishing 1D and 2D Sequences and finding the inner type is done later.
        r"Array of (?:Array of )?[\w\,\s]*": {Sequence},
        r"InputFile(?: or String)?": {FileInput},
    }

    tg_param_type: str = tg_parameter[1]  # Type of parameter as specified in the docs
    is_class = inspect.isclass(obj)
    # Let's check for a match:
    mapped: set[type] = _get_params_base(tg_param_type, TYPE_MAPPING)

    # We should have a maximum of one match.
    assert len(mapped) <= 1, f"More than one match found for {tg_param_type}"

    if not mapped:  # no match found, it's from telegram module
        # it could be a list of objects, so let's check that:
        objs = _extract_words(tg_param_type)
        # We want to store both string version of class and the class obj itself. e.g. "InputMedia"
        # and InputMedia because some annotations might be ForwardRefs.
        if len(objs) >= 2:  # We have to unionize the objects
            mapped_type: tuple[Any, ...] = (_unionizer(objs, False), _unionizer(objs, True))
        else:
            mapped_type = (
                getattr(telegram, tg_param_type),  # This will fail if it's not from telegram mod
                ForwardRef(tg_param_type),
                tg_param_type,  # for some reason, some annotations are just a string.
            )
    elif len(mapped) == 1:
        mapped_type = mapped.pop()

    # Resolve nested annotations to get inner types.
    if (ptb_annotation := list(get_args(ptb_param.annotation))) == []:
        ptb_annotation = ptb_param.annotation  # if it's not nested, just use the annotation

    if isinstance(ptb_annotation, list):
        # Some cleaning:
        # Remove 'Optional[...]' from the annotation if it's present. We do it this way since: 1)
        # we already check if argument should be optional or not + type checkers will complain.
        # 2) we want to check if our `obj` is same as API's `obj`, and since python evaluates
        # `Optional[obj] != obj` we have to remove the Optional, so that we can compare the two.
        if type(None) in ptb_annotation:
            ptb_annotation.remove(type(None))

        # Cleaning done... now let's put it back together.
        # Join all the annotations back (i.e. Union)
        ptb_annotation = _unionizer(ptb_annotation, False)

        # Last step, we need to use get_origin to get the original type, since using get_args
        # above will strip that out.
        wrapped = get_origin(ptb_param.annotation)
        if wrapped is not None:
            # collections.abc.Sequence -> typing.Sequence
            if "collections.abc.Sequence" in str(wrapped):
                wrapped = Sequence
            ptb_annotation = wrapped[ptb_annotation]
        # We have put back our annotation together after removing the NoneType!

    # CHECKING:
    # Each branch may have exits in the form of return statements. If the annotation is found to be
    # correct, the function will return True. If not, it will return False.

    # 1) HANDLING ARRAY TYPES:
    # Now let's do the checking, starting with "Array of ..." types.
    if "Array of " in tg_param_type:
        assert mapped_type is Sequence
        # For exceptions just check if they contain the annotation
        if ptb_param.name in ARRAY_OF_EXCEPTIONS:
            return ARRAY_OF_EXCEPTIONS[ptb_param.name] in str(ptb_annotation)

        pattern = r"Array of(?: Array of)? ([\w\,\s]*)"
        obj_match: re.Match | None = re.search(pattern, tg_param_type)  # extract obj from string
        if obj_match is None:
            raise AssertionError(f"Array of {tg_param_type} not found in {ptb_param.name}")
        obj_str: str = obj_match.group(1)
        # is obj a regular type like str?
        array_of_mapped: set[type] = _get_params_base(obj_str, TYPE_MAPPING)

        if len(array_of_mapped) == 0:  # no match found, it's from telegram module
            # it could be a list of objects, so let's check that:
            objs = _extract_words(obj_str)
            # let's unionize all the objects, with and without ForwardRefs.
            unionized_objs: list[type] = [_unionizer(objs, True), _unionizer(objs, False)]
        else:
            unionized_objs = [array_of_mapped.pop()]

        # This means it is Array of Array of [obj]
        if "Array of Array of" in tg_param_type:
            return any(Sequence[Sequence[o]] == ptb_annotation for o in unionized_objs)

        # This means it is Array of [obj]
        return any(mapped_type[o] == ptb_annotation for o in unionized_objs)

    # 2) HANDLING DEFAULTS PARAMETERS:
    # Classes whose parameters are all ODVInput should be converted and checked.
    if obj.__name__ in IGNORED_DEFAULTS_CLASSES:
        parsed = ODVInput[mapped_type]
        return (ptb_annotation | None) == parsed  # We have to add back None in our annotation
    if not (
        # Defaults checking should not be done for:
        # 1. Parameters that have name conflict with `Defaults.name`
        is_class
        and obj.__name__ in ("ReplyParameters", "Message", "ExternalReplyInfo")
        and ptb_param.name in IGNORED_DEFAULTS_PARAM_NAMES
    ):
        # Now let's check if the parameter is a Defaults parameter, it should be
        for name, _ in inspect.getmembers(Defaults, lambda x: isinstance(x, property)):
            if name == ptb_param.name or "parse_mode" in ptb_param.name:
                # mapped_type should not be a tuple since we need to check for equality:
                # This can happen when the Defaults parameter is a class, e.g. LinkPreviewOptions
                if isinstance(mapped_type, tuple):
                    mapped_type = mapped_type[1]  # We select the ForwardRef
                # Assert if it's ODVInput by checking equality:
                parsed = ODVInput[mapped_type]
                if (ptb_annotation | None) == parsed:  # We have to add back None in our annotation
                    return True
                return False

    # 3) HANDLING OTHER TYPES:
    # Special case for send_* methods where we accept more types than the official API:
    if (
        ptb_param.name in ADDITIONAL_TYPES
        and not isinstance(mapped_type, tuple)
        and obj.__name__.startswith("send")
    ):
        mapped_type = mapped_type | ADDITIONAL_TYPES[ptb_param.name]

    # 4) HANDLING DATETIMES:
    if (
        re.search(
            r"""([_]+|\b)  # check for word boundary or underscore
                date       # check for "date"
                [^\w]*\b   # optionally check for a word after 'date'
            """,
            ptb_param.name,
            re.VERBOSE,
        )
        or "Unix time" in tg_parameter[-1]
    ):
        # TODO: Remove this in v22 when it becomes a datetime
        datetime_exceptions = {
            "file_date",
        }
        if ptb_param.name in datetime_exceptions:
            return True
        # If it's a class, we only accept datetime as the parameter
        mapped_type = datetime if is_class else mapped_type | datetime

    # RESULTS: ALL OTHER BASIC TYPES-
    # Some types are too complicated, so we replace them with a simpler type:
    for (param_name, expected_class), exception_type in COMPLEX_TYPES.items():
        if ptb_param.name == param_name and is_class is expected_class:
            ptb_annotation = exception_type

    # Final check, if the annotation is a tuple, we need to check if any of the types in the tuple
    # match the mapped type.
    if isinstance(mapped_type, tuple) and any(ptb_annotation == t for t in mapped_type):
        return True

    # If the annotation is not a tuple, we can just check if it's equal to the mapped type.
    return mapped_type == ptb_annotation


def _extract_words(text: str) -> set[str]:
    """Extracts all words from a string, removing all punctuation and words like 'and' & 'or'."""
    return set(re.sub(r"[^\w\s]", "", text).split()) - {"and", "or"}


def _unionizer(annotation: Sequence[Any] | set[Any], forward_ref: bool) -> Any:
    """Returns a union of all the types in the annotation. If forward_ref is True, it wraps the
    annotation in a ForwardRef and then unionizes."""
    union = None
    for t in annotation:
        if forward_ref:
            t = ForwardRef(t)  # noqa: PLW2901
        elif not forward_ref and isinstance(t, str):  # we have to import objects from lib
            t = getattr(telegram, t)  # noqa: PLW2901
        union = t if union is None else union | t
    return union


argvalues: list[tuple[Callable[[Tag], None], Tag]] = []
names: list[str] = []

if RUN_TEST_OFFICIAL:
    argvalues = []
    names = []
    request = httpx.get("https://core.telegram.org/bots/api")
    soup = BeautifulSoup(request.text, "html.parser")

    for thing in soup.select("h4 > a.anchor"):
        # Methods and types don't have spaces in them, luckily all other sections of the docs do
        # TODO: don't depend on that
        if "-" not in thing["name"]:
            h4: Tag | None = thing.parent

            if h4 is None:
                raise AssertionError("h4 is None")
            # Is it a method
            if h4.text[0].lower() == h4.text[0]:
                argvalues.append((check_method, h4))
                names.append(h4.text)
            elif h4.text not in IGNORED_OBJECTS:  # Or a type/object
                argvalues.append((check_object, h4))
                names.append(h4.text)


@pytest.mark.skipif(not RUN_TEST_OFFICIAL, reason="test_official is not enabled")
@pytest.mark.parametrize(("method", "data"), argvalues=argvalues, ids=names)
def test_official(method, data):
    method(data)
