#
# Copyright © 2022-2024 University of Strasbourg. All Rights Reserved.
# Copyright © 2022-2025 QPerfect. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

from functools import partial
from http.server import HTTPServer
import json
import threading
import webbrowser
from urllib.parse import urljoin
import time
from mimiqlink.abstractconnection import AbstractConnection

# Import the connection handler
from mimiqlink.handler import AuthenticationHandler

# Import the logging utils
from mimiqlink.utils import getLogger

QPERFECT_CLOUD = "https://mimiq.qperfect.io"
QPERFECT_DEV = "https://mimiqfast.qperfect.io"


class MimiqConnection(AbstractConnection):
    """Connection to the MIMIQ remote services.

    It handles the authentication and the requests.
    """

    def __init__(self, url=None):
        if url is None:
            url = QPERFECT_CLOUD

        super().__init__(url)

        # refresher related variables
        self.refresher_lock = threading.Lock()
        self.refresher_task = None
        self.refresher_interval = 15 * 60
        self.refresher_stop = False

        # tokens
        self.access_token = None
        self.refresh_token = None

        # user limits
        self.user_limits = None

    def get_api_url(self, *paths):
        """Get the API URL for the given paths."""
        base = urljoin(self.url + "/", "api")
        if not paths:
            return base

        # Join all paths with the base URL
        result = base
        for path in paths:
            # Remove leading slash if present to avoid double slashes
            if path.startswith("/"):
                path = path[1:]
            result = urljoin(result + "/", path)

        return result

    def _weblogin(self, data):
        """Authenticate to the remote server with the given credentials. But return the response."""
        endpoint = "sign-in"

        # ask for access tokens
        response = self.session.post(
            self.get_api_url(endpoint), json=data, headers={"Connection": "close"}
        )

        if response.status_code != 200:
            reason = response.json().get("message", "Unknown error")
            raise ConnectionError(
                f"Authentication failed with status code {response.status_code} and reason: {reason}"
            )

        tokens = response.json()

        # set the access tokens
        with self.refresher_lock:
            self.access_token = tokens["token"]
            self.refresh_token = tokens["refreshToken"]

        self.__updateSessionHeaders()
        self.__updateUserLimits()
        self.__startRefresher()

        getLogger().info("Authentication successful.")

        return response

    def connectUser(self, email, password):
        """Authenticate to the remote server with the given credentials (email and password)."""

        if self.isOpen():
            self.close()

        self._weblogin({"email": email, "password": password})

        return self

    def connectToken(self, token):
        """Authenticate to the remote server with the given refresh token"""

        if self.isOpen():
            self.close()

        # set the refresh token
        with self.refresher_lock:
            self.refresh_token = token

        # refresh
        status = self.refresh()

        if not status:
            raise ConnectionError("Authentication failed.")

        getLogger().info("Authentication successful.")
        self.__updateSessionHeaders()
        self.__updateUserLimits()
        self.__startRefresher()
        return self

    def connectWeb(self):
        """Authenticate to the remote services by taking credentials from a locally shown login page"""

        if self.isOpen():
            self.close()

        preferred_port = 1444

        h = partial(AuthenticationHandler, lambda data: self._weblogin(data))

        try:
            # Attempt to create the server with the fixed port
            httpd = HTTPServer(("localhost", preferred_port), h)
            port = preferred_port
        except OSError:
            # If the fixed port is in use, use a random available port
            httpd = HTTPServer(("localhost", 0), h)
            port = httpd.server_port

        getLogger().info(
            f"Listening on: 127.0.0.1:{port}, thread id: {threading.get_ident()}"
        )
        getLogger().info(f"Please login in your browser at http://localhost:{port}")

        webbrowser.open(f"http://localhost:{port}", new=2)

        with httpd:
            while self.access_token is None:
                httpd.handle_request()

        if not self.access_token:
            raise ConnectionError(
                "Authentication failed. Unable to obtain access token."
            )

        return self

    def connect(self, *args, **kwargs):
        """Connect to the remote server.

        Supports three calling conventions:

        - ``connect()`` — opens a browser-based login page (recommended).
        - ``connect(token)`` — authenticates with a refresh token.
        - ``connect(email, password)`` — authenticates with credentials.

        If the connection is already open, this is a no-op.

        Internally delegates to :meth:`connectWeb`, :meth:`connectToken`,
        or :meth:`connectUser` respectively.

        Examples:

        .. code-block:: python

            # Browser login (recommended)
            conn = MimiqConnection()
            conn.connect()

            # Token-based login
            conn = MimiqConnection()
            conn.connect("mytoken")

            # Credential-based login
            conn = MimiqConnection()
            conn.connect("john.doe@example.com", "password")
        """
        if self.isOpen():
            return self

        if len(args) == 0:
            return self.connectWeb()

        if len(args) == 1:
            return self.connectToken(*args)

        if len(args) == 2:
            return self.connectUser(*args)

        raise ConnectionError(
            "Invalid number of arguments. Expected 0, 1 (token) or 2 (username, password)."
        )

    def __startRefresher(self):
        """Start the refresher thread."""

        # if the refresher is alreeady running stop it
        with self.refresher_lock:
            if self.refresher_task is not None and self.refresher_task.is_alive():
                self.refresher_stop = True
                self.refresher_task.join()

        # ensure that the refresher is not stopped immediately
        with self.refresher_lock:
            self.refresher_stop = False

        # create and start the refresher
        self.refresher_task = threading.Thread(target=self.__refresherMain, daemon=True)
        self.refresher_task.start()

    def __refresherMain(self):
        """Refresher function
        Will refresh the access token with the refresh token at every
        configured interval.
        """
        while True:
            # check the stop flag every second
            for i in range(self.refresher_interval):
                time.sleep(1)
                with self.refresher_lock:
                    if self.refresher_stop:
                        break

            with self.refresher_lock:
                if self.refresher_stop:
                    break

            self.refresh()

    def refresh(self):
        """Refresh the access token using the refresh token."""
        endpoint = "/access-token"

        # prepare the request
        with self.refresher_lock:
            data = {"refreshToken": self.refresh_token}

        # ask for a new access token
        response = self.session.post(
            self.get_api_url(endpoint), json=data, headers={"Connection": "close"}
        )

        # check if the response is valid
        if response.status_code != 200:
            raise ConnectionError(
                f"Failed to refresh the access token. Server responded with {response.status_code}"
            )

        tokens = response.json()

        # write the new tokens
        with self.refresher_lock:
            self.access_token = tokens["token"]
            self.refresh_token = tokens["refreshToken"]

        self.__updateSessionHeaders()
        self.__updateUserLimits()
        return True

    def __updateSessionHeaders(self):
        # fetch the access token
        with self.refresher_lock:
            token = self.access_token

        # build the headers
        self.session.headers.update({"Authorization": f"Bearer {token}"})

    def checkAuth(self):
        with self.refresher_lock:
            if self.access_token is None:
                raise ConnectionError("Not yet authenticated.")

    def savetoken(self, filepath="qperfect.json"):
        """Save the current token to a file."""
        if self.refresh_token is None:
            self.connect()
        with open(filepath, "w") as f:
            json.dump({"token": self.refresh_token, "url": self.url}, f)

    def loadtoken(self, filepath="qperfect.json"):
        """Load and connect using a token from a file."""
        # Attempt to read the token file
        try:
            with open(filepath, "r") as f:
                data = json.load(f)
                saved_url = data.get("url", "")
                token = data.get("token")

            # Check if the current URL matches the saved URL in the token file
            if self.url != saved_url:
                raise ValueError(
                    f"The URL in the token file ({saved_url}) does not match the current URL ({self.url}). Authentication failed."
                )

        except Exception as e:
            # Log error and re-raise as ConnectionError
            getLogger().error(f"Error reading token file: {e}")
            raise ConnectionError("Failed to read token file.") from e

        # Establish a new connection using the token
        try:
            self.connectToken(token)
            return self
        except ConnectionError as e:
            # Log error and re-raise
            getLogger().error(f"Authentication failed: {e}")
            raise ConnectionError(
                "Authentication failed. Unable to connect using the stored token."
            ) from e

    def close(self):
        """Close the connection."""
        getLogger().info(f"Closing connection to {self.url}")
        # ask the refresher to stop
        with self.refresher_lock:
            self.refresher_stop = True
            if self.refresher_task is not None and self.refresher_task.is_alive():
                getLogger().info("Shutting down token refresher")
                self.refresher_task.join()
                getLogger().info(f"Task (done) @{hex(id(self.refresher_task))}")

        # clean the tokens
        self.access_token = None
        self.refresh_token = None

    def isOpen(self):
        """Check if the connection is open."""
        return (
            self.refresher_task is not None
            and self.refresher_task.is_alive()
            and self.access_token is not None
        )

    def checkUserLimits(self, limits=None):
        """Check if the user limits are respected."""
        if limits is None:
            limits = self.user_limits

        if limits is None:
            return

        if limits.get("enabledExecutionTime"):
            used_time = limits.get("usedExecutionTime")
            max_time = limits.get("maxExecutionTime")
            if used_time > max_time:
                getLogger().warning(
                    f"You have exceeded your computing time limit of {max_time} minutes"
                )

        if limits.get("enabledMaxExecutions"):
            used_exec = limits.get("usedExecutions")
            max_exec = limits.get("maxExecutions")
            if used_exec is not None and max_exec is not None:
                if used_exec > max_exec:
                    getLogger().warning(
                        f"You have exceeded your number of executions limit of {max_exec}"
                    )

    def __updateUserLimits(self):
        """Fetch user limits from the server."""
        self.checkAuth()

        endpoint = "users/limits"

        response = self.session.get(self.get_api_url(endpoint))

        if response.status_code != 200:
            raise ConnectionError(
                f"Failed to retrieve user limits. Server responded with {response.status_code}"
            )

        self.user_limits = response.json()
        self.checkUserLimits()

    def __str__(self):
        limits = self.user_limits
        result = f"MimiqConnection:\n├── url: {self.url}\n"

        if limits is not None:
            if limits.get("enabledExecutionTime"):
                used_time = limits.get("usedExecutionTime")
                max_time = limits.get("maxExecutionTime")
                if used_time is not None and max_time is not None:
                    result += f"├── Computing time: {round(used_time / 60)}/{round(max_time / 60)} minutes\n"
            if limits.get("enabledMaxExecutions"):
                used_exec = limits.get("usedExecutions")
                max_exec = limits.get("maxExecutions")
                if used_exec is not None and max_exec is not None:
                    result += f"├── Executions: {used_exec}/{max_exec}\n"
            if limits.get("enabledMaxTimeout"):
                max_timeout = limits.get("maxTimeout")
                if max_timeout is not None:
                    max_timeout = round(max_timeout)
                    result += f"├── Max time limit per request: {max_timeout} minutes\n"
                    result += f"├── Default time limit is equal to max time limit: {max_timeout} minutes\n"
            else:
                result += "├── Max time limit is: 30 minutes\n"
                result += "├── Default time limit is: 30 minutes\n"

        result += "└── status: " + ("open" if self.isOpen() else "closed")

        return result

    def __repr__(self) -> str:
        return self.__str__()
