from __future__ import annotations

import json
import logging
import os
from dataclasses import asdict, dataclass
from subprocess import run
from typing import IO, Dict, Optional

import httpx
from aext_panels_server.const import (
    DEFAULT_HTTPX_TIMEOUT_SEC,
    DEV_USE_STAGING_MICROSERVICE,
    PROJECT_ROOT_PATH,
    PYTHONANYWHERE_PRODUCTION_DOMAIN,
    PYTHONANYWHERE_PRODUCTION_SITE,
    Environment,
)
from aext_panels_server.exceptions import CantRetrieveCondaPackages
from aext_panels_server.logger import custom_logger
from aext_panels_server.request_schemas.notebook_projects_microservice import (
    CreateProject,
    CreateProjectVersion,
    UpdateProject,
)
from aext_panels_server.schemas import (
    CreatePanelAppRequest,
    PublishAppRequest,
    UnpublishAppRequest,
)
from aext_panels_server.utils import get_environment, handle_network_exceptions
from anaconda_cloud_auth.client import AuthConfig
from anaconda_cloud_auth.token import TokenInfo
from httpx import Timeout
from tornado.httputil import HTTPServerRequest
from traitlets.config import Application

from aext_shared.backend_proxy import backend_proxy_core
from aext_shared.errors import BackendError

logger = logging.getLogger(__name__)


@dataclass
class HeadersAndCookies:
    headers: Optional[Dict] = None
    cookies: Optional[Dict] = None


class CondaAPI:
    @staticmethod
    def list(environment):
        if environment == os.environ.get("CONDA_DEFAULT_ENV"):
            command = ["conda", "list", "--json"]
        else:
            command = ["conda", "list", "--json", "--name", environment]
        proc = run(command, text=True, capture_output=True)
        result = json.loads(proc.stdout)

        if "error" in result:
            raise CantRetrieveCondaPackages(f"Failed retrieving Conda Packages. {result['error']}")

        return result


class BaseAPI:
    incoming_request = None

    @property
    def pa_domain(self) -> str:
        return os.getenv("PYTHONANYWHERE_DOMAIN", PYTHONANYWHERE_PRODUCTION_DOMAIN)

    @property
    def pa_site(self) -> str:
        return os.getenv("PYTHONANYWHERE_SITE", PYTHONANYWHERE_PRODUCTION_SITE)

    def _get_headers_and_cookies(self, **kwargs) -> HeadersAndCookies:
        raise NotImplementedError()

    def _get_url(self, endpoint: str, *args, **kwargs) -> str:
        raise NotImplementedError()

    async def _simple_request(
        self,
        method,
        endpoint,
        params=None,
        data=None,
        json_data=None,
        timeout_sec=None,
        **kwargs,
    ):
        request_kwargs = {}
        headers_and_cookies = self._get_headers_and_cookies(**kwargs)
        url = self._get_url(endpoint, **kwargs)
        if timeout_sec is None:
            timeout_sec = DEFAULT_HTTPX_TIMEOUT_SEC
        try:
            custom_logger.info(
                f"--> <REQUEST {method.upper()} {url} params:{params} data:{data} json-data:{json_data}> timeout: {timeout_sec} headers:{headers_and_cookies.headers} cookies:{headers_and_cookies.cookies}"
            )

            if params:
                request_kwargs["params"] = params
            if headers_and_cookies.headers:
                request_kwargs["headers"] = headers_and_cookies.headers
            if headers_and_cookies.cookies:
                request_kwargs["cookies"] = headers_and_cookies.cookies

            if method.lower() in ["post", "put", "patch"]:
                # httpx does not take data argument for all methods. Adding for the ones relevant
                if data:
                    request_kwargs["data"] = data
                if kwargs.get("files"):
                    request_kwargs["files"] = kwargs.get("files")
                if json_data:
                    request_kwargs["json"] = json_data

            if self.incoming_request is not None:
                custom_logger.info("Using backend proxy")
                custom_logger.info(f"--> Request KWARGS: {request_kwargs}")
                request_kwargs["method"] = method.upper()
                httpx_response = (await backend_proxy_core(self.incoming_request, url, **request_kwargs))[1]

            else:
                # TODO: remove this once we are done with tests and safe with backend-proxy
                custom_logger.info("Using async client")
                async with httpx.AsyncClient(
                    headers=headers_and_cookies.headers,
                    cookies=headers_and_cookies.cookies,
                    timeout=Timeout(timeout_sec),
                    follow_redirects=True,
                ) as client:
                    httpx_response = await getattr(client, method)(url, **request_kwargs)
                    res_content = httpx_response.text
                    custom_logger.info(
                        f"--> <RESPONSE status:{httpx_response.status_code} reason:{httpx_response.reason_phrase} content:{res_content}>"
                    )
            return httpx_response
        except BackendError as ex:
            custom_logger.error(f"--> Proxy request error: {vars(ex)}")
        except Exception as ex:
            custom_logger.error(f"--> Error while making simple request: {method} {url}")
            custom_logger.debug(f"--> Exception: {ex}")
            raise

    async def _get(self, endpoint, params=None, timeout_sec=None, **kwargs):
        return await self._simple_request("get", endpoint, params=params, timeout_sec=timeout_sec, **kwargs)

    async def _put(self, endpoint, data=None, json_data=None, timeout_sec=None, **kwargs):
        return await self._simple_request(
            "put", endpoint, data=data, json_data=json_data, timeout_sec=timeout_sec, **kwargs
        )

    async def _patch(self, endpoint, params=None, json_data=None, timeout_sec=None, **kwargs):
        return await self._simple_request(
            "patch",
            endpoint,
            params=params,
            json_data=json_data,
            timeout_sec=timeout_sec,
            **kwargs,
        )

    async def _post(self, endpoint, data=None, json_data=None, timeout_sec=None, **kwargs):
        return await self._simple_request(
            "post", endpoint, data=data, json_data=json_data, timeout_sec=timeout_sec, **kwargs
        )

    async def _delete(self, endpoint: str, data: Dict = None, timeout_sec=None, **kwargs):
        return await self._simple_request("delete", endpoint, data=data, timeout_sec=timeout_sec, **kwargs)


class PythonAnywhereAPI(BaseAPI):
    """
    Implements an API to make requests to PythonAnywhere APIs.

    See details at https://help.pythonanywhere.com/pages/API/

    Attributes
    ----------
    domain: str | None
        The PythonAnywhere domain to make requests on.
    username: str | None
        The username making the request.
    token: str | None
        An API token authorizing the user. See
        https://www.pythonanywhere.com/user/<username>/account/#api_token
    csrftoken: str | None
        CSRF Token to authenticate request.
    sessionidstr: str | None
        Session ID cookie used to identify authorized requests.
    """

    _endpoints = {
        "webapps": "/api/v1/user/{username}/webapps/",
        "webapp": "/api/v1/user/{username}/webapps/{app_id}",
        "domains": "/api/v1/user/{username}/domains/{domain_name}/",
        "list_files": "/api/v0/user/{username}/files/tree/?path={path}",
        "file_access": "/api/v0/user/{username}/files/path{path}",
        "delete_file": "/api/v0/user/{username}/files/path{path}",
        "panels": "/api/v1/user/{username}/panels/",
        "panel": "/api/v1/user/{username}/panels/{domain_name}/",
        "max_apps": "/api/v0/user/{username}/features/",
    }

    _root_path = PROJECT_ROOT_PATH

    def __init__(
        self,
        domain: str | None = None,
        username: str | None = None,
        token: str | None = None,
        csrftoken: str | None = None,
        sessionid: str | None = None,
    ):
        if not domain:
            domain = os.getenv("PYTHONANYWHERE_SITE", PYTHONANYWHERE_PRODUCTION_SITE)
        self.domain = domain

        if not username:
            username = os.getenv("PYTHONANYWHERE_USER")

        if not token:
            token = os.getenv("PYTHONANYWHERE_TOKEN", "")

        self.username = username
        self.token = token
        self.csrftoken = csrftoken
        self.sessionid = sessionid
        self.session = None
        self.log = Application.instance().log

    def _get_headers_and_cookies(self, **kwargs) -> HeadersAndCookies:
        """
        Returns a pair of headers and cookies used to create the request session
        """
        headers = {
            "Referer": f"https://{self.domain}/user/{self.username}",
        }
        if self.token:
            headers["Authorization"] = f"Token {self.token}"
        else:
            headers["X-CSRFToken"] = self.csrftoken

        cookies = {}
        if self.sessionid:
            cookies["sessionid"] = self.sessionid
        if self.csrftoken:
            cookies["csrftoken"] = self.csrftoken

        custom_logger.info("Creating request session")
        return HeadersAndCookies(headers=headers, cookies=cookies)

    def _get_url(self, endpoint: str, **kwargs) -> str:
        endpoint = endpoint.format(username=self.username, **kwargs)
        return f"https://{self.domain}{endpoint}"

    # Public API

    async def list_deployments(self):
        """
        Returns the list of deployments

        Returns
        -------
        deployments: List[Dict[str, Any]]

        Raises
        ------
        HTTPError
        """
        return await self._get(self._endpoints["webapps"])

    async def get_deployment_info(self, app_id: int) -> httpx.Response:
        """
        Return information about a web app's configuration

        Returns
        -------
        deployments: List[Dict[str, Any]]

        Raises
        ------
        HTTPError
        """
        custom_logger.info(f"Getting app {app_id} info")
        return await self._get(self._endpoints["webapp"], app_id=app_id)

    async def get_max_apps_info(self, user_id: str) -> httpx.Response:
        """
        Return information about how many apps a user can deploy

        Returns
        -------
        features: Dict[str, Any]

        Raises
        ------
        HTTPError
        """
        custom_logger.info(f"Getting max apps info for user {user_id}")
        return await self._get(self._endpoints["max_apps"], user_id=user_id)

    async def delete_deployment(self, domain_name: str) -> httpx.Response:
        """
        Deletes a deployment (a deployed app which may be inactive or active)
        and all domains associated with it
        Parameters
        ----------
        domain_name: deployed app domain name

        Returns httpx response object
        -------

        """
        custom_logger.info(f"Deleting app deployment {domain_name}")
        return await self._delete(self._endpoints["panel"], domain_name=domain_name)

    async def delete_project(self, project_id: str) -> httpx.Response:
        """
        Deletes all files related to the project/app that are stored in PA file storage
        Parameters
        ----------
        project_id: project identifier

        Returns httpx response object
        -------

        """
        custom_logger.info(f"Deleting app project {project_id}")
        return await self._delete(
            self._endpoints["delete_file"],
            path=f"{self._root_path}.projects/{project_id}",
        )

    async def add_deployment(self, domain_name, **params):
        """
        Return information about a web app's configuration

        Parameters
        ----------
        domain_name: str
            Name of the domain to host the deployment at.
        python_version: str
            Python version in the base environment.
        source_directory: str
            Directory where the application source lives.
        virtualenv_path: str
            Virtual environment to activate
        force_https: bool
            Whether to force an HTTPS redirect
        password_protection_enabled: bool
            Whether to password protection to the app.
        password_protection_username: str
            Username to authorize with the password protected application.
        password_protection_password: str
            Password to authorize with the password protected application.

        Raises
        ------
        HTTPError
        """
        return await self._put(self._endpoints["app"], domain_name=domain_name)

    async def list_files(self, path):
        """
        List files at the given path.

        Parameters
        ----------
        path: str
          The path for which to list files.

        Returns
        -------
        files: List[str]

        Raises
        ------
        HTTPError
        """
        return await self._get(self._endpoints["list_files"], path=f"{self._root_path}/{path}")

    async def download_file(self, path, include_root_path: bool = True):
        """
        List files at the given path.

        Parameters
        ----------
        path: str
          The path for which to list files.

        include_root_path: bool
          The root path will be appended

        Returns
        -------
        files: List[str]

        Raises
        ------
        HTTPError
        """
        base_path = ""
        if include_root_path:
            base_path = self._root_path

        return await self._get(self._endpoints["file_access"], path=f"{base_path}{path}")

    async def unpublish_app(self, request_data: UnpublishAppRequest, domain_name: str) -> httpx.Response:
        """
        Given a published app this function unpublish it by flipping the `enabled` flag for
        a given UnpublishAppRequest (domain, enabled, command).

        Returns

        Parameters
        ----------
        request_data
        domain_name

        Returns httpx.Response
        -------

        """
        custom_logger.info(f"Unpublishing panel app {request_data}")
        return await self._patch(self._endpoints["panel"], json_data=asdict(request_data), domain_name=domain_name)

    async def publish_app(self, request_data: PublishAppRequest, domain_name: str) -> httpx.Response:
        """
        Given an unpublished app this function publishes it by flipping the `enabled` flag for
        a given PublishAppRequest (domain, enabled, command).

        Parameters
        ----------
        request_data
        domain_name

        Returns httpx.Response
        -------

        """
        custom_logger.info(f"Publishing panel App {request_data}")
        return await self._patch(self._endpoints["panel"], json_data=asdict(request_data), domain_name=domain_name)

    async def upload_file(self, path, fileobj: IO) -> httpx.Response:
        """
        Uploads a file object to a given path.

        Warning: If the file in the given exists it will be overwritten.

        Parameters
        ----------
        path: str
            Path relative to upload the file to.
        fileobj: IO
            File object containing the object to be uploaded.

        Returns
        -------
        response
        """
        custom_logger.info(f"Uploading file {path}")
        files = {"content": ("content", fileobj)}
        return await self._post(self._endpoints["file_access"], path=path, files=files)

    async def create_panel_app(self, request_data: CreatePanelAppRequest) -> httpx.Response:
        """
        Creates a new panel app given a CreatePanelAppRequest (domain, enabled, command)
        Parameters
        ----------
        request_data

        Returns
        -------
        """
        custom_logger.info(f"Creating panel app {request_data}")
        return await self._post(self._endpoints["panels"], json_data=asdict(request_data))


class ProjectDeploymentMicroserviceAPI(BaseAPI):
    NUCLEUS_BASE_URLS = {
        Environment.PRODUCTION: "https://anaconda.cloud/api/panels/v1/",
        Environment.STAGING: "https://nucleus-latest.anacondaconnect.com/api/panels/v1/",
        Environment.LOCAL: os.getenv("DEV_PANELS_MICROSERVICE_ADDRESS", "http://notebook-deployment-api:8000/v1/"),
    }

    anaconda_cloud_access_token = None
    anaconda_cloud_username = None
    incoming_request = None
    pa_username = None

    def __init__(self, environment: Environment) -> None:
        try:
            self.base_url = self.NUCLEUS_BASE_URLS[environment]
        except KeyError:
            logger.error(f"Could not find neither an API key nor Nucleus URL for {environment}")
            raise ValueError(f"Could not find neither an API key nor Nucleus URL for {environment}")

    def set_anaconda_cloud_access_token(self, access_token: Optional[str]) -> None:
        self.anaconda_cloud_access_token = access_token

    def set_anaconda_cloud_username(self, username: Optional[str]) -> None:
        self.anaconda_cloud_username = username

    def set_incoming_request(self, incoming_request: Optional[HTTPServerRequest]) -> None:
        self.incoming_request = incoming_request

    def _get_url(self, endpoint, **kwargs) -> str:
        """
        Get the URL used in the request
        Parameters
        ----------
        endpoint: the API endpoint
        kwargs

        Returns full url
        -------

        """
        return f"{self.base_url}{endpoint}"

    def _get_headers_and_cookies(self, **kwargs) -> HeadersAndCookies:
        """
        Get request headers
        Returns: Dict containing all necessary headers
        -------

        """
        pa_username = os.getenv("JUPYTERHUB_USER", os.getenv("PYTHONANYWHERE_USER", self.anaconda_cloud_username))
        pa_api_token = os.getenv("API_TOKEN", os.getenv("PYTHONANYWHERE_TOKEN"))
        cloud_flare_client = os.getenv("NUCLEUS_CLOUDFLARE_CLIENT_ID")
        cloud_flare_secret = os.getenv("NUCLEUS_CLOUDFLARE_CLIENT_SECRET")
        pa_site = self.pa_site
        pa_domain = self.pa_domain

        if pa_username is None:
            raise ValueError("PA Username not set")

        headers = {
            "pa-username": pa_username,
            "pa-site": pa_site,
            "pa-domain": pa_domain,
        }

        if pa_api_token:
            headers["pa-api-token"] = pa_api_token

        # Authorization header
        # Currently works with both Panel MS API KEY and anaconda.cloud access token
        assert self.anaconda_cloud_access_token, "Anaconda cloud access token missing"
        headers["Authorization"] = f"Bearer {self.anaconda_cloud_access_token}"
        custom_logger.info(f"Authorization: Anaconda Cloud Access Token {headers}")

        if cloud_flare_client and cloud_flare_secret:
            headers.update(
                {
                    "CF-Access-Client-Id": cloud_flare_client,
                    "CF-Access-Client-Secret": cloud_flare_secret,
                }
            )

        return HeadersAndCookies(
            headers=headers,
            cookies={},
        )

    async def get_project(self, project_id: str, **kwargs) -> httpx.Response:
        endpoint = f"project/{project_id}/"
        return await self._get(endpoint, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def create_project(self, request_data: CreateProject, archive_content: IO, **kwargs) -> httpx.Response:
        """
        Creates a new project
        Parameters
        ----------
        request_data: all the data necessary to create the project
        archive_content: archive content (bytes)

        Returns http response
        -------

        """
        endpoint = "project/"
        archive = {"archive": archive_content}
        return await self._post(endpoint, data=asdict(request_data), files=archive, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def upload_project_archive(self, project_id: str, archive_content: IO, **kwargs) -> httpx.Response:
        """
        Uploads project's archive

        Parameters
        ----------
        project_id: id of the project
        archive_content: archive content (bytes)

        Returns http response
        -------

        """
        endpoint = f"project/{project_id}/archive/upload/"
        archive = {"archive": archive_content}
        return await self._post(endpoint, files=archive, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def update_project(self, project_id: str, request_data: UpdateProject, **kwargs) -> httpx.Response:
        """
        Updates the project information
        Parameters
        ----------
        project_id: project to be updated
        request_data: the new project data
        kwargs

        Returns http response
        -------

        """
        endpoint = f"project/{project_id}/"
        return await self._patch(endpoint, json_data={"project_data": asdict(request_data)})

    @handle_network_exceptions(max_retries=2, default_timeout=4)
    async def list_projects(self, user_id: str, **kwargs) -> httpx.Response:
        """
        List all users project
        Parameters
        ----------
        user_id: id of the user
        kwargs

        Returns http repsonse
        -------

        """
        endpoint = f"user/{user_id}/projects/"
        return await self._get(endpoint, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def delete_project(self, project_id: str, **kwargs) -> httpx.Response:
        """
        Deletes a project
        Parameters
        ----------
        project_id: project id that should be deleted
        kwargs

        Returns http response
        -------

        """
        endpoint = f"project/{project_id}/"
        return await self._delete(endpoint, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def publish_latest_version(self, project_id: str, **kwargs) -> httpx.Response:
        """
        Publish the latest project saved version
        Parameters
        ----------
        project_id: project that should have its latest version published
        kwargs

        Returns http response
        -------

        """
        endpoint = f"project/{project_id}/publish/"
        return await self._post(endpoint, data={"domain": self.pa_domain}, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def stop_app(self, app_domain_name: str, **kwargs) -> httpx.Response:
        """
        Given an up and running app this function stops the app.
        Parameters
        ----------
        app_domain_name: domain name associated to the app that should be stopped
        kwargs

        Returns http response
        -------

        """
        endpoint = f"deployment/{app_domain_name}/unpublish/"
        return await self._post(endpoint, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def start_app(self, app_domain_name: str, **kwargs) -> httpx.Response:
        """
        This function start a stopped app
        Parameters
        ----------
        app_domain_name: domain name associated to the app that should be stopped
        kwargs

        Returns http response
        -------

        """
        endpoint = f"deployment/{app_domain_name}/publish/"
        return await self._post(endpoint, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def restart_app(self, app_domain_name: str, **kwargs) -> httpx.Response:
        """
        Given an up and running app this function restarts it by stopping and starting the app
        Parameters
        ----------
        app_domain_name: domain name associated to the app that should be stopped
        kwargs

        Returns http response
        -------

        """
        endpoint = f"deployment/{app_domain_name}/restart/"
        return await self._post(endpoint, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def download_files(self, project_id: str, project_version: str, **kwargs) -> httpx.Response:
        """
        Downloads the project files (archive)
        Parameters
        ----------
        project_id: project that should be downloaded
        project_version: the version of the project that should be downloaded
        kwargs

        Returns http response
        -------

        """
        endpoint = f"project/{project_id}/version/{project_version}/download/"
        return await self._get(endpoint, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def create_project_version(
        self, request_data: CreateProjectVersion, archive_content: IO, **kwargs
    ) -> httpx.Response:
        """
        Creates a new project version

        Parameters
        ----------
        project_id: project id that will have a new version created
        request_data: data necessary to create a new version
        kwargs

        Returns http response
        -------

        """
        endpoint = f"project/{request_data.project_id}/version/"
        archive = {"archive": archive_content}
        return await self._post(endpoint, data=asdict(request_data), files=archive, **kwargs)

    @handle_network_exceptions(max_retries=1)
    async def get_project_notebook(self, project_id: str, version_id: str, **kwargs) -> httpx.Response:
        raise NotImplementedError

    @handle_network_exceptions(max_retries=1)
    async def delete_project_version(self, project_id: str, version_id: str, **kwargs) -> httpx.Response:
        raise NotImplementedError


project_deployment_ms_api = ProjectDeploymentMicroserviceAPI(get_environment())
