import re
import time
from logging import Logger
from typing import Any, Callable, Dict, List, Type
from urllib.parse import SplitResult, urlsplit

from bs4 import BeautifulSoup
from pydantic import AnyHttpUrl, parse_obj_as
from requests import PreparedRequest, Request, Response
from requests.adapters import HTTPAdapter

from . import logger
from .__about__ import __version__
from .exceptions import (
    ParseModelError,
    ServerHtmlError,
    ServerJsonError,
    ServerMultiJsonError,
    ServerTextError,
)
from .model.commons import (
    Error,
    MsData,
    MsDataUrl,
    MsError,
    MsStatus,
    SaeAlert,
    TiAlert,
)
from .model.enums import Api, HttpMethod, Provider, Status
from .model.requests import EndpointTask
from .model.responses import (
    MR,
    AddAlertNoteResp,
    BaseLinkableResp,
    BaseMultiResponse,
    BytesResp,
    C,
    ConsumeLinkableResp,
    GetAlertDetailsResp,
    MultiResp,
    MultiUrlResp,
    NoContentResp,
    R,
    S,
    SandboxSubmissionStatusResp,
)
from .results import multi_result, result

USERAGENT_SUFFIX: str = "PyTMV1"
API_VERSION: str = "v3.0"
DEFAULT_POLL_TIME: float = 1800
CONNECT_TIMEOUT: int = 5
READ_TIMEOUT: int = 30

log: Logger = logger.get_logger(__name__)


class Core:
    def __init__(
        self,
        appname: str,
        token: str,
        url: str,
        pool_connections: int,
        pool_maxsize: int,
    ):
        self._adapter = HTTPAdapter(pool_connections, pool_maxsize, 0, True)
        self._appname = appname
        self._token = token
        self._url = parse_obj_as(AnyHttpUrl, _format(url))
        self._headers: Dict[str, str] = {
            "Authorization": f"Bearer {self._token}",
            "User-Agent": f"{self._appname}-{USERAGENT_SUFFIX}/{__version__}",
        }

    @result
    def send(
        self,
        class_: Type[R],
        api: str,
        method: HttpMethod = HttpMethod.GET,
        **kwargs: Any,
    ) -> R:
        return self._process(
            class_,
            api,
            method,
            **kwargs,
        )

    @multi_result
    def send_endpoint(
        self,
        api: Api,
        *tasks: EndpointTask,
    ) -> MultiResp:
        return self._process(
            MultiResp,
            api,
            HttpMethod.POST,
            json=[
                task.dict(by_alias=True, exclude_none=True) for task in tasks
            ],
        )

    @result
    def send_linkable(
        self,
        class_: Type[BaseLinkableResp[C]],
        api: str,
        consumer: Callable[[C], None],
        **kwargs: Any,
    ) -> ConsumeLinkableResp:
        return ConsumeLinkableResp(
            total_consumed=self._consume_linkable(
                lambda: self._process(
                    class_,
                    api,
                    **kwargs,
                ),
                consumer,
            )
        )

    @multi_result
    def send_multi(
        self,
        class_: Type[MR],
        api: str,
        **kwargs: Any,
    ) -> MR:
        return self._process(
            class_,
            api,
            HttpMethod.POST,
            **kwargs,
        )

    @result
    def send_sandbox_result(
        self,
        class_: Type[R],
        api: Api,
        submit_id: str,
        poll: bool,
        poll_time_sec: float,
    ) -> R:
        if poll:
            _poll_status(
                lambda: self._process(
                    SandboxSubmissionStatusResp,
                    Api.GET_SANDBOX_SUBMISSION_STATUS.value.format(submit_id),
                ),
                poll_time_sec,
            )
        return self._process(class_, api.value.format(submit_id))

    @result
    def send_task_result(
        self, class_: Type[S], task_id: str, poll: bool, poll_time_sec: float
    ) -> S:
        status_call: Callable[[], S] = lambda: self._process(
            class_,
            Api.GET_TASK_RESULT.value.format(task_id),
        )
        if poll:
            _poll_status(
                status_call,
                poll_time_sec,
            )
        return status_call()

    def _consume_linkable(
        self,
        api_call: Callable[[], BaseLinkableResp[C]],
        consumer: Callable[[C], None],
        count: int = 0,
    ) -> int:
        total_count: int = count
        response: BaseLinkableResp[C] = api_call()
        for item in response.items:
            consumer(item)
            total_count += 1
        if response.next_link:
            sr: SplitResult = urlsplit(response.next_link)
            log.info("Found nextLink")
            return self._consume_linkable(
                lambda: self._process(
                    type(response),
                    sr.path[5:],
                    params={"skipToken": sr.query.split("=")[-1]},
                ),
                consumer,
                total_count,
            )
        log.info(
            "Records consumed: [Total=%s, Type=%s]",
            total_count,
            type(
                response.items[0] if len(response.items) > 0 else response
            ).__name__,
        )
        return total_count

    def _process(
        self,
        class_: Type[R],
        uri: str,
        method: HttpMethod = HttpMethod.GET,
        **kwargs: Any,
    ) -> R:
        log.info(
            "Processing request [Method=%s, Class=%s, Api=%s, Options=%s]",
            method.value,
            class_.__name__,
            uri,
            kwargs,
        )
        raw_response: Response = self._send_internal(
            self._prepare(uri, method, **kwargs)
        )
        _validate(raw_response)
        return _parse_data(raw_response, class_)

    def _prepare(
        self, uri: str, method: HttpMethod, **kwargs: Any
    ) -> PreparedRequest:
        return Request(
            method.value,
            self._url + uri,
            headers={**self._headers, **kwargs.pop("headers", {})},
            **kwargs,
        ).prepare()

    def _send_internal(self, request: PreparedRequest) -> Response:
        log.info(
            "Sending request [Method=%s, URL=%s, Headers=%s, Body=%s]",
            request.method,
            request.url,
            re.sub("Bearer [^\\s']+", "*****", str(request.headers)),
            (
                request.body.decode("utf-8")
                if type(request.body) == bytes
                else request.body
            ),
        )
        response: Response = self._adapter.send(
            request, stream=False, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT)
        )
        log.info(
            "Received response [Status=%s, Headers=%s, Body=%s]",
            response.status_code,
            response.headers,
            _hide_binary(response),
        )
        return response


def _format(url: str) -> str:
    return (url if url.endswith("/") else url + "/") + API_VERSION


def _hide_binary(response: Response) -> str:
    content_type = response.headers.get("Content-Type", "")
    if "json" not in content_type and "application" in content_type:
        return "***binary content***"
    return response.text


def _is_http_success(status_codes: List[int]) -> bool:
    return len(list(filter(lambda s: not 200 <= s < 399, status_codes))) == 0


def _parse_data(raw_response: Response, class_: Type[R]) -> R:
    content_type = raw_response.headers.get("Content-Type", "")
    if "json" in content_type:
        if issubclass(class_, BaseMultiResponse):
            log.info("Parsing json multi response [Class=%s]", class_.__name__)
            class_d: Type[List[Any]]
            if issubclass(class_, MultiUrlResp):
                class_d = List[MsDataUrl]
            else:
                class_d = List[MsData]
            return class_(
                items=parse_obj_as(
                    class_d,
                    raw_response.json(),
                )
            )
        log.info("Parsing json response [Class=%s]", class_.__name__)
        if class_ == GetAlertDetailsResp:
            response_json: Dict[str, str] = raw_response.json()
            return class_(
                alert=parse_obj_as(
                    (
                        SaeAlert
                        if response_json.get("alertProvider") == Provider.SAE
                        else TiAlert
                    ),
                    response_json,
                ),
                etag=raw_response.headers.get("ETag", ""),
            )
        return class_.parse_obj(raw_response.json())
    if "application" in content_type and class_ == BytesResp:
        log.info("Parsing binary response")
        return class_(content=raw_response.content)
    if raw_response.status_code == 201 and class_ == AddAlertNoteResp:
        return class_.parse_obj(raw_response.headers)
    if raw_response.status_code == 204 and class_ == NoContentResp:
        return class_()
    raise ParseModelError(class_.__name__, raw_response)


def _parse_html(html: str) -> str:
    log.info("Parsing html response [Html=%s]", html)
    soup = BeautifulSoup(html, "html.parser")
    return "\n".join(
        line.strip() for line in soup.text.split("\n") if line.strip()
    )


def _poll_status(
    status_call: Callable[[], S],
    poll_time_sec: float,
) -> None:
    start_time: float = time.time()
    elapsed_time: float = 0
    response: S = status_call()
    while elapsed_time < poll_time_sec:
        if response.status in [Status.QUEUED, Status.RUNNING]:
            response = status_call()
            elapsed_time = time.time() - start_time
        else:
            break


def _validate(raw_response: Response) -> None:
    log.info("Validating response [%s]", raw_response)
    content_type: str = raw_response.headers.get("Content-Type", "")
    if "text/html" in content_type:
        raise ServerHtmlError(
            raw_response.status_code, _parse_html(raw_response.text)
        )
    if not _is_http_success([raw_response.status_code]):
        if "application/json" in content_type:
            error: Dict[str, Any] = raw_response.json().get("error")
            error["status"] = raw_response.status_code
            raise ServerJsonError(
                Error.parse_obj(error),
            )
        raise ServerTextError(raw_response.status_code, raw_response.text)
    if raw_response.status_code == 207:
        if not _is_http_success(
            MsStatus.parse_obj(raw_response.json()).values()
        ):
            raise ServerMultiJsonError(
                parse_obj_as(List[MsError], raw_response.json())
            )
