#! python3  # noqa: E265

"""Perform network request."""

# ############################################################################
# ########## Imports ###############
# ##################################

# standard library
import mimetypes
import uuid
from codecs import encode
from pathlib import Path
from socket import AF_INET, SOCK_STREAM, error as socket_error, socket

# PyQGIS
from qgis.core import (
    Qgis,
    QgsApplication,
    QgsAuthManager,
    QgsBlockingNetworkRequest,
    QgsFileDownloader,
    QgsNetworkReplyContent,
)
from qgis.PyQt.QtCore import (
    QByteArray,
    QEventLoop,
    QFile,
    QFileInfo,
    QIODevice,
    QUrl,
    QUrlQuery,
)
from qgis.PyQt.QtGui import QDesktopServices
from qgis.PyQt.QtNetwork import QNetworkReply, QNetworkRequest

# project
from oslandia.constants import (
    UTM_CAMPAIGN,
    UTM_MEDIUM_BACKGROUND,
    UTM_MEDIUM_DEFAULT,
    UTM_SOURCE,
)
from oslandia.toolbelt.file_stats import convert_octets
from oslandia.toolbelt.log_handler import PlgLogger
from oslandia.toolbelt.preferences import PlgOptionsManager
from oslandia.toolbelt.qt_utils import TranslatableMixin


def add_utm_to_url(
    url: QUrl, caller: str, caller_medium: str = UTM_MEDIUM_DEFAULT
) -> QUrl:
    """Returns the URL with UTM tag

    :param url: input URL to insert tags
    :type url: QUrl
    :param caller: part of the code the function is called from
    :type caller: str
    :param caller_medium: medium from which the function is called from
    :type caller_medium: str

    :return: Qt URL object with full parameters
    :rtype: QUrl
    """
    if PlgOptionsManager.get_plg_settings().enable_utm:
        campaign = UTM_CAMPAIGN.replace(
            "{pluginversion}",
            PlgOptionsManager.get_plg_settings().version.replace(".", "_"),
        )
        campaign = campaign.replace("{qgisversion}", str(Qgis.QGIS_VERSION_INT))

        url_query = QUrlQuery(url.query())
        url_query.addQueryItem("utm_source", UTM_SOURCE)
        url_query.addQueryItem("utm_campaign", campaign)
        url_query.addQueryItem("utm_medium", caller_medium)
        url_query.addQueryItem("utm_content", caller)
        url.setQuery(url_query)

    return url


def open_url_in_browser(url: QUrl | str, caller: str) -> bool:
    """Interface for the openUrl function to include UTM params

    :param url: input URL to insert tags
    :type url: QUrl | str
    :param caller: part of the code the function is called from
    :type caller: str

    :return: true if successful otherwise returns false
    :rtype: bool
    """
    if isinstance(url, str):
        url: QUrl = QUrl(url)

    neo_url: QUrl = add_utm_to_url(url, caller)
    return QDesktopServices.openUrl(neo_url)


# ############################################################################
# ########## Classes ###############
# ##################################


class NetworkRequestsManager(TranslatableMixin):
    """Helper on network operations."""

    def __init__(self):
        """Initialization."""
        self.log = PlgLogger().log
        self.ntwk_requester = QgsBlockingNetworkRequest()

    # -- Utils --
    @staticmethod
    def is_port_available(host: str = "127.0.0.1", port: int = 7070) -> bool:
        """Check if a port is already in use.

        :param host: host name or IP, defaults to "127.0.0.1"
        :type host: str, optional
        :param port: port number to check, defaults to 7070
        :type port: int, optional

        :return: True if the port is available
        :rtype: bool
        """
        with socket(AF_INET, SOCK_STREAM) as network_socket:
            try:
                network_socket.bind((host, port))
                return True
            except socket_error as err:
                PlgLogger().log(
                    message="Port {} is already in use on {}. Trace: {}".format(
                        port, host, err
                    ),
                    log_level=Qgis.MessageLevel.Critical,
                    push=False,
                )
                return False

    # -- HTTP verbs --
    def get_url(
        self,
        url: QUrl,
        config_id: str | None = None,
        debug_log_response: bool = True,
        return_req_reply: bool = False,
        caller: str = "",
    ) -> QByteArray | QgsNetworkReplyContent:
        """Send a get method.

        :param url: URL to request
        :type url: QUrl
        :param config_id: QGIS auth config ID, defaults to None
        :type config_id: str, optional
        :param debug_log_response: option to do not log decoded content in debug mode,
            defaults to True
        :type debug_log_response: bool, optional
        :param return_req_reply: option to return request reply instead of request
            reply content, defaults to False
        :type return_req_reply: bool, optional
        :param caller: part of the code the function is called from
        :type caller: str

        :raises ConnectionError: if any problem occurs during feed fetching.
        :raises TypeError: if response mime-type is not valid

        :return: feed response in bytes
        :rtype: QByteArray
        """
        return self._send_request(
            http_method="get",
            url=url,
            config_id=config_id,
            debug_log_response=debug_log_response,
            return_req_reply=return_req_reply,
            caller=caller,
            push_on_status_error=False,
            push_on_reply_error=True,
            push_on_exception=False,
            error_return_value=QByteArray(),
        )

    def post_url(
        self,
        url: QUrl,
        data: QByteArray | None = None,
        config_id: str | None = None,
        content_type_header: str = "",
        debug_log_response: bool = True,
        caller: str = "",
        caller_medium: str = "",
    ) -> QByteArray | None:
        """Send a post method with data option.
        :raises ConnectionError: if any problem occurs during feed fetching.
        :raises TypeError: if response mime-type is not valid

        :param url: url
        :type url: QUrl
        :param data: data for post, defaults to None
        :type data: QByteArray, optional
        :param config_id: QGIS auth config ID, defaults to None
        :type config_id: str, optional
        :param content_type_header: content type header for request, defaults to ""
        :type content_type_header: str, optional
        :param debug_log_response: option to do not log decoded content in debug mode, defaults to True
        :type debug_log_response: bool, optional
        :param caller: part of the code the function is called from
        :type caller: str
        :param caller_medium: medium from which the function is called from
        :type caller_medium: str

        :return: feed response in bytes
        :rtype: QByteArray
        """
        return self._send_request(
            http_method="post",
            url=url,
            data=data,
            config_id=config_id,
            content_type_header=content_type_header,
            debug_log_response=debug_log_response,
            caller=caller,
            caller_medium=caller_medium,
            push_on_status_error=True,
            push_on_reply_error=True,
            push_on_exception=True,
            error_return_value=None,
        )

    def put_url(
        self,
        url: QUrl,
        data: QByteArray | None = None,
        config_id: str | None = None,
        content_type_header: str = "",
        debug_log_response: bool = True,
        caller: str = "",
    ) -> QByteArray | None:
        """Send a put method with data option.
        :raises ConnectionError: if any problem occurs during feed fetching.
        :raises TypeError: if response mime-type is not valid

        :param url: url
        :type url: QUrl
        :param data: data for put, defaults to None
        :type data: QByteArray, optional
        :param config_id: QGIS auth config ID, defaults to None
        :type config_id: str, optional
        :param content_type_header: content type header for request, defaults to ""
        :type content_type_header: str, optional
        :param debug_log_response: option to do not log decoded content in debug mode, defaults to True
        :type debug_log_response: bool, optional
        :param caller: part of the code the function is called from
        :type caller: str

        :return: feed response in bytes
        :rtype: QByteArray
        """
        return self._send_request(
            http_method="put",
            url=url,
            data=data,
            config_id=config_id,
            content_type_header=content_type_header,
            debug_log_response=debug_log_response,
            caller=caller,
            push_on_status_error=True,
            push_on_reply_error=True,
            push_on_exception=True,
            error_return_value=None,
        )

    def _send_request(
        self,
        http_method: str,
        url: QUrl,
        data: QByteArray | None = None,
        config_id: str | None = None,
        content_type_header: str = "",
        debug_log_response: bool = True,
        caller: str = "",
        caller_medium: str = UTM_MEDIUM_DEFAULT,
        return_req_reply: bool = False,
        push_on_status_error: bool = True,
        push_on_reply_error: bool = True,
        push_on_exception: bool = True,
        error_return_value: QByteArray | None = None,
    ) -> QByteArray | QgsNetworkReplyContent | None:
        """Send a blocking network request and handle the common request error-checking \
            debug-logging boilerplate shared by get_url, post_url and put_url.

        :param http_method: HTTP verb to use, one of "get", "post", "put"
        :type http_method: str
        :param url: url to request
        :type url: QUrl
        :param data: data to send for post/put, unused for get, defaults to None
        :type data: QByteArray, optional
        :param config_id: QGIS auth config ID, defaults to None
        :type config_id: str, optional
        :param content_type_header: content type header for request, defaults to ""
        :type content_type_header: str, optional
        :param debug_log_response: option to do not log decoded content in debug mode,
            defaults to True
        :type debug_log_response: bool, optional
        :param caller: part of the code the function is called from
        :type caller: str
        :param caller_medium: medium from which the function is called from
        :type caller_medium: str
        :param return_req_reply: option to return request reply instead of request
            reply content, defaults to False
        :type return_req_reply: bool, optional
        :param push_on_status_error: whether to push a message bar notification when the
            blocking request itself fails, defaults to True
        :type push_on_status_error: bool, optional
        :param push_on_reply_error: whether to push a message bar notification when the
            network reply carries an error, defaults to True
        :type push_on_reply_error: bool, optional
        :param push_on_exception: whether to push a message bar notification when an
            unexpected exception is caught, defaults to True
        :type push_on_exception: bool, optional
        :param error_return_value: value returned when the request ultimately fails,
            defaults to None
        :type error_return_value: Optional[QByteArray]

        :raises ConnectionError: if any problem occurs during feed fetching.

        :return: feed response in bytes (or request reply, or error_return_value on failure)
        :rtype: QByteArray | QgsNetworkReplyContent | None
        """
        url = add_utm_to_url(url, caller, caller_medium)
        auth_manager: QgsAuthManager | None = QgsApplication.authManager()
        req = QNetworkRequest(url)
        if content_type_header:
            req.setHeader(
                QNetworkRequest.KnownHeaders.ContentTypeHeader, content_type_header
            )
        if isinstance(auth_manager, QgsAuthManager):
            auth_manager.updateNetworkRequest(req, config_id)

        # send request
        try:
            if http_method == "get":
                req_status: QgsBlockingNetworkRequest.ErrorCode = (
                    self.ntwk_requester.get(request=req, forceRefresh=True)
                )
            else:
                req_status: QgsBlockingNetworkRequest.ErrorCode = getattr(
                    self.ntwk_requester, http_method
                )(request=req, data=data)

            # check if request is fine
            if req_status != QgsBlockingNetworkRequest.ErrorCode.NoError:
                self.log(
                    message=self.ntwk_requester.errorMessage(),
                    log_level=Qgis.MessageLevel.Critical,
                    push=push_on_status_error,
                )
                raise ConnectionError(self.ntwk_requester.errorMessage())

            req_reply: QgsNetworkReplyContent = self.ntwk_requester.reply()

            if req_reply.error() != QNetworkReply.NetworkError.NoError:
                self.log(
                    message=req_reply.errorString(),
                    log_level=Qgis.MessageLevel.Critical,
                    push=push_on_reply_error,
                )
                raise ConnectionError(req_reply.errorString())

            if PlgOptionsManager.get_plg_settings().debug_mode:
                verb_label: str = http_method.upper()
                if debug_log_response:
                    self.log(
                        message="{} response: {} ({})".format(
                            verb_label,
                            req_reply.content().data().decode("utf-8"),
                            convert_octets(req_reply.content().size()),
                        ),
                        log_level=Qgis.MessageLevel.NoLevel,
                        push=False,
                    )
                else:
                    self.log(
                        message="{} response from {}. Received content size: {}".format(
                            verb_label,
                            url.toString(),
                            convert_octets(req_reply.content().size()),
                        ),
                        log_level=Qgis.MessageLevel.NoLevel,
                        push=False,
                    )

            if return_req_reply:
                return req_reply
            return req_reply.content()

        except Exception as err:
            err_msg: str = self.tr(
                "{} request on URL {} (with auth config {}) failed. Trace: {}".format(
                    http_method.upper(), url, config_id, err
                )
            )
            self.log(
                message=err_msg,
                log_level=Qgis.MessageLevel.Critical,
                push=push_on_exception,
            )
            return error_return_value

    def download_file_to(
        self,
        remote_url: str,
        local_path: Path | str,
        auth_cfg: str | None = None,
        caller: str = "",
    ) -> str:
        """Download a file from a remote web server accessible through HTTP.

        :param remote_url: remote URL
        :type remote_url: str
        :param local_path: path to the local file
        :type local_path: str
        :param auth_cfg: authentication configuration ID, defaults to None
        :type auth_cfg: Optional[str], optional
        :param caller: part of the code the function is called from
        :type caller: str

        :return: output path
        :rtype: str
        """
        # check if destination path is a str and if parent folder exists
        if isinstance(local_path, Path):
            local_path.parent.mkdir(parents=True, exist_ok=True)
            local_path = f"{local_path.resolve()}"
        elif isinstance(local_path, str):
            Path(local_path).parent.mkdir(parents=True, exist_ok=True)

        self.log(
            message=f"Downloading file from {remote_url} to {local_path}",
            log_level=Qgis.MessageLevel.NoLevel,
        )
        # download it
        loop = QEventLoop()
        neo_remote_url = add_utm_to_url(
            QUrl(remote_url), caller, caller_medium=UTM_MEDIUM_BACKGROUND
        )
        file_downloader = QgsFileDownloader(
            url=neo_remote_url,
            outputFileName=local_path,
            delayStart=True,
            authcfg=auth_cfg,
        )
        file_downloader.downloadExited.connect(loop.quit)
        file_downloader.startDownload()
        loop.exec()

        self.log(
            message=f"Download of {remote_url} to {local_path} succeedeed",
            log_level=Qgis.MessageLevel.Success,
        )
        return local_path

    def post_file(
        self,
        url: QUrl,
        file_path: Path,
        config_id: str | None = None,
        caller: str = "",
    ) -> QByteArray | None:
        """Post a file using multipart/form-data

        :param url: url
        :type url: QUrl
        :param file_path: file path to file to upload
        :type file_path: Path
        :param config_id: authentication configuration ID, defaults to None
        :type config_id: Optional[str], optional
        :param caller: part of the code the function is called from
        :type caller: str
        :return: feed response in bytes
        :rtype: Optional[QByteArray]
        """

        fp = QFile(str(file_path))
        fp.open(QIODevice.OpenModeFlag.ReadOnly)

        boundary = f"----OslandiaQGISPluginBoundary{uuid.uuid4().hex}"

        body_list = []

        # Define part for file
        # Add boundary
        body_list.append(encode("--" + boundary))
        body_list.append(
            encode(
                f'Content-Disposition: form-data; name="file"; filename="{QFileInfo(fp).fileName()}"'
            )
        )
        # Define content-type
        file_type = (
            mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
        )
        body_list.append(encode(f"Content-Type: {file_type}"))

        # Add file content
        body_list.append(encode(""))
        body_list.append(fp.readAll())

        # Close part for file
        body_list.append(encode("--" + boundary + "--"))

        # Create body for encoded data
        body = b"\r\n".join(body_list)

        # Define content header with multipart/form-data and used boundary
        content_type_header = f"multipart/form-data; boundary={boundary}"

        req_reply = self.post_url(
            url=url,
            data=body,
            config_id=config_id,
            content_type_header=content_type_header,
            caller=caller,
        )
        return req_reply
