from __future__ import annotations

from typing import Any, Callable

import pytest
import inspect

_MARK_FOR_DISPATCHING = "_speclike_dispatch"

_TEST_PREFIX = "test_"
_ACT_PREFIX = "act_"

class _DispatcherMeta(type):

    _SPEC_ACT_CACHE: dict[type[Spec], set[Callable]] = {}

    def __new__(mcls, name, bases, namespace: dict[str, Any]):
        tests = {}
        for k, v in namespace.items():
            if callable(v):
                if hasattr(v, _MARK_FOR_DISPATCHING):
                    # class specific test name
                    cstname = _TEST_PREFIX + k
                    if not inspect.iscoroutinefunction(v):
                        tests[cstname] = mcls._make_class_specific_test(k)
                    else:
                        tests[cstname] = pytest.mark.asyncio(
                            mcls._make_async_class_specific_test(k)
                        )
        
        for spec, acts in mcls._SPEC_ACT_CACHE.items():
            for act in acts:
                casename, template, ptmark = spec.get_details()
                actname = _ACT_PREFIX + casename
                # common test name
                cmtname = _TEST_PREFIX + casename
                tests[actname] = act
                tests[cmtname] = mcls._make_common_test(template, actname, ptmark)

        mcls._SPEC_ACT_CACHE.clear()

        namespace.update(tests)

        return super().__new__(mcls, name, bases, namespace)

    @classmethod
    def register_spec(mcls, spec: type[Spec], act: Callable):
        if not issubclass(spec, Spec):
            raise TypeError(
                f"spec must be a subclass of \"{Spec.__name__}\". " +
                f"but recieves -> \"{type(spec)}\"."
            )
        if spec not in mcls._SPEC_ACT_CACHE:
            mcls._SPEC_ACT_CACHE[spec] = set()
        mcls._SPEC_ACT_CACHE[spec].add(act)
    
    @staticmethod
    def _make_class_specific_test(k):
        def bridge(self):
            bound_method = getattr(self, k)
            self.dispatch(k, bound_method)
        return bridge

    @staticmethod
    def _make_async_class_specific_test(k):
        async def bridge(self):
            bound_method = getattr(self, k)
            await self.dispatch_async(k, bound_method)
        return bridge

    @staticmethod
    def _make_common_test(template, actname, ptmark):
        def bridge(self, *args, **kwargs):
            bound_act = getattr(self, actname)
            template(bound_act, *args, **kwargs)
        if ptmark:
            bridge.pytestmark = ptmark
        return bridge


class Test(metaclass = _DispatcherMeta):

    def dispatch(self, method_name: str, method: Callable) -> None:
        """Invoke a method representing a test.

        Override this method to customize how test methods are invoked,
        such as adjusting their call signatures.  
        Test skipping must **not** be performed within this method.
        """
        method()
    
    async def dispatch_async(self, method_name: str, method: Callable) -> None:
        """Invoke an asynchronous method representing a test.

        Asynchronous test methods should be awaited within this dispatcher.

        Override this method to customize how asynchronous test methods
        are invoked, such as adjusting their call signatures.  
        Test skipping must **not** be performed within this method.
        """
        await method()

class Spec:

    _FIXED_METHOD_NAME = "spec"

    def __new__(cls, act) -> None:
        _DispatcherMeta.register_spec(cls, act)

    @classmethod
    def get_details(cls) -> tuple[str, Callable, list[pytest.Mark] | None]:
        already_found = False
        for k, v in cls.__dict__.items():
            if k.startswith(cls._FIXED_METHOD_NAME):
                if already_found:
                    raise TypeError(
                        f"Multiple .{cls._FIXED_METHOD_NAME}* methods found. " +
                        f"such as '{k}'"
                    )
                already_found = True
                if not callable(v):
                    raise TypeError(
                        f"{cls.__name__}.{k} is not callable."
                    )
                name = k.replace(cls._FIXED_METHOD_NAME, cls.__name__, 1)
                ptmark = list(v.pytestmark) if hasattr(v, "pytestmark") else None
                return name, v, ptmark
        raise TypeError(
            f"Missing .{cls._FIXED_METHOD_NAME}* method."
        )
    
    def __init__(self):
        raise RuntimeError(
            "Unexpected call. __new__ may have returned a non-None value."
        )
