import enum
import re
import textwrap
import typing as t

import imy.docstrings
import pytest

import rio.docs
from tests.utils.ruff import ruff_check, ruff_format


def parametrize_with_name(
    param_name: str,
    docs: t.Iterable[
        imy.docstrings.FunctionDocs
        | imy.docstrings.ClassDocs
        | imy.docstrings.AttributeDocs
        | imy.docstrings.ParameterDocs
    ],
):
    def decorator(func):
        return pytest.mark.parametrize(
            param_name,
            docs,
            ids=[doc.name for doc in docs],
        )(func)

    return decorator


def _create_tests() -> None:
    for docs in rio.docs.get_toplevel_documented_objects().values():
        test_cls = _create_tests_for(docs)
        globals()[test_cls.__name__] = test_cls


def _create_tests_for(
    docs: imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs,
) -> type:
    if isinstance(docs, imy.docstrings.FunctionDocs):
        cls = _create_function_tests(docs)
    else:
        cls = _create_class_tests(docs)

    cls.__name__ = _get_name_for_test_class(docs)
    return cls


def _get_name_for_test_class(
    docs: imy.docstrings.FunctionDocs | imy.docstrings.ClassDocs,
) -> str:
    return "Test<" + docs.full_name.removeprefix("rio.").replace(".", "_") + ">"


CODE_BLOCK_PATTERN = re.compile(r"```(.*?)```", flags=re.DOTALL)


def _get_code_blocks(docstring: str | None) -> list[str]:
    """
    Returns a list of all code blocks in the docstring of a component.
    """
    # No docs?
    if not docstring:
        return []

    docstring = textwrap.dedent(docstring)

    # Find any contained code blocks
    result: list[str] = []
    for match in CODE_BLOCK_PATTERN.finditer(docstring):
        block: str = match.group(1)

        # Split into language and source
        linebreak = block.find("\n")
        assert linebreak != -1
        language = block[:linebreak]
        block = block[linebreak + 1 :]

        # Make sure a language is specified
        assert language, "The code block has no language specified"

        if language in ("py", "python"):
            result.append(block)

    return result


def _create_tests_for_docstring(
    docs: imy.docstrings.ClassDocs
    | imy.docstrings.FunctionDocs
    | imy.docstrings.PropertyDocs,
) -> type:
    code_blocks = _get_code_blocks(docs.summary)
    code_blocks += _get_code_blocks(docs.details)

    code_block_ids = [f"code block {nr}" for nr, _ in enumerate(code_blocks, 1)]

    class DocstringTests:
        # __init__ methods don't need a summary or details
        if docs.name != "__init__":

            def test_summary(self) -> None:
                assert docs.summary is not None, f"{docs.name} has no summary"

            def test_details(self) -> None:
                assert docs.details is not None, f"{docs.name} has no details"

        @pytest.mark.parametrize("code", code_blocks, ids=code_block_ids)
        def test_code_block_is_formatted(self, code: str) -> None:
            # Make sure all code blocks are formatted according to ruff
            formatted_code = ruff_format(code)

            # Ruff often inserts 2 empty lines between definitions, but that's
            # really not necessary in docstrings. Collapse them to a single
            # empty line.
            code = code.replace("\n\n\n", "\n\n")
            formatted_code = formatted_code.replace("\n\n\n", "\n\n")

            assert formatted_code == code

        @pytest.mark.parametrize("code", code_blocks, ids=code_block_ids)
        def test_analyze_code_block(self, code: str) -> None:
            # A lot of snippets are missing context, so it's only natural that
            # ruff will find issues with the code. There isn't really anything
            # we can do about it, so we'll just skip those objects.
            if docs.object in (
                rio.Color,
                rio.UserSettings,
            ):
                pytest.xfail()

            errors = ruff_check(code)
            assert not errors, errors

    return DocstringTests


def _create_function_tests(docs: imy.docstrings.FunctionDocs) -> type:
    # If the function is a decorator, there's no need to document that it takes
    # a function/class as an argument
    if docs.metadata.decorator and list(docs.parameters) == ["handler"]:
        parameters = []
    elif docs.has_implicit_first_parameter:
        parameters = list(docs.parameters.values())[1:]
    else:
        parameters = docs.parameters.values()

    DocstringTests = _create_tests_for_docstring(docs)

    class FunctionTests(DocstringTests):
        def test_parameters_are_all_public(self) -> None:
            private_params = [
                param.name for param in parameters if param.name.startswith("_")
            ]
            assert (
                not private_params
            ), f"These parameters should be private: {private_params}"

        def test_parameter_descriptions(self) -> None:
            params_without_description = [
                param.name for param in parameters if not param.description
            ]
            assert not params_without_description, f"These parameters have no description: {params_without_description}"

    return FunctionTests


def _create_property_tests(docs: imy.docstrings.PropertyDocs) -> type:
    DocstringTests = _create_tests_for_docstring(docs)

    class PropertyTests(DocstringTests):
        pass

    return PropertyTests


def _create_class_tests(docs: imy.docstrings.ClassDocs) -> type:
    # Components only need their constructor documented, attributes don't matter
    attributes = (
        []
        if issubclass(docs.object, rio.Component)
        else docs.attributes.values()
    )

    DocstringTests = _create_tests_for_docstring(docs)

    class ClassTests(DocstringTests):
        # Event and Error classes shouldn't be instantiated by the user, so make
        # sure their constructor is marked as private
        if docs.name.endswith("Event") or issubclass(
            docs.object, (BaseException, enum.Enum)
        ):

            def test_constructor_is_private(self):
                assert (
                    "__init__" not in docs.members
                ), f"Constructor of {docs.name} is not marked as private"

        def test_attributes_are_all_public(self):
            private_attrs = [
                attr.name
                for attr in docs.attributes.values()
                if attr.name.startswith("_")
            ]
            assert (
                not private_attrs
            ), f"These attributes should be private: {private_attrs}"

        @parametrize_with_name("attr", attributes)
        def test_attribute_description(
            self, attr: imy.docstrings.AttributeDocs
        ) -> None:
            assert (
                attr.description is not None
            ), f"Attribute {attr.name!r} has no details"

        # Create tests for all members of this class
        for member in docs.members.values():
            if isinstance(member, imy.docstrings.FunctionDocs):
                test = _create_function_tests(member)
            elif isinstance(member, imy.docstrings.PropertyDocs):
                test = _create_property_tests(member)
            else:
                raise Exception(
                    f"Don't know how to create tests for a {type(member).__name__} object"
                )

            test.__name__ = f"Test<{member.name}>"
            vars()[test.__name__] = test

    return ClassTests


_create_tests()
