from typing import ClassVar, Optional, Type, TypeVar
from uuid import UUID

from google.protobuf.json_format import MessageToDict
from google.protobuf.message import Message
from pydantic import BaseModel, validate_call

from . import config
from .api_constants import (
    SUCCESS_CODE,
    BasketRequestMessages,
    BasketRPCMethodNames,
    InstallmentTermRequestMessages,
    InstallmentTermRPCMethodNames,
    Stubs,
    TradeRequestMessages,
    TradeRPCMethodNames,
)
from .schemas import InstallmentTerm as InstallmentTermSchema

M = TypeVar("M", bound=BaseModel)


class ValidationError(Exception):
    pass


class ResponseCodeError(Exception):
    pass


class RPCClient(BaseModel):
    _instances: ClassVar[dict] = {}
    stub_cls: ClassVar

    def __new__(cls, *args, **kwargs):
        # Ensure that each subclass has its own singleton instance
        if cls not in cls._instances:
            cls._instances[cls] = super().__new__(cls)
        return cls._instances[cls]

    @validate_call(config=dict(arbitrary_types_allowed=True))
    async def perform_rpc(
        self,
        *,
        rpc_method_name: str,
        request_message: Optional[Message] = None,
        response_model: Optional[Type[M]] = None,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ) -> list[M] | dict:
        """Perform the remote-precedure-call.

        Parameters:
            rpc_method_name: The name of the RPC method (e.g., `Retrieve`).
            request_message: The request message instance.
            timeout: RPC timeout.
            wait_for_ready: Wait for Channel to be ready in case is not.
        """
        if config.CHANNEL is None:
            await config.construct_channel()

        RPCMethod = getattr(self.stub_cls(config.CHANNEL), rpc_method_name, None)
        if not RPCMethod:
            raise ValidationError("Invalid RPC method name.")

        metadata = [("api-key", config.grpc_settings.API_KEY)]
        if request_id:
            metadata.append(("x-request-id", str(request_id)))

        response = await RPCMethod(
            request_message,
            metadata=metadata,
            timeout=config.grpc_settings.TIMEOUT if timeout is None else timeout,
            wait_for_ready=config.grpc_settings.WAIT_FOR_READY
            if wait_for_ready is None
            else wait_for_ready,
        )

        # Raise error if response is not successful
        if response.code != SUCCESS_CODE:
            raise ResponseCodeError(response.code)

        response_dict = MessageToDict(response, preserving_proto_field_name=True)
        data = response_dict.get("data", [])

        if not response_model:
            return data

        return [response_model.model_validate(item) for item in data]


class Basket(RPCClient):
    stub_cls = Stubs.BASKET

    @validate_call
    async def create(
        self,
        owner_identifier: Optional[str] = None,
        basket_identifier: Optional[str] = None,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Create a new basket instance."""
        # Assign attributes to the request message
        request = BasketRequestMessages.CREATE()
        if owner_identifier:
            request.owner_identifier = owner_identifier
        if basket_identifier:
            request.basket_identifier = basket_identifier

        return await self.perform_rpc(
            rpc_method_name=BasketRPCMethodNames.CREATE,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )

    @validate_call
    async def retrieve(
        self,
        basket_identifier: str,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Retrieve a basket instance."""
        # Assign attributes to the request message
        request = BasketRequestMessages.RETRIEVE(basket_identifier=basket_identifier)

        return await self.perform_rpc(
            rpc_method_name=BasketRPCMethodNames.RETRIEVE,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )

    @validate_call
    async def add_item(
        self,
        item_identifier: str,
        basket_identifier: str,
        installment: Optional[bool] = False,
        extra_data: Optional[dict] = None,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Add item to an existing basket."""
        # Set the keyword attribute in the request message
        request = BasketRequestMessages.ADD_ITEM(
            item_identifier=item_identifier,
            basket_identifier=basket_identifier,
            installment=installment,
        )
        if extra_data:
            request.extra_data.update(extra_data)

        return await self.perform_rpc(
            rpc_method_name=BasketRPCMethodNames.ADD_ITEM,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )

    @validate_call
    async def remove_item(
        self,
        item_identifier: str,
        basket_identifier: str,
        extra_data: Optional[dict] = None,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Remove an item from an existing basket."""
        # Set the keyword attribute in the request message
        request = BasketRequestMessages.REMOVE_ITEM(
            item_identifier=item_identifier, basket_identifier=basket_identifier
        )
        if extra_data:
            request.extra_data.update(extra_data)

        return await self.perform_rpc(
            rpc_method_name=BasketRPCMethodNames.REMOVE_ITEM,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )

    @validate_call
    async def assign_owner(
        self,
        owner_identifier: str,
        basket_identifier: str,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Assign the specified owner to the specified basket."""
        # Set the keyword attribute in the request message
        request = BasketRequestMessages.ASSIGN_OWNER(
            owner_identifier=owner_identifier, basket=basket_identifier
        )

        return await self.perform_rpc(
            rpc_method_name=BasketRPCMethodNames.ASSIGN_OWNER,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )

    @validate_call
    async def apply_discount(
        self,
        basket_identifier: str,
        discount_token: str,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Assign the specified owner to the specified basket."""
        # Set the keyword attribute in the request message
        request = BasketRequestMessages.APPLY_DISCOUNT(
            discount_token=discount_token, basket=basket_identifier
        )

        return await self.perform_rpc(
            rpc_method_name=BasketRPCMethodNames.APPLY_DISCOUNT,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )


class Trade(RPCClient):
    stub_cls = Stubs.TRADE

    @validate_call
    async def start(
        self,
        basket_identifier: str,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Start the trade process (paying a basket)."""

        # Set the keyword attribute in the START request message
        request = TradeRequestMessages.START(basket=basket_identifier)
        return await self.perform_rpc(
            rpc_method_name=TradeRPCMethodNames.START,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )

    @validate_call
    async def end(
        self,
        gateway_status: str,
        payment_identifier: str,
        extra_data: Optional[dict] = None,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """End the trade process (verify payment)."""

        # Set the keyword attribute in the END request message
        request = TradeRequestMessages.END(
            gateway_status=gateway_status, payment_identifier=payment_identifier
        )
        if extra_data:
            request.extra_data.update(extra_data)
        return await self.perform_rpc(
            rpc_method_name=TradeRPCMethodNames.END,
            request_message=request,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )


class InstallmentTerm(RPCClient):
    stub_cls = Stubs.INSTALLMENT_TERM

    @validate_call
    async def retrieve(
        self,
        identifier: str | UUID,
        item_price: int,
        request_id: Optional[str | int | UUID] = None,
        timeout: Optional[float] = None,
        wait_for_ready: Optional[bool] = None,
    ):
        """Retrieve installment term for an item with `item_price`."""
        # Assign attributes to the request message
        request = InstallmentTermRequestMessages.RETRIEVE(
            identifier=str(identifier), item_price=item_price
        )

        return await self.perform_rpc(
            rpc_method_name=InstallmentTermRPCMethodNames.RETRIEVE,
            request_message=request,
            response_model=InstallmentTermSchema,
            request_id=request_id,
            timeout=timeout,
            wait_for_ready=wait_for_ready,
        )
