import logging
from collections.abc import Callable
from enum import Enum, auto
from pathlib import Path

from mstrio import config
from mstrio.connection import Connection
from mstrio.helpers import VersionException
from mstrio.object_management.migration.package import (
    Package,
    PackageConfig,
    PackageImport,
)
from mstrio.utils.helper import Dictable, exception_handler
from mstrio.utils.progress_bar_mixin import ProgressBarMixin
from mstrio.utils.wip import WipLevels, module_wip

module_wip(globals(), level=WipLevels.PREVIEW)

logger = logging.getLogger(__name__)


class MigrationStatus(Enum):
    NOT_STARTED = auto()
    STARTED = auto()
    CREATING_PACKAGE = auto()
    CREATING_PACKAGE_FAILED = auto()
    CREATING_PACKAGE_COMPLETED = auto()
    MIGRATION_IN_PROGRESS = auto()
    MIGRATION_FAILED = auto()
    MIGRATION_COMPLETED = auto()
    UNDO_STARTED = auto()
    UNDO_FAILED = auto()
    UNDO_COMPLETED = auto()


class Migration(Dictable, ProgressBarMixin):
    """A class encapsulating migration process from env A to env B.

    Raises VersionException if either of environments are running IServer
    version lower than 11.3.0200

    Raises AttributeError if parent directory of specified `save_path` is
    non-existent or points to another kind of file.

    Attributes:
        configuration(PackageConfig): a configuration of the migration
        save_path(str): a full path used when saving import package to a
        file. It is also used for saving undo package with the addition of
        `_undo` suffix. By default, uses ~cwd/migration.mmp and
        ~cwd/migration_undo.mmp.
        source_connection(Connection): A MicroStrategy connection object
          to the source env,
        target_connection(Connection): A MicroStrategy connection object
          to the target env,
        name(str): Name of the migration. Used for identification purposes for
            the convenience of the user,
        package(Package): a package that is migrated,
        package_import(PackageImport): a package import process object,
        package_binary(Binary): actual data that is migrated
        status(MigrationStatus): current status of migration,
        create_undo(bool): should a undo package be made
        undo_package(Binary): undo package binary for this import process.
          if `create_undo` is False, it is equal to None.

    Additional args:
        custom_package_path(str): a full path to an already existing .mpp file.
            This argument overrides `save_path`. Creating a `Migration` object
            with only `source_connection`, `target_connection` and
            `custom_package_path` allows for an easier export to the target by
            running `migrate_package()` on that `Migration` instance (instead of
            performing full migration with an import from the source first.
            See demos and examples for details.)."""

    def __init__(
        self,
        save_path: str | None = None,
        source_connection: Connection | None = None,
        configuration: PackageConfig | None = None,
        target_connection: Connection | None = None,
        custom_package_path: str | None = None,
        name: str | None = None,
        create_undo: bool = True,
    ):
        save_path = save_path or (Path.cwd() / 'migration.mmp')
        if not self._validate_envs_version(source_connection, target_connection):
            exception_handler(
                msg=(
                    "Environments must run IServer version 11.3.0200 or newer. "
                    "Please update your environments to use this feature."
                ),
                exception_type=VersionException,
            )
        self.name = name
        self.source_connection = source_connection
        self.target_connection = target_connection
        self.configuration = configuration
        self.create_undo = create_undo
        self._status = MigrationStatus.NOT_STARTED

        self._check_file_path(save_path, "save_path")
        self.save_path = save_path

        self._package = None
        self._package_import = None
        self._undo_binary = None

        if custom_package_path and self._check_file_path(
            custom_package_path, "custom_package_path"
        ):
            self.save_path = custom_package_path
            filename, file_extension = self._decompose_file_path(custom_package_path)
            with open(f"{filename}{file_extension}", "rb") as f:
                self._package_binary = f.read()
        else:
            self._package_binary = None

    @staticmethod
    def _validate_envs_version(
        source_connection: Connection, target_connection: Connection
    ) -> bool:
        return (
            source_connection is None
            or source_connection._iserver_version >= '11.3.0200'
        ) and (
            target_connection is None
            or target_connection._iserver_version >= '11.3.0200'
        )

    @staticmethod
    def _decompose_file_path(file_path: str) -> tuple:
        file_path = Path(file_path)
        filename, file_extension = file_path.parent / file_path.stem, file_path.suffix
        if file_extension == '':
            return filename, '.mmp'
        return filename, file_extension

    @staticmethod
    def _check_file_path(file_path: str, var_name: str):
        file_path = Path(file_path)

        if file_path.parent.is_dir() and not file_path.is_dir():
            return True
        else:
            exception_handler(
                msg=f"Invalid save path. Parent directory specified in `{var_name}` is"
                f" incorrect.",
                exception_type=AttributeError,
            )

    def perform_full_migration(self) -> bool:
        """Perform 'create_package()' and 'migrate_package()' using
        configuration provided when creating `Migration` object.
        """
        if (
            not isinstance(self.source_connection, Connection)
            or self.source_connection is None
        ):
            exception_handler(
                msg=(
                    "Migration object missing `source_connection`. "
                    "`perform_full_migration()` unavailable."
                ),
                exception_type=AttributeError,
            )
        if (
            not isinstance(self.target_connection, Connection)
            or self.target_connection is None
        ):
            exception_handler(
                msg=(
                    "Migration object missing `target_connection`. "
                    "`perform_full_migration()` unavailable."
                ),
                exception_type=AttributeError,
            )

        self._display_progress_bar(
            desc='Migration status: ',
            unit='Migration',
            total=4,
            bar_format='{desc} |{bar:50}| {percentage:3.0f}%',
        )

        if not self.create_package():
            return False

        if not self.migrate_package():
            return False

        self._close_progress_bar()
        return True

    def create_package(self) -> bool:
        """Performs import of the object described in the migration
        configuration from the source environment and save it in a file
        at location specified in `save_path` parameter.

        Raises AttributeError if `source_connection` is not specified.

        Raises FileExistsError if a package or undo package with the same name
        already exist at`save_path` location."""
        if (
            not isinstance(self.source_connection, Connection)
            or self.source_connection is None
        ):
            exception_handler(
                msg=(
                    "Migration object does not have `source_connection`. Import "
                    "unavailable."
                ),
                exception_type=AttributeError,
            )

        filename, file_extension = self._decompose_file_path(self.save_path)
        """File existence checked here instead of using "xb" mode in context
        manager to avoid creation of package if it should not be saved"""
        if (
            Path(f"{filename}{file_extension}").exists()
            or Path(f"{filename}_undo{file_extension}").exists()
        ):
            exception_handler(
                msg=(
                    "Migration file / undo package with this name already exists."
                    "Please use other location / filename in `save_path` parameter."
                ),
                exception_type=FileExistsError,
            )

        self.__private_status = MigrationStatus.CREATING_PACKAGE
        self._create_package_holder(self.source_connection)
        if not self._update_package_holder():
            self.__private_status = MigrationStatus.CREATING_PACKAGE_FAILED
            return False

        self._download_package_binary()
        self._delete_package_holder()
        self._save_package_binary_locally(filename, file_extension, self.package_binary)

        self.__private_status = MigrationStatus.CREATING_PACKAGE_COMPLETED
        return True

    def migrate_package(
        self, custom_package_path: str | None = None, is_undo=False
    ) -> bool:
        """Performs migration of already created package to the target
        environment. Import package will be loaded from `custom_package_path`.
        If `custom_package_path` not provided, the object previously acquired
        with the `create_package()` will be used.
        If `create_undo` parameter is set to True, package needed for undo
        process will be downloaded.

        Raises AttributeError if `target_connection` is not specified.
        """

        if (
            not isinstance(self.target_connection, Connection)
            or self.target_connection is None
        ):
            exception_handler(
                msg=(
                    "Migration object does not have `target_connection`. "
                    "Export unavailable."
                ),
                exception_type=AttributeError,
            )

        if custom_package_path and self._check_file_path(
            custom_package_path, "custom_package_path"
        ):
            self.save_path = custom_package_path
            filename, file_extension = self._decompose_file_path(custom_package_path)
            with open(f"{filename}{file_extension}", "rb") as f:
                return self._migrate_package(f.read(), is_undo=is_undo)
        return self._migrate_package(
            self._package_binary,
            custom_package_path=custom_package_path,
            is_undo=is_undo,
        )

    def _migrate_package(
        self,
        binary: bytes,
        is_undo: bool = False,
        custom_package_path: str | None = None,
    ) -> bool:
        if binary is None:
            exception_handler(
                msg=(
                    "Import package is None. Run `create_package()` first, "
                    "or specify `custom_package_path`."
                ),
                exception_type=AttributeError,
            )

        self.__private_status = MigrationStatus.MIGRATION_IN_PROGRESS
        self._create_package_holder(self.target_connection)
        self._upload_package_binary(binary)
        if not self._create_import(self.target_connection):
            self.__private_status = MigrationStatus.MIGRATION_FAILED
            return False

        if not is_undo and self.create_undo:
            self._download_undo_binary()
            file_path = custom_package_path if custom_package_path else self.save_path
            filename, file_extension = self._decompose_file_path(file_path)
            self._save_package_binary_locally(
                filename=f"{filename}_undo",
                file_extension=file_extension,
                _bytes=self.undo_binary,
            )

        self._delete_package_holder()
        self._delete_import()

        self.__private_status = MigrationStatus.MIGRATION_COMPLETED
        return True

    def undo_migration(self):
        """Revert the migration using the package downloaded during
        `migrate_package()` or `perform_full_migration()`.

        Raises AttributeError if `undo_binary` is None.
        """
        self.__private_status = MigrationStatus.UNDO_STARTED
        if self.undo_binary:
            self._migrate_package(self._undo_binary, True)
        else:
            self.__private_status = MigrationStatus.UNDO_FAILED
            msg = (
                '`undo_binary` is None. Perform migration with `create_undo`'
                ' parameter set to True'
            )
            exception_handler(msg, exception_type=AttributeError)
        self.__private_status = MigrationStatus.UNDO_COMPLETED

    def _create_package_holder(self, conn: Connection):
        self._package = Package.create(
            conn, progress_bar=self._progress_bar is not None
        )

    def _update_package_holder(self):
        return self._package.update_config(self.configuration)

    def _download_package_binary(self):
        self._package_binary = self._package.download_package_binary(
            progress_bar=self._progress_bar is not None
        )

    def _upload_package_binary(self, binary: bytes):
        self._package.upload_package_binary(
            binary, progress_bar=self._progress_bar is not None
        )

    def _delete_package_holder(self):
        self._package.delete(force=True)
        self._package = None

    def _create_import(self, conn: Connection) -> bool:
        self._package_import = PackageImport.create(
            conn,
            self.package.id,
            self.create_undo,
            progress_bar=self._progress_bar is not None,
        )
        if self._package_import is False:
            return False
        return True

    def _delete_import(self):
        self._package_import.delete(force=True)
        self._package_import = None

    def _download_undo_binary(self):
        self._undo_binary = self._package_import.download_undo_binary(
            progress_bar=self._progress_bar is not None
        )

    def _save_package_binary_locally(
        self, filename: str, file_extension: str, _bytes: bytes
    ):
        with open(f"{filename}{file_extension}", "wb") as f:
            f.write(_bytes)
        logger.info(
            f'Package / package undo binary created at:{filename}{file_extension}'
        )

    @property
    def package(self):
        return self._package

    @property
    def package_binary(self):
        return self._package_binary

    @property
    def package_import(self):
        return self._package_import

    @property
    def undo_binary(self):
        return self._undo_binary

    @property
    def status(self):
        return self._status

    @property
    def __private_status(self):
        return self._status

    @__private_status.setter
    def __private_status(self, var: MigrationStatus):
        self._update_progress_bar_if_needed(
            new_description=f'[{self.name}] Status: {var.name}'
        )
        self._status = var


def bulk_full_migration(migrations: Migration | list[Migration], verbose: bool = False):
    """Run `perform_full_migration()` for each of the migrations provided.
    Args:
        migrations: migrations to be executed
        verbose: if True, information about each step will be printed
    """
    __run_func_for_all_migrations(Migration.perform_full_migration, migrations, verbose)


def bulk_migrate_package(
    migrations: Migration | list[Migration], verbose: bool = False
):
    """Run `migrate_package()` for each of the migrations provided.
    Args:
        migrations: migrations to be executed
        verbose: if True, information about each step will be printed
    """
    __run_func_for_all_migrations(Migration.migrate_package, migrations, verbose)


def __run_func_for_all_migrations(
    func: Callable,
    migrations: Migration | list[Migration],
    verbose: bool = False,
    *args,
    **kwargs,
):
    if type(migrations) is Migration:
        migrations = [migrations]
    original_verbose = config.verbose
    config.verbose = verbose
    for migration in migrations:
        func(migration, *args, **kwargs)
    config.verbose = original_verbose
