import urllib.parse
from collections import namedtuple
from typing import List, Union

import aiohttp
import google.protobuf.message
from tenacity import (
    AsyncRetrying,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential,
)

from maps_adv.points.proto.errors_pb2 import Error
from maps_adv.points.proto.points_in_polygons_pb2 import (
    PointsInPolygonsInput,
    PointsInPolygonsOutput,
)
from maps_adv.points.proto.primitives_pb2 import Point, Polygon

from .exceptions import (
    BadGateway,
    CollectionNotFound,
    InvalidPolygon,
    InvalidVersion,
    NonClosedPolygon,
    NoPointsPassed,
    NoPolygonsPassed,
    NotFound,
    ServiceUnavailable,
    UnknownError,
    UnknownResponseBody,
    UnknownResponseCode,
    ValidationError,
)

ResultPoint = namedtuple("ResultPoint", ("id", "latitude", "longitude"))

REQUEST_MAX_ATTEMPTS = 5
RETRY_WAIT_MULTIPLIER = 0.1

MAP_PROTO_ERROR_TO_EXCEPTION = {
    Error.ERROR_CODE.COLLECTION_NOT_FOUND: lambda _: CollectionNotFound(),
    Error.ERROR_CODE.NON_CLOSED_POLYGON: lambda _: NonClosedPolygon(),
    Error.ERROR_CODE.INVALID_POLYGON: lambda _: InvalidPolygon(),
    Error.ERROR_CODE.NO_POLYGONS_PASSED: lambda _: NoPolygonsPassed(),
    Error.ERROR_CODE.INVALID_VERSION: lambda _: InvalidVersion(),
    Error.ERROR_CODE.NO_POINTS_PASSED: lambda _: NoPointsPassed(),
    Error.ERROR_CODE.VALIDATION_ERROR: lambda e: ValidationError(e.description),
}


class Client:
    __slots__ = "url", "_retryer"

    url: str
    _retryer: AsyncRetrying

    def __init__(self, url: str):
        self.url = url

        self._retryer = AsyncRetrying(
            stop=stop_after_attempt(REQUEST_MAX_ATTEMPTS),
            retry=retry_if_exception_type(
                (
                    BadGateway,
                    ServiceUnavailable,
                    aiohttp.ServerDisconnectedError,
                    aiohttp.ServerTimeoutError,
                )
            ),
            wait=wait_exponential(multiplier=RETRY_WAIT_MULTIPLIER),
        )

    def _url(self, uri: str) -> str:
        return urllib.parse.urljoin(self.url, uri)

    async def __call__(self, polygons: List[List[dict]], points_version: int):
        url = self._url(f"/api/v1/points/billboard/{points_version}/by-polygons/")
        request_pb = PointsInPolygonsInput(
            polygons=list(
                Polygon(
                    points=list(
                        Point(
                            latitude=str(point["latitude"]),
                            longitude=str(point["longitude"]),
                        )
                        for point in polygon
                    )
                )
                for polygon in polygons
            )
        )

        response_body = await self._request_with_retry(
            "POST", url, request_pb.SerializeToString(), 200
        )

        try:
            result_pb = PointsInPolygonsOutput.FromString(response_body)
        except google.protobuf.message.DecodeError:
            raise UnknownResponseBody(status_code=200, payload=response_body)

        return list(
            ResultPoint(id=point.id, longitude=point.longitude, latitude=point.latitude)
            for point in result_pb.points
        )

    async def _request_with_retry(self, *args, **kwargs):
        return await self._retryer.call(self._request, *args, **kwargs)

    async def _request(
        self, method: str, url: str, payload: dict, expected_status: int
    ) -> Union[dict, bytes]:
        async with aiohttp.ClientSession() as session:
            async with session.request(method, url, data=payload) as response:
                await self._check_response(response, expected_status)
                return await response.content.read()

    @classmethod
    async def _check_response(cls, resp: aiohttp.ClientResponse, expected_status: int):
        if resp.status != expected_status:
            await cls._process_bad_response(resp)

    @classmethod
    async def _process_bad_response(cls, resp: aiohttp.ClientResponse):
        if resp.status == 502:
            raise BadGateway()
        elif resp.status == 503:
            raise ServiceUnavailable()
        elif resp.status == 404:
            raise NotFound()
        elif resp.status == 400:
            await cls._process_proto_error_response(resp)
        else:
            raise UnknownResponseCode(resp.status)

    @classmethod
    async def _process_proto_error_response(cls, resp: aiohttp.ClientResponse):
        response_body = await resp.read()
        try:
            error = Error.FromString(response_body)
        except google.protobuf.message.DecodeError:
            raise UnknownResponseBody(status_code=resp.status, payload=response_body)

        if error.code in MAP_PROTO_ERROR_TO_EXCEPTION:
            raise MAP_PROTO_ERROR_TO_EXCEPTION[error.code](error)
        else:
            raise UnknownError(resp.status, error)

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args, **kwargs):
        pass
