from functools import wraps

from parallels.core import messages
from parallels.core import safe_format
from parallels.core.logging import get_logger
from parallels.core.reports.model.issue import Issue
from parallels.core.runners.base import BaseRunner
from parallels.core.utils.common.string_utils import safe_utf8_encode
from parallels.core.utils.file_utils import get_nested_file_names, get_nested_dir_trees, path_to_binary_string

logger = get_logger(__name__)


class ApplicationModel(object):
    # max size of a file, which is supposed could be retrieved safely
    MAX_FILE_SIZE = 1048576

    DATABASE_TYPE_MYSQL = 'mysql'
    DATABASE_TYPE_MSSQL = 'mssql'

    @property
    def id(self):
        """Application id

        :rtype: str
        """
        raise NotImplementedError()

    @property
    def name(self):
        """Application name

        :rtype: str
        """
        raise NotImplementedError()

    def get_instance(self, server, base_path):
        """Retrieve instance of application with given options

        :type server: parallels.core.connections.server.Server
        :type base_path: str
        :rtype: parallels.core.application.base.ApplicationInstance
        """
        raise NotImplementedError()

    def get_supported_database_types(self):
        """Retrieve list of database types, supported by this application

        :rtype: list[str]
        """
        raise NotImplementedError()

    def is_database_type_supported(self, database_type):
        return database_type in self.get_supported_database_types()

    def search(self, server, base_path, tree=None):
        """Look up application instances in given path on the server

        :type server: parallels.core.connections.server.Server
        :type base_path: str | unicode
        :type tree: list[str | (str, list)] | None
        :rtype: list[parallels.core.application.base.ApplicationInstance]
        """
        with logger.info_level_context(messages.APPLICATION_SEARCH, application_name=self.name, base_path=base_path):
            base_path = path_to_binary_string(base_path)

            if tree is None:
                with server.runner() as runner:
                    assert isinstance(runner, BaseRunner)
                    tree = runner.get_dir_tree(base_path)

            application_instances = []
            self._search_tree(server, base_path, tree, application_instances)
            return application_instances

    def _search_tree(self, server, base_path, tree, application_instances):
        """
        :type server: parallels.core.connections.server.Server
        :type base_path: str
        :type tree: list[str | (str, list)]
        """
        def catch_application_instance(func):
            @wraps(func)
            def wrapper(*args, **kwargs):
                value = func(*args, **kwargs)
                if value is not None:
                    assert isinstance(value, ApplicationInstance)
                    logger.info(safe_format(
                        messages.APPLICATION_SEARCH_FOUND,
                        application_instance_description=value.description,
                    ))
                    application_instances.append(value)
                    return True
                return False
            return wrapper

        @catch_application_instance
        def is_found(method, *args):
            return method(*args)

        # check base directory to find application markers
        base_directory_name = server.get_base_name(base_path)
        if is_found(self._search_directory, server, base_path, base_directory_name, tree):
            # application instance found, assume that several instances of the same applications could not
            # be placed at the same location, so consider to stop further search in current tree
            return

        # iterate over files in base directory to find application markers
        for file_name in get_nested_file_names(tree):
            file_path = server.join_file_path(base_path, file_name)
            if is_found(self._search_file, server, file_path, file_name):
                # application instance found, assume that several instances of the same applications could not
                # be placed at the same location, so consider to stop further search in current tree
                return

        # iterate over nested directories recursively
        for directory_name, directory_tree in get_nested_dir_trees(tree):
            self._search_tree(
                server, server.join_file_path(base_path, directory_name), directory_tree, application_instances
            )

    def _search_directory(self, server, directory_path, directory_name, directory_tree):
        """Search application markers according given directory

        :type server: parallels.core.connections.server.Server
        :type directory_path: str
        :type directory_name: str
        :type directory_tree: list[str | (str, list)]
        :rtype parallels.core.application.base.ApplicationInstance
        """
        raise NotImplementedError()

    def _search_file(self, server, file_path, file_name):
        """Search application markers according given file

        :type server: parallels.core.connections.server.Server
        :type file_path: str
        :type file_name: str
        :rtype parallels.core.application.base.ApplicationInstance
        """
        raise NotImplementedError()

    @classmethod
    def _safe_get_file_contents(cls, server, file_path):
        """Safely retrieve contents of file by given path;

        :rtype: str | None
        """
        try:
            with server.runner() as runner:
                if runner.get_file_size(file_path) > cls.MAX_FILE_SIZE:
                    # skip large files to avoid high memory consumption
                    logger.fdebug(messages.APPLICATION_SEARCH_FILE_SKIP_SIZE, file_path=file_path)
                    return None
                try:
                    return runner.get_file_contents(file_path)
                except UnicodeDecodeError:
                    logger.fdebug(messages.APPLICATION_SEARCH_FILE_SKIP_BINARY, file_path=file_path)
        except Exception as e:
            logger.exception()
            logger.fdebug(messages.APPLICATION_SEARCH_FILE_SKIP_ERROR, file_path=file_path, error=e)
        return None


class ApplicationInstance(object):
    def __init__(self, server, base_path):
        """
        :type server: parallels.core.connections.server.Server
        :type base_path: str
        """
        self._server = server
        self._base_path = base_path

    @property
    def description(self):
        """Description of this application

        :rtype: str
        """
        raise NotImplementedError()

    @property
    def server(self):
        """Server where this application located

        :rtype: parallels.core.connections.server.Server
        """
        return self._server

    @property
    def base_path(self):
        """Base path to files of this application

        :rtype: str
        """
        return self._base_path

    def get_paths(self):
        """Retrieve filesystem paths which contains files of this application instance

        :rtype: list[str]
        """
        return []

    def get_databases(self):
        """Retrieve databases of this application instance

        :rtype: list[parallels.core.application.utils.ApplicationInstanceDatabase]
        """
        return []

    def fix_database_hostname(self, database_hostname_mapping):
        """Replace hostname in database connection string of application configuration files

        :type database_hostname_mapping: dict[str, str]
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        raise NotImplementedError()

    def fix_database_name(self, database_name_mapping):
        """Fix database name, when it changed during conflict resolution

        :type database_name_mapping: dict[str | unicode, str | unicode]
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        raise NotImplementedError()

    def fix_database_user_name(self, database_user_name_mapping):
        """Fix database user name, when it changed during conflict resolution

        :type database_user_name_mapping: dict[str | unicode, str | unicode]
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        raise NotImplementedError()

    def fix_files(self, domain, url, domain_changed=False, url_changed=False):
        """Fix files, set proper domain and URL

        :type domain: str | unicode
        :type url: str | unicode
        :type domain_changed: bool
        :type url_changed: bool
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        raise NotImplementedError()

    def fix_databases(self, database, domain, url, domain_changed=False, url_changed=False):
        """Adjust application databases, set proper application`s domain and URL

        :type database: parallels.core.application.utils.ApplicationInstanceDatabase
        :type domain: str | unicode
        :type url: str | unicode
        :type domain_changed: bool
        :type url_changed: bool
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        return []

    def get_path_excludes(self, domain_changed=False, url_changed=False):
        """Returns paths which can be excluded from copying
        Some applications can be broken if base URL was changed and file cache contains old value
        As a simple solution copying of a cache can be skipped

        :type domain_changed: bool
        :type url_changed: bool
        :rtype: list[str | unicode]
        """
        return []

    def _replace_file_contents(self, file_path, replace_results, reason):
        """Replace content of given application file with given path

        :type file_path: str
        :type replace_results: parallels.core.utils.line_processor.ReplaceResults
        :type reason: str
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        if replace_results.has_changes:
            with self._server.runner() as runner:
                runner.upload_file_content(file_path, safe_utf8_encode(replace_results.new_contents))
        issues = []
        for changed_line in replace_results.changed_lines:
            issues.append(Issue(
                'changed_application_file_line', Issue.SEVERITY_INFO,
                safe_format(
                    messages.APPLICATION_REPLACE_FILE_CONTENT,
                    application_instance_description=self.description,
                    reason=reason,
                    file=file_path,
                    line_number=changed_line.line_number,
                    before_fix=changed_line.old_contents.strip(),
                    after_fix=changed_line.new_contents.strip()
                ),
            ))
        return issues


class SingleConfigApplicationInstance(ApplicationInstance):
    @property
    def description(self):
        raise NotImplementedError()

    @property
    def config_file_path(self):
        raise NotImplementedError()

    def get_path_excludes(self, domain_changed=False, url_changed=False):
        """Returns paths which can be excluded from copying
        Some applications can be broken if base URL was changed and file cache contains old value
        As a simple solution copying of a cache can be skipped

        :type domain_changed: bool
        :type url_changed: bool
        :rtype: list[str | unicode]
        """
        return []

    def fix_database_hostname(self, database_hostname_mapping):
        if self.config_file_path is None:
            return []

        with self._server.runner() as runner:
            config_file_contents = runner.get_file_contents(self.config_file_path)
        return self._replace_file_contents(
            self.config_file_path,
            self._get_database_hostname_replace_result(config_file_contents, database_hostname_mapping),
            messages.APPLICATION_REPLACE_FILE_CONTENT_REASON_FIX_DATABASE_HOSTNAME
        )

    def fix_database_name(self, database_name_mapping):
        """Fix database name, when it changed during conflict resolution

        :type database_name_mapping: dict[str | unicode, str | unicode]
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        if self.config_file_path is None:
            return []

        with self._server.runner() as runner:
            config_file_contents = runner.get_file_contents(self.config_file_path)
        return self._replace_file_contents(
            self.config_file_path,
            self._get_database_name_replace_result(config_file_contents, database_name_mapping),
            messages.APPLICATION_REPLACE_FILE_CONTENT_REASON_FIX_DATABASE_NAME
        )

    def fix_database_user_name(self, database_user_name_mapping):
        """Fix database user name, when it changed during conflict resolution

        :type database_user_name_mapping: dict[str | unicode, str | unicode]
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        if self.config_file_path is None:
            return []

        with self._server.runner() as runner:
            config_file_contents = runner.get_file_contents(self.config_file_path)
        return self._replace_file_contents(
            self.config_file_path,
            self._get_database_user_name_replace_result(config_file_contents, database_user_name_mapping),
            messages.APPLICATION_REPLACE_FILE_CONTENT_REASON_FIX_DATABASE_USER_NAME
        )

    def fix_files(self, domain, url, domain_changed=False, url_changed=False):
        """Fix files, set proper domain and URL

        :type domain: str | unicode
        :type url: str | unicode
        :type domain_changed: bool
        :type url_changed: bool
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        return []

    def _get_database_hostname_replace_result(self, config_file_contents, database_hostname_mapping):
        """Replace database hostnames in configuration file of application, provided by string

        :type config_file_contents: str | unicode
        :type database_hostname_mapping: dict[str | unicode, str | unicode]
        :rtype: parallels.core.utils.line_processor.ReplaceResults
        """
        raise NotImplementedError()

    def _get_database_name_replace_result(self, config_file_contents, database_name_mapping):
        """Replace database names in configuration file of application, provided by string

        :type config_file_contents: str | unicode
        :type database_name_mapping: dict[str | unicode, str | unicode]
        :rtype: parallels.core.utils.line_processor.ReplaceResults
        """
        raise NotImplementedError()

    def _get_database_user_name_replace_result(self, config_file_contents, database_user_name_mapping):
        """Replace database user names in configuration file of application, provided by string

        :type config_file_contents: str | unicode
        :type database_user_name_mapping: dict[str | unicode, str | unicode]
        :rtype: parallels.core.utils.line_processor.ReplaceResults
        """
        raise NotImplementedError()
