from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Callable, Self, TypeVar

import inspect

import pytest


MOD_NAME = "speclike"

T = TypeVar("T", bound = Callable)

_MARK_DECO = f"_{MOD_NAME}_deco_"
_REF_DISPATCHER = f"_{MOD_NAME}_dispatcher"
_NAME_ON_NS = f"_{MOD_NAME}_name_on_namespace"
_OWNER = f"_{MOD_NAME}_owner"

class TargetKind(Enum):
    UNDETERMINED = 0
    EX_SPEC_ACT = auto()
    EX_SPEC_DISPATCHER = auto()
    INDIVIDUAL_TEST_BODY = auto()
    

@dataclass(slots = True)
class _Decorator:
    """
    Internal, single-use decorator.

    Each instance is disposable and cannot be reused once applied.  
    A function named "_" is treated as an actor of an externally defined spec.
    """
    
    label: object | None = field(default = None)
    label_as_pytestmark: bool = field(default = False)
    _param: tuple[tuple, dict] | None = field(init = False, default = None)
    _ptms: list = field(init = False, default_factory = list)
    # Lazy initialization until __call__.
    _ref_dispatcher: Callable | None = field(init = False, default = None)
    _returns_target_already: bool = field(init = False, default = False)

    def __call__(self, target: T) -> T | Self:
        if self._returns_target_already:
            raise RuntimeError(
                f"Attempted to process {target.__qualname__}, "
                f"but the decorator has already finished."
            )
        if hasattr(target, _MARK_DECO):
            # On the second decoration, treat it as a reference to dispatcher.
            self._ref_dispatcher = target
            return self
        if target.__name__ == "_":
            # If the name is "_", treat it as an actor and set reference to dispatcher.
            if self._ref_dispatcher is None:
                raise RuntimeError(
                    f"Missing dispatcher correspond to {target.__qualname__}."
                )
            if hasattr(target, "pytestmark"):
                raise ValueError(
                    f"Pytestmark can not be applied to actor. " + 
                    f"Actor name {target.__qualname__}"
                )
            setattr(target, _MARK_DECO, TargetKind.EX_SPEC_ACT)
            setattr(target, _REF_DISPATCHER, self._ref_dispatcher)
            self._returns_target_already = True
            return target
        
        # Otherwise, treat it as either the individual test target or 
        # the dispatcher itself.
        self._prepare_pytestmark_attr_as_list(target)
        setattr(target, _MARK_DECO, TargetKind.UNDETERMINED)
        if self.label_as_pytestmark and self.label is not None:
            if isinstance(self.label, str):
                target.pytestmark.append(getattr(pytest.mark, self.label))
            else:
                target.pytestmark.append(self.label)
        if self._param:
            param_mark = self._synth_parametrize_mark(target, self._param)
            target.pytestmark.append(param_mark)
        target.pytestmark.extend(self._ptms)

        self._returns_target_already = True
        return target

    def follows(self, *argvalues, **options) -> Self:
        self._param = argvalues, options
        return self
    
    def skip(self) -> Self:
        return self.ptm(pytest.mark.skip("User specified."))

    def ptm(self, *pytestmark) -> Self:
        self._ptms.extend(pytestmark)
        return self

    def _prepare_pytestmark_attr_as_list(self, target: Callable) -> None:
        ptm = getattr(target, "pytestmark", None)
        if ptm:
            if not isinstance(ptm, list):
                ptm = list(ptm)
        else:
            ptm = []
        setattr(target, "pytestmark", ptm)

    def _synth_parametrize_mark(self, target: Callable, unit: tuple[tuple, dict]):
        args, kwargs = unit
        sig = inspect.signature(target)
        params = list(sig.parameters.keys())[1:]
        argnames = ",".join(params)
        if len(args) == 1 and isinstance(args[0], (list, tuple)):
            argvalues = args[0]
        else:
            argvalues = args

        return pytest.mark.parametrize(argnames, argvalues, **kwargs)


class _ExSpecNamespace(dict):
    def __setitem__(self, key, value):
        if hasattr(value, _MARK_DECO):
            if getattr(value, _MARK_DECO) is TargetKind.UNDETERMINED:
                setattr(value, _MARK_DECO, TargetKind.EX_SPEC_DISPATCHER)
                setattr(value, _NAME_ON_NS, key)

class _ExSpecMeta(type):

    def __prepare__(self, name, bases, **kwargs) -> _ExSpecNamespace:
        return _ExSpecNamespace()

    def __new__(mcls, name, bases, namespace: _ExSpecNamespace):
        cls = super().__new__(mcls, bases, name, namespace)
        for v in cls.__dict__.values():
            if hasattr(v, _MARK_DECO):
                if getattr(v, _MARK_DECO) is TargetKind.EX_SPEC_DISPATCHER:
                    setattr(v, _OWNER, cls)
        return cls

class ExSpec(metaclass = _ExSpecMeta):
    """
    Base class for externally defined spec dispatchers.

    "Ex" stands for "externally defined" (not "external test").
    Inherit this when grouping dispatcher functions defined
    outside of a Spec class.
    """
    pass

class CaseBase:
    """
    Base provider of decorator instances.

    Inherit this class when implementing a custom decorator factory.
    """
    __slots__ = ("_as_pytestmark",)

    def __init__(self, as_pytestmark: bool = False):
        self._as_pytestmark = as_pytestmark
    
    def d(self, label_object: object | None) -> _Decorator:
        return _Decorator(label_object, self._as_pytestmark)

class Case(CaseBase):
    @property
    def feature(self) -> _Decorator:
        return self.d("feature")
    
    @property
    def default(self) -> _Decorator:
        return self.d("default")
    
    @property
    def init(self) -> _Decorator:
        return self.d("init")
    
    @property
    def init_fail(self) ->_Decorator:
        return self.d("init_fail")
    
    @property
    def cleanup(self) -> _Decorator:
        return self.d("cleanup")
    
    @property
    def cleanup_fail(self) -> _Decorator:
        return self.d("cleanup_fail")
    
    @property
    def edge(self) -> _Decorator:
        return self.d("edge")

    @property
    def edge_pass(self) -> _Decorator:
        return self.d("edge_pass")
    
    @property
    def edge_fail(self) -> _Decorator:
        return self.d("edge_fail")
    
    @property
    def legacy(self) -> _Decorator:
        return self.d("legacy")
    
    @property
    def legacy_fail(self) -> _Decorator:
        return self.d("legacy_fail")
    
    @property
    def violation(self) -> _Decorator:
        return self.d("violation")
    
    @property
    def raises(self) -> _Decorator:
        return self.d("raises")
    
    @property
    def recovers(self) -> _Decorator:
        return self.d("recovers")
    
    @property
    def error(self) -> _Decorator:
        return self.d("error")
    
    @property
    def critical(self) -> _Decorator:
        return self.d("critical")
    
    @property
    def silent(self) -> _Decorator:
        return self.d("silent")
    
    @property
    def NOTE(self) -> _Decorator:
        return self.d("NOTE")
    
    @property
    def IMPORTANT(self) -> _Decorator:
        return self.d("IMPORTANT")


class _SpecNamespace(dict):
    def __setitem__(self, key, value):
        if hasattr(value, _MARK_DECO):
            if getattr(value, _MARK_DECO) is TargetKind.UNDETERMINED:
                setattr(value, _MARK_DECO, TargetKind.INDIVIDUAL_TEST_BODY)
                setattr(value, _NAME_ON_NS, key)

class _SpecMeta(type):

    def __prepare__(self, name, bases, **kwargs) -> _SpecNamespace:
        return _SpecNamespace()

    def __new__(mcls, name, bases, namespace: _SpecNamespace):
        generated_tests: dict[str, Callable] = {}
        for k, v in namespace.items():
            if hasattr(v, _MARK_DECO):
                kind = getattr(v, _MARK_DECO)
                if kind is TargetKind.EX_SPEC_ACT:
                    dispatcher = getattr(v, _REF_DISPATCHER)
                    act = v
                    generated_tests[mcls._get_test_name(dispatcher)] = \
                        mcls._create_ex_test(dispatcher, act)
                if kind is TargetKind.INDIVIDUAL_TEST_BODY:
                    test_body = v
                    generated_tests[mcls._get_test_name(test_body)] = \
                        mcls._create_in_test(test_body)
        namespace.update(generated_tests)
        return super().__new__(mcls, name, bases, namespace)
    
    @classmethod
    def _get_test_name(mcls, src):
        return f"test_{getattr(src, _NAME_ON_NS)}"

    @classmethod
    def _create_ex_test(mcls, dispatcher: Callable, act: Callable):
        is_d_async = inspect.iscoroutinefunction(dispatcher)
        is_a_async = inspect.iscoroutinefunction(act)
        if not is_d_async and is_a_async:
            raise TypeError("Ex test actor must be sync function.")
        act_ns_name = getattr(act, _NAME_ON_NS)
        disp_ns_name = getattr(dispatcher, _NAME_ON_NS)
        disp_owner = getattr(dispatcher, _OWNER)
        generated_test = None
        if is_d_async:
            async def ex_test_async(self, *args, **kwargs):
                bound_act = getattr(self, act_ns_name)
                disp_instance = disp_owner()
                bound_disp = getattr(disp_instance, disp_ns_name)
                await bound_disp(bound_act, *args, **kwargs)
            generated_test = ex_test_async
        else:
            def ex_test(self, *args, **kwargs):
                bound_act = getattr(self, act_ns_name)
                disp_instance = disp_owner()
                bound_disp = getattr(disp_instance, disp_ns_name)
                bound_disp(bound_act, *args, **kwargs)
            generated_test = ex_test

        return generated_test
    
    @classmethod
    def _create_in_test(mcls, test_body: Callable):
        test_body_ns_name = getattr(test_body, _NAME_ON_NS)
        generated_test = None
        if inspect.iscoroutinefunction(test_body):
            async def in_test_async(self, *args, **kwargs):
                bound_act = getattr(self, test_body_ns_name)
                await self.dispatch_async(
                    test_body_ns_name, bound_act, *args, **kwargs
                )
            generated_test = in_test_async
        else:
            def in_test(self, *args, **kwargs):
                bound_act = getattr(self, test_body_ns_name)
                self.dispatch(
                    test_body_ns_name, bound_act, *args, **kwargs
                )
            generated_test = in_test
        
        return generated_test


class Spec(metaclass = _SpecMeta):
    """
    Base class for generated spec tests.

    Subclass this to customize how individual tests are invoked.
    Override `dispatch()` or `dispatch_async()` to change
    call signatures or invocation behavior of generated tests.
    """
    async def dispatch_async(self, name: str, actor: Callable, *args, **kwargs):
        await actor(*args, **kwargs)
    
    def dispatch(self, name: str, actor: Callable, *args, **kwargs):
        actor(*args, **kwargs)

