import os
from datetime import datetime
from pathlib import Path

from git import Repo

from album.core.api.controller.controller import IAlbumController
from album.core.api.controller.deploy_manager import IDeployManager
from album.core.api.model.catalog import ICatalog
from album.core.model.default_values import DefaultValues
from album.core.utils.export.changelog import create_changelog_file, \
    process_changelog_file
from album.core.utils.export.docker import create_docker_file
from album.core.utils.operations.file_operations import copy, write_dict_to_yml, force_remove, \
    folder_empty
from album.core.utils.operations.git_operations import create_new_head, add_files_commit_and_push, \
    retrieve_default_mr_push_options, checkout_main, clean_repository, remove_files, remove_tag, add_tag, \
    get_tags, revert
from album.core.utils.operations.resolve_operations import get_attributes_from_string, dict_to_coordinates, \
    as_tag_unversioned, as_tag
from album.core.utils.operations.solution_operations import get_deploy_dict
from album.runner import album_logging
from album.runner.core.api.model.coordinates import ICoordinates
from album.runner.core.api.model.solution import ISolution

module_logger = album_logging.get_active_logger


class DeployManager(IDeployManager):

    def __init__(self, album: IAlbumController):
        self.album = album

    def deploy(
            self,
            deploy_path,
            catalog_name: str,
            dry_run: bool,
            push_options=None,
            git_email: str = None,
            git_name: str = None,
            force_deploy: bool = False,
            changelog: str = None
    ):
        if dry_run:
            module_logger().info('Pretending to deploy %s to %s...' % (deploy_path, catalog_name))
        else:
            module_logger().info('Deploying %s to %s...' % (deploy_path, catalog_name))

        deploy_path = Path(deploy_path)
        path_to_solution = self._get_path_to_solution(deploy_path)

        active_solution = self.album.state_manager().load(path_to_solution)
        active_solution.setup().changelog = changelog

        if catalog_name:
            catalog = self.album.catalogs().get_by_name(catalog_name)
        else:
            raise RuntimeError("No catalog specified for deployment!")

        self._deploy(catalog, active_solution, deploy_path, dry_run, force_deploy, push_options, git_email, git_name)

        if dry_run:
            module_logger().info('Successfully pretended to deploy %s to %s.' % (deploy_path, catalog_name))
        else:
            module_logger().info('Successfully deployed %s to %s.' % (deploy_path, catalog_name))

    def undeploy(
            self,
            solution_to_resolve,
            catalog_name: str,
            dry_run: bool,
            push_options=None,
            git_email: str = None,
            git_name: str = None
    ):

        exit_msg = 'Pretending to remove %s from %s...' if dry_run else 'Removing %s from %s...'
        module_logger().info(exit_msg % (solution_to_resolve, catalog_name))

        coordinates = dict_to_coordinates(get_attributes_from_string(solution_to_resolve))
        catalog = self.album.catalogs().get_by_name(catalog_name)
        # check for cache catalog only
        if catalog.is_cache():
            raise RuntimeError("Cannot undeploy from catalog only used for caching! Aborting...")

        with catalog.retrieve_catalog(self.get_download_path(catalog), force_retrieve=True) as repo:
            # load index
            catalog.set_index_path(Path(repo.working_tree_dir).joinpath(DefaultValues.catalog_index_file_name.value))
            self.album.migration_manager().load_index(catalog)

            # get versions from catalog
            ordered_version_tags = self._get_tags_for_coordinates(repo, coordinates)
            current_version_tag = ordered_version_tags[0] if len(ordered_version_tags) > 0 else None
            previous_version_tag = ordered_version_tags[1] if len(ordered_version_tags) > 1 else None

            version_to_be_removed = as_tag(coordinates)
            if not version_to_be_removed in ordered_version_tags:
                raise LookupError("Cannot find %s in deployed versions %s" % (version_to_be_removed, ordered_version_tags))

            self._remove_from_downloaded_catalog(catalog, coordinates, dry_run)

            if not previous_version_tag:
                # remove files from catalog, update db, remove tag
                self._remove_db_entry_and_files(repo, coordinates, dry_run, push_options, git_email, git_name)
            else:
                if current_version_tag == version_to_be_removed:
                    # roll back to last version
                    self._remove_db_entry_and_revert_files(repo, coordinates, previous_version_tag, dry_run,
                                                   push_options, git_email, git_name)
                else:
                    # remove tag, update db
                    self._remove_db_entry_and_tag(repo, coordinates, dry_run, push_options, git_email, git_name)

        # refresh the local index of the catalog
        self.album.migration_manager().refresh_index(catalog)

        exit_msg = 'Successfully pretended to remove %s from %s.' if dry_run else 'Successfully removed %s from %s.'
        module_logger().info(exit_msg % (solution_to_resolve, catalog_name))

    def _deploy(
            self,
            catalog: ICatalog,
            active_solution: ISolution,
            deploy_path: Path,
            dry_run: bool,
            force_deploy: bool,
            push_options: list,
            git_email=None,
            git_name=None
    ):
        # check for cache catalog only
        if catalog.is_cache():
            raise RuntimeError("Cannot deploy to catalog only used for caching! Aborting...")

        dl_path = self.get_download_path(catalog)

        # a catalog is always a repository
        with catalog.retrieve_catalog(dl_path, force_retrieve=True) as repo:
            # load index
            catalog.set_index_path(Path(repo.working_tree_dir).joinpath(DefaultValues.catalog_index_file_name.value))
            self.album.migration_manager().load_index(catalog)

            # requires a loaded index
            process_changelog_file(catalog, active_solution, deploy_path)

            if catalog.type() == "direct":
                self._deploy_to_direct_catalog(
                    repo, catalog, active_solution, deploy_path, dry_run, force_deploy, push_options, git_email,
                    git_name
                )
            elif catalog.type() == "request":
                self._deploy_to_request_catalog(
                    repo, catalog, active_solution, deploy_path, dry_run, push_options, git_email, git_name
                )
            else:
                raise NotImplementedError("type %s not supported!" % catalog.type())

    def _deploy_to_direct_catalog(
            self,
            repo: Repo,
            catalog: ICatalog,
            active_solution: ISolution,
            deploy_path: Path,
            dry_run: bool,
            force_deploy: bool,
            push_options: list,
            git_email=None, git_name=None
    ):
        """Routine to deploy to a direct catalog"""
        # adding solutions in the SQL databse for catalog type "direct" done on user side, hence the timestamp
        active_solution.setup()["timestamp"] = datetime.strftime(datetime.now(), '%Y-%m-%dT%H:%M:%S.%f')

        self._add_to_downloaded_catalog(catalog, active_solution, dry_run, force_deploy)

        solution_files = self._deploy_routine_in_local_src(catalog, repo, active_solution, deploy_path)
        commit_files = solution_files + [catalog.index_file_path()]
        solution_root = str(self.album.configuration().get_solution_path_suffix_unversioned(active_solution.coordinates()))

        # merge and push
        if not dry_run:
            try:
                self._push_directly(active_solution.coordinates(), repo, [solution_root], commit_files, dry_run, push_options, git_email, git_name)
                add_tag(repo, as_tag(active_solution.coordinates()))
            except Exception as e:
                module_logger().error("Pushing to catalog failed! Rolling back deployment...")
                try:
                    clean_repository(repo)
                    for export in solution_files:
                        export.unlink()
                finally:
                    raise e

            # refresh the local index of the catalog
            self.album.migration_manager().refresh_index(catalog)
        else:
            module_logger().info("Would commit the changes and push to %s..." % catalog.src())
            module_logger().info("Would refresh the index from src")

    def _deploy_to_request_catalog(
            self,
            repo: Repo,
            catalog: ICatalog,
            active_solution: ISolution,
            deploy_path: Path,
            dry_run: bool,
            push_options: list,
            git_email=None, git_name=None
    ):
        """Routine to deploy to a request catalog."""

        # include files/folders in catalog
        mr_files = self._deploy_routine_in_local_src(
            catalog, repo, active_solution, deploy_path
        )

        if not push_options:
            push_options = retrieve_default_mr_push_options(catalog.src())

        self._create_merge_request(active_solution.coordinates(), repo, mr_files, dry_run, push_options, git_email, git_name)

    def _deploy_routine_in_local_src(
            self, catalog: ICatalog, repo: Repo, active_solution: ISolution, deploy_path: Path
    ):
        """Performs all routines a deploy process needs to do locally.

        Returns:
            solution zip file and additional attachments.

        """
        solution_files = self._collect_solution_files(catalog, repo.working_tree_dir, active_solution, deploy_path)

        return solution_files

    def get_download_path(self, catalog: ICatalog):
        return Path(self.album.configuration().cache_path_download()).joinpath(catalog.name())

    def _collect_solution_files(self, catalog: ICatalog, catalog_local_src: str, active_solution: ISolution, deploy_path: Path):
        coordinates = active_solution.coordinates()

        catalog_solution_local_src_path = Path(catalog_local_src).joinpath(
            self.album.configuration().get_solution_path_suffix_unversioned(coordinates)
        )


        res = []
        if deploy_path.is_file():
            solution_path = catalog_solution_local_src_path.joinpath(DefaultValues.solution_default_name.value)
            res.append(copy(deploy_path, solution_path))
        else:
            for subdir, dirs, files in os.walk(deploy_path):
                for file in files:
                    filepath = subdir + os.sep + file
                    rel_path = os.path.relpath(filepath, deploy_path)
                    target = catalog_solution_local_src_path.joinpath(rel_path)
                    res.append(copy(filepath, target))

        res.append(create_docker_file(active_solution, catalog_solution_local_src_path))
        res.append(self._create_yaml_file_in_local_src(active_solution, catalog_solution_local_src_path))
        res.append(create_changelog_file(active_solution, catalog, catalog_solution_local_src_path))

        return res

    def _get_tmp_dir(self):
        return self.album.configuration().tmp_path()

    @staticmethod
    def _get_tags_for_coordinates(repo, coordinates: ICoordinates):
        tags = get_tags(repo)
        tag_start = as_tag_unversioned(coordinates)
        return [tag for tag in tags if tag.startswith(tag_start)]

    @staticmethod
    def _add_to_downloaded_catalog(catalog: ICatalog, active_solution: ISolution, dry_run: bool,
                                   force_deploy: bool):
        """Updates the index in the downloaded repository!"""
        if not dry_run:
            catalog.add(active_solution, force_overwrite=force_deploy)
        else:
            module_logger().info("Would add the solution %s to index..." % active_solution.coordinates().name())

    @staticmethod
    def _remove_from_downloaded_catalog(catalog: ICatalog, coordinates: ICoordinates, dry_run: bool):
        """Updates the index in the downloaded repository!"""
        if not dry_run:
            catalog.remove(coordinates)
        else:
            module_logger().info("Would remove the solution %s from index..." % coordinates.name())

    @staticmethod
    def _clear_deploy_target_path(target_path: Path, force_deploy: bool):
        """Clears the target path (locally) where the solution is supposed to be deployed to."""
        if target_path.is_dir() and not folder_empty(target_path):
            if force_deploy:
                force_remove(target_path)
            else:
                raise RuntimeError("The deploy target folder is not empty! Enable --force-deploy to continue!")

    @staticmethod
    def _get_path_to_solution(deploy_path: Path):
        """Gets the path to the solution behind the deploy_path. If folder is provided file called solution.py must
        live in the deploy_path.
        """
        if deploy_path.is_dir():
            path_to_solution = deploy_path.joinpath(DefaultValues.solution_default_name.value)
        else:
            path_to_solution = deploy_path

        return path_to_solution

    @staticmethod
    def retrieve_head_name(coordinates: ICoordinates):
        """Retrieves the branch (head) name for the merge request of the solution file."""
        return "_".join([coordinates.group(), coordinates.name(), coordinates.version()])

    @staticmethod
    def _create_merge_request(coordinates: ICoordinates, repo: Repo, file_paths, dry_run=False, push_option=None,
                              email=None, username=None):
        """Creates a merge request to the catalog repository for the album object.

        Commits first the files given in the call, but will not include anything else than that.

        Args:
            file_paths:
                A list of files to include in the merge request. Can be relative to
                the catalog repository path or absolute.
            dry_run:
                Option to only show what would happen. No Merge request will be created.
            push_option:
                Push options for the repository.
            username:
                The git user to use. (Default: systems git configuration)
            email:
                The git email to use. (Default: systems git configuration)

        Raises:
            RuntimeError when no differences to the previous commit can be found.

        """
        if push_option is None:
            push_option = []

        # make a new branch and checkout
        new_head = create_new_head(repo, DeployManager.retrieve_head_name(coordinates))
        new_head.checkout()

        commit_msg = "Adding new/updated %s" % DeployManager.retrieve_head_name(coordinates)

        add_files_commit_and_push(
            new_head,
            file_paths,
            commit_msg,
            push=not dry_run,
            push_option_list=push_option,
            email=email,
            username=username,
            force=True
        )

    @staticmethod
    def _push_directly(coordinates: ICoordinates, repo: Repo, files_to_remove: list, files_to_add: list, dry_run=False, push_option=None,
                       email=None, username=None):
        if push_option is None:
            push_option = []

        # don't create a new branch. use reference branch from origin
        head = checkout_main(repo)

        commit_msg = "Adding new/updated %s" % DeployManager.retrieve_head_name(coordinates)
        remove_files(head, files_to_remove)

        add_files_commit_and_push(
            head,
            files_to_add,
            commit_msg,
            push=not dry_run,
            push_option_list=push_option,
            email=email,
            username=username,
            force=False
        )

    @staticmethod
    def _create_yaml_file_in_local_src(active_solution: ISolution, solution_home: Path):
        """Creates a yaml file in the given repo for the given solution.

        Returns:
            The Path to the created markdown file.

        """
        yaml_path = solution_home.joinpath(DefaultValues.solution_yml_default_name.value)

        module_logger().debug('Writing yaml file to: %s...' % yaml_path)
        write_dict_to_yml(yaml_path, get_deploy_dict(active_solution))

        return yaml_path

    def _remove_db_entry_and_files(self, repo, coordinates, dry_run, push_options, git_email, git_name):

        solution_root = str(self.album.configuration().get_solution_path_suffix_unversioned(coordinates))
        db_index = DefaultValues.catalog_index_file_name.value

        init_msg = 'Would remove files %s, would update %s..' if dry_run else 'Removing files %s, updating %s..'
        module_logger().info(init_msg % (solution_root, db_index))

        if not dry_run:
            self._try_push_remove_tag(coordinates, repo, [solution_root], [db_index], push_options, git_email, git_name)

    def _remove_db_entry_and_revert_files(self, repo, coordinates, previous_version_tag, dry_run, push_options,
                                          git_email, git_name):

        solution_root = str(self.album.configuration().get_solution_path_suffix_unversioned(coordinates))
        db_index = DefaultValues.catalog_index_file_name.value

        init_msg = 'Would revert %s to %s, would update %s..' if dry_run else 'Reverting %s to %s, updating %s..'
        module_logger().info(init_msg % (solution_root, previous_version_tag, db_index))

        if not dry_run:
            revert(repo, previous_version_tag, [solution_root])
            self._try_push_remove_tag(coordinates, repo, [], [db_index], push_options, git_email, git_name)

    def _remove_db_entry_and_tag(self, repo, coordinates, dry_run, push_options, git_email,
                                 git_name):
        db_index = DefaultValues.catalog_index_file_name.value

        init_msg = 'Would update %s..' if dry_run else 'Updating %s..'
        module_logger().info(init_msg % (db_index))

        if not dry_run:
            self._try_push_remove_tag(coordinates, repo, [], [db_index], push_options, git_email, git_name)

    def _try_push_remove_tag(self, coordinates, repo, files_to_remove, files_to_add, push_options, git_email, git_name):
        try:
            if push_options is None:
                push_options = []

            # don't create a new branch. use reference branch from origin
            head = checkout_main(repo)

            commit_msg = "Removing %s" % DeployManager.retrieve_head_name(coordinates)

            remove_tag(repo, as_tag(coordinates))

            remove_files(head, files_to_remove)

            add_files_commit_and_push(
                head,
                files_to_add,
                commit_msg,
                push=True,
                push_option_list=push_options,
                email=git_email,
                username=git_name,
                force=False
            )
        except Exception as e:
            module_logger().error("Pushing to catalog failed! Rolling back deployment...")
            try:
                clean_repository(repo)
            finally:
                raise e
