import asyncio
import base64
import datetime as dt
import io
import json
import os
import pathlib
import zipfile
from dataclasses import asdict
from typing import List, Optional
from uuid import uuid4

import yaml
from aext_panels_server.api import CondaAPI, PythonAnywhereAPI
from aext_panels_server.const import (
    NBCONVERT_PACKAGE_NAME,
    PANEL_PACKAGE_MINIMUM_VERSION,
    PANEL_PACKAGE_NAME,
    PANEL_VALID_KERNELS_PATTERN,
    PROJECT_ROOT_PATH,
)
from aext_panels_server.exceptions import (
    CantCreateZipFile,
    CantGenerateCondaPackages,
    CantRetrieveCondaPackages,
    NbconvertNotInstalled,
    PanelNotInstalled,
)
from aext_panels_server.logger import custom_logger
from aext_panels_server.schemas import (
    FileInfo,
    GenerateFiles,
    PAProjectInfo,
    ProjectInfoData,
    VersioningData,
)
from aext_panels_server.utils import (
    call_api,
    clean_notebook,
    escape_filename,
    extract_file_name,
    get_relative_file_path,
)
from packaging import version as packaging_version


class ProjectService:
    async def create_project(self, file_info, environment):
        next_version = self._create_or_get_version()
        versioning = VersioningData(next_version=next_version)
        title = extract_file_name(file_info.file_name)

        project = ProjectInfoData(
            title=title,
            project_id="",
            versioning=versioning,
            notebook_file_name=file_info.file_name,
            notebook_file_size=file_info.file_size,
            permission="Public",
            environment=environment,
            description="",
            domains=[],
        )

        return project

    async def create_project_info(
        self,
        title: str,
        version: str,
        description: str,
        notebook_file_info: FileInfo,
        environment: str,
    ) -> PAProjectInfo:
        """
        Create a Python Anywhere Project Info


        Parameters:
        -----------
        api: PythonAnywhereAPI
        project_id: Project's id
        title: Project's title
        version: Project next version
        description: Project description
        notebook_file_info: Notebook file info which contains file name and size
        environment: notebook conda environment

        Returns dict containing the PA project info
        """
        project_id = uuid4()
        project_path = self.get_project_info_file_path(project_id)
        relative_notebook_file_path = get_relative_file_path(notebook_file_info.file_name)

        return PAProjectInfo(
            title=title,
            project_id=str(project_id),
            path=project_path,
            versions=[version],
            description=description,
            notebook_file_size=notebook_file_info.file_size,
            notebook_file_name=relative_notebook_file_path,
            environment=environment,
        )

    async def get_project_info(self, api: PythonAnywhereAPI, project_id: str) -> Optional[PAProjectInfo]:
        """
        Get a Python Anywhere Project Info

        Parameters:
        -----------
        api: PythonAnywhereAPI
        project_id: Project's id

        Returns dict containing the PA project info
        """
        project_path = self.get_project_info_file_path(project_id)
        err, response = await call_api(api.download_file(project_path), always_return_response=True)

        if err.status and err.message == "Not found":
            # TODO: App should be removed from project_index and force an unpublished
            custom_logger.error(f"Cant Retrieve Project Info. HTTP response: {response} - Detail: {response.json()}")
            return None

        if response:
            return PAProjectInfo(**response.json())

    async def get_project(self):
        raise NotImplementedError

    def get_project_info_file_path(self, project_id: Optional[str], include_root_path: bool = False) -> str:
        file_path = f".projects/{project_id}/project_info.json"
        if include_root_path:
            file_path = f"{PROJECT_ROOT_PATH}{file_path}"
        return file_path

    def get_project_archive_file_path(self, project_id: str, version: str, include_root_path: bool = False) -> str:
        file_name = f"{project_id}-{version}.zip"
        file_path = f".projects/{project_id}/{file_name}"
        if include_root_path:
            file_path = f"{PROJECT_ROOT_PATH}{file_path}"
        return file_path

    async def generate_zip_file(
        self,
        username: str,
        name: str,
        version: str,
        environment: str,
        app_path: str,
        extra_files: List[str] = [],
    ) -> io.BytesIO:
        """
        Generates a conda-project zip archive

        Parameters
        ----------
        username: user's PythonAnywhere username
        name: Project name
        version: Project version
        environment: Conda environment name
        app_path: Path to application
        extra_files: List of additional files to add to the zip archive

        Returns
        -------
        BytesIO
        """
        try:
            packages = CondaAPI.list(environment)
            # Organizing project packages
            dependencies, pip_packages = [], []
            environment_spec = {"name": environment, "dependencies": dependencies}
            env_contains_valid_panel = False
            env_contains_nbconvert = False
            for package in packages:
                if package["name"] == PANEL_PACKAGE_NAME:
                    if self.is_panel_package_valid(package):
                        env_contains_valid_panel = True
                if package["name"] == NBCONVERT_PACKAGE_NAME:
                    env_contains_nbconvert = True
                if package["platform"] == "pypi":
                    pip_packages.append(f"{package['name']}=={package['version']}")
                else:
                    dependencies.append(f"{package['name']}={package['version']}")
            if pip_packages:
                dependencies.append({"pip": pip_packages})
        except CantRetrieveCondaPackages as e:
            custom_logger.info(f"Can't generate list of Conda Packages {e}")
            raise CantGenerateCondaPackages(message=e.message)
        except Exception as e:
            custom_logger.info(f"Unexpected error. Can't generate list of Conda Packages {e}")
            raise CantGenerateCondaPackages(message="Can't generate Conda Packages")
        if not env_contains_valid_panel:
            raise PanelNotInstalled(
                f"The panel >= 1.0.4 package was not found. Please select the latest {PANEL_VALID_KERNELS_PATTERN} kernel to proceed."
            )
        if not env_contains_nbconvert:
            raise NbconvertNotInstalled(
                "The nbconvert package was not found. Make sure it is installed in your environment"
            )

        # Note: In future we have to distinguish between served files
        # and any additional assets.
        escaped_nb_filename = escape_filename(os.path.basename(app_path))
        project = {
            "name": name,
            "maintainers": [username],
            "platforms": ["linux-64"],
            "environments": {"default": ["environment.yaml"]},
            "commands": {"serve": {"cmd": f"panel serve {escaped_nb_filename}"}},
        }

        custom_logger.info("Creating project yaml file")
        project_yaml = yaml.dump(project, Dumper=yaml.dumper.SafeDumper, sort_keys=False)
        custom_logger.info("Creating environment yaml file")
        environment_yaml = yaml.dump(environment_spec, Dumper=yaml.dumper.SafeDumper, sort_keys=False)

        proj_path = f"{escape_filename(name)}-{version}"
        try:
            custom_logger.info("Creating project zip file")
            project_zip = io.BytesIO()
            with zipfile.ZipFile(project_zip, "w", zipfile.ZIP_DEFLATED) as zipf:
                project_info = zipfile.ZipInfo(filename=os.path.join(proj_path, "conda-project.yaml"))
                project_data = project_yaml.encode("utf-8")
                project_info.file_size = len(project_data)
                zipf.writestr(project_info, project_data)

                env_info = zipfile.ZipInfo(filename=os.path.join(proj_path, "environment.yaml"))
                env_data = environment_yaml.encode("utf-8")
                env_info.file_size = len(env_data)
                zipf.writestr(env_info, env_data)

                with open(app_path, "r") as f:
                    app_data = f.read()
                if app_path.endswith(".ipynb"):
                    app_data = clean_notebook(app_data)
                app_info = zipfile.ZipInfo(filename=os.path.join(proj_path, escaped_nb_filename))
                app_data = app_data.encode("utf-8")
                app_info.file_size = len(app_data)
                zipf.writestr(app_info, app_data)

                # Add extra files relative to application path
                app_dir = pathlib.Path(app_path).parent
                for fpath in extra_files:
                    with open(fpath, "rb") as f:
                        extra_data = f.read()
                    extra_path = os.path.join(proj_path, str(pathlib.Path(fpath).relative_to(app_dir)))
                    extra_info = zipfile.ZipInfo(filename=extra_path)
                    extra_info.size = len(extra_data)
                    zipf.writestr(extra_info, extra_data)
            project_zip.seek(0)
        except FileNotFoundError as e:
            custom_logger.info("File Not found {e}")
            raise e
        except Exception as e:
            custom_logger.info(f"Can't create project zip file {e}")
            raise CantCreateZipFile(message="Can't create tar file")

        return project_zip

    async def generate_files(
        self,
        username: str,
        name: str,
        version: str,
        project_info: PAProjectInfo,
        environment: str,
        notebook_path: str,
    ) -> GenerateFiles:
        """
        Generates the project files which includes the project archive
        file and the project info file.

        Parameters
        ----------
        username: user's username on PythonAnywhere
        name: project name
        version: project version to deploy
        project_info: all project essential information
        environment: project conda environment
        notebook_path: relative path to the notebook file

        Returns
        -------
        GenerateFiles
        """
        custom_logger.info(f"Creating files for {project_info.title} project")
        info_file_content = io.BytesIO(json.dumps(asdict(project_info)).encode("utf-8"))
        archive_file_content = await self.generate_zip_file(username, name, version, environment, notebook_path, [])
        return GenerateFiles(
            project_info_content=info_file_content,
            archive_content=archive_file_content,
        )

    @staticmethod
    def is_valid_version(version: str) -> bool:
        """
        Validate project version

        Parameters
        ----------
        version: project version - format: `YYYY.MM.X`, where X is int

        Returns
        -------
        True if it is valid, otherwise False
        """
        if not version:
            return False

        version_split = version.split(".")

        # check if version has 3 parts: YYYY.MM.X
        if len(version_split) == 3:
            # check XXXX and MM
            if len(version_split[0]) == 4 and len(version_split[1]) == 2:
                # check if X starts with "0". e.g.: 2023.04.01
                if version_split[2][0] != "0":
                    return True

        return False

    @staticmethod
    def _get_version():
        raise NotImplementedError

    # TODO: To be removed - it will be replaced by `_create_version()` and `_get_version()`
    @staticmethod
    def _create_or_get_version(vdate: Optional[str] = None, project_info: Optional[PAProjectInfo] = None) -> str:
        """
        Create of get project version

        Parameters
        ----------
        vdate: version date - format: `YYYY.MM`
        project_info: PaProjectInfo

        Returns
        -------
        project_version: Project version - format: `YYYY.MM.X`
        """
        if not vdate:
            now = dt.datetime.now()
            vdate = f"{now.year}.{now.month:0>2}"

        existing = []
        if project_info:
            existing = [v for v in project_info.versions if v.startswith(vdate)]
        vnumber = max([int(e.split(".")[-1]) for e in existing]) if existing else 0
        return f"{vdate}.{vnumber + 1}"

    def is_panel_package_valid(self, package: dict) -> bool:
        """
        Check for panel package validit in the environment
        ----------
        panel_package: Panel package data retrieved from conda list

        Returns boolean
        -------

        """
        custom_logger.debug(f"Checking if panel package is valid: {package}")
        if package["name"] == PANEL_PACKAGE_NAME:
            panel_version = packaging_version.parse(package["version"])
            panel_min_version = packaging_version.parse(PANEL_PACKAGE_MINIMUM_VERSION)
            custom_logger.debug(f"Panel version captured: {package}")
            if panel_version < panel_min_version:
                return False

        return True


project_service = ProjectService()
