from parallels.core import messages
from parallels.core.application.base import ApplicationModel
from parallels.core.connections.source_server import SourceServer
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.application_utils import get_application_models
from parallels.core.utils.common import safe_format
from parallels.core.utils.common.ip import resolve_all_safe
from parallels.core.utils.common.string_utils import safe_utf8_encode
from parallels.core.utils.database_utils import split_mssql_host_parts, join_mssql_hostname_and_instance
from parallels.core.utils.entity import Entity
from parallels.core.utils.line_processor import LineProcessor, ReplaceResults, ChangedLineInfo
from parallels.core.utils.stop_mark import check_stop_mark

logger = get_logger(__name__)


class Adjuster(object):
    """Class to adjust files of applications after migration: replace IP addresses, paths, etc"""

    # White list of extensions: if file has extension from that list, migrator will try to adjust absolute
    # paths in it, according to virtual host structure and location changes.
    ADJUST_PATHS_EXTENSIONS = {
        'htm', 'html', 'shtml', 'php', 'php3', 'php4', 'inc', 'phtml',
        'tpl', 'pl', 'pm', 'py', 'rb', 'asp', 'aspx', 'cs', 'config', 'ini',
        'xml', 'yaml', 'yml', 'json'
    }

    MAX_ADJUST_REPORT_ISSUES = 100

    def adjust_paths(self, target_server, path_mapping, vhost_path, stop_mark=None):
        """Adjust absolute paths of applications

        For example, if on  source server www root of domain was located at
        /home/vhosts/test.tld/www-data, and on target it is located at
        /var/www/vhosts/test.tld/httpdocs, then
        /home/vhosts/test.tld/www-data/index.html will be replaced with
        /var/www/vhosts/test.tld/httpdocs/index.html in all code and configuration files,
        so absolute references between files are not broken.

        :type target_server: parallels.core.connections.target_servers.TargetServer
        :type path_mapping: dict[str | unicode, str | unicode]
        :type vhost_path: str | unicode
        :type stop_mark: parallels.core.utils.stop_mark.StopMark | None
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        issues = []
        normalized_path_mapping = self._normalize_mapping(path_mapping, target_server.is_windows())

        if len(normalized_path_mapping) == 0:
            # Mapping is empty, paths are not changed
            return issues

        logger.fdebug(
            messages.DEBUG_APPLICATION_PATH_MAPPING,
            mapping="\n".join("%s => %s" % (path_from, path_to) for path_from, path_to in normalized_path_mapping)
        )

        with target_server.runner() as runner:
            try:
                filename_iterator = runner.iter_files_list_nested(vhost_path)
            except NotImplementedError:
                return issues

            check_stop_mark(stop_mark)

            for filename in filename_iterator:
                check_stop_mark(stop_mark)

                try:
                    self._adjust_paths(runner, target_server.is_windows(), filename, normalized_path_mapping, issues)
                except Exception as e:
                    logger.exception()
                    error_message = safe_format(
                        messages.FAILED_TO_PROCESS_FILE_WHEN_ADJUSTING_PATHS,
                        filename=filename, reason=e
                    )
                    logger.error(error_message)
                    issues.append(Issue(
                        'failed_to_process_file_when_adjusting_paths', Issue.SEVERITY_WARNING, error_message
                    ))

        return issues

    def adjust_database(
        self, server, vhost_path, database_servers, source_subscription_ips,
        database_name_mapping, database_user_name_mapping
    ):
        """Adjust database connection settings of application instances found in given path

        :type server: parallels.core.connections.server.Server
        :type vhost_path: str | unicode
        :type database_servers: parallels.core.application.adjuster.DatabaseServers
        :type source_subscription_ips: list[str | unicode]
        :type database_name_mapping: dict[str | unicode, str | unicode]
        :type database_user_name_mapping: dict[str | unicode, str | unicode]
        :rtype: list[parallels.core.reports.model.issue.Issue]
        """
        issues = []

        mysql_db_host_mapping = self._get_mysql_mapping(database_servers, source_subscription_ips)
        mssql_db_host_mapping = self._get_mssql_mapping(database_servers, source_subscription_ips)

        with server.runner() as runner:
            assert isinstance(runner, BaseRunner)
            try:
                tree = runner.get_dir_tree(vhost_path)
            except NotImplementedError:
                return issues

        for application_model in get_application_models():
            for application_instance in application_model.search(server, vhost_path, tree):
                try:
                    if application_model.is_database_type_supported(ApplicationModel.DATABASE_TYPE_MYSQL):
                        issues.extend(application_instance.fix_database_hostname(mysql_db_host_mapping))
                    if application_model.is_database_type_supported(ApplicationModel.DATABASE_TYPE_MSSQL):
                        issues.extend(application_instance.fix_database_hostname(mssql_db_host_mapping))
                    issues.extend(application_instance.fix_database_name(database_name_mapping))
                    issues.extend(application_instance.fix_database_user_name(database_user_name_mapping))
                except Exception as e:
                    logger.exception()
                    error_message = safe_format(
                        messages.ADJUSTER_APPLICATION_INSTANCE_ADJUST_DATABASE_FAILED,
                        application_instance_description=application_instance.description,
                        reason=e
                    )
                    logger.error(error_message)
                    issues.append(Issue(
                        'failed_to_process_file_when_adjusting_db', Issue.SEVERITY_WARNING, error_message
                    ))

        return issues

    def _adjust_paths(self, runner, is_windows, filename, normalized_path_mapping, issues):
        """Adjust paths in specified file

        :type runner: parallels.core.runners.base.BaseRunner
        :type is_windows: bool
        :type filename: str | unicode
        :type normalized_path_mapping: list[tuple[str | unicode]]
        :type issues: list[parallels.core.reports.model.issue.Issue]
        """
        if not any(filename.endswith('.%s' % extension) for extension in self.ADJUST_PATHS_EXTENSIONS):
            return

        if runner.get_file_size(filename) > 1024 * 1024:
            # Skip large files that are > 1MB, to avoid high memory usage.
            # Code and configuration files should be always small.
            return

        try:
            contents = runner.get_file_contents(filename)
        except UnicodeDecodeError:
            logger.exception()
            logger.fdebug(messages.DEBUG_SKIP_BINARY_FILE, filename=filename)
            return

        replace_results = self._adjust_path_file_contents(
            contents, normalized_path_mapping, is_windows
        )
        if replace_results.has_changes:
            runner.upload_file_content(filename, safe_utf8_encode(replace_results.new_contents))
            for changed_line in replace_results.changed_lines:
                # To avoid out of memory error when there are many changed (for example, if customer has
                # some very old large log file with many many paths that were adjusted, or has many backups
                # of configuration files) limit number of issues in the report.
                if len(issues) == self.MAX_ADJUST_REPORT_ISSUES:
                    issues.append(Issue(
                        'changed_path_limit', Issue.SEVERITY_INFO,
                        safe_format(
                            messages.CHANGED_MANY_PATHS,
                            limit=self.MAX_ADJUST_REPORT_ISSUES
                        ),
                    ))
                    break
                elif len(issues) > self.MAX_ADJUST_REPORT_ISSUES:
                    break

                issues.append(Issue(
                    'changed_application_file_line', Issue.SEVERITY_INFO,
                    safe_format(
                        messages.CHANGED_PATHS,
                        filename=filename,
                        line_number=changed_line.line_number,
                        before_fix=changed_line.old_contents.strip(),
                        after_fix=changed_line.new_contents.strip()
                    ),
                ))

    @staticmethod
    def _normalize_mapping(path_mapping, is_windows):
        if is_windows:
            # Normalize paths so we always have Windows-style slashes in paths
            path_mapping = {
                source.replace('/', '\\').lower(): target.replace('/', '\\')
                for source, target in path_mapping.items()
            }

        path_mapping = {
            source.rstrip('/').rstrip('\\'): target.rstrip('/').rstrip('\\')
            for source, target in path_mapping.items()
        }
        path_mapping = {
            source: target
            for source, target in path_mapping.items()
            if (source.lower() if is_windows else source) != (target.lower() if is_windows else target)
        }
        # sort for overlapping replaces to work fine: the longest path has more priority
        sorted_path_mapping = sorted(
            path_mapping.items(),
            # sort by source path - it is the first element
            key=lambda i: i[0],
            reverse=True
        )

        # Handle escaped Windows paths, for example "C:\\inetpub" instead of "C:\inetpub"
        def iter_double_slashes(path_mapping_list):
            for p_from, p_to in path_mapping_list:
                yield (p_from, p_to)
                if is_windows:
                    yield (p_from.replace('\\', '\\\\'), p_to.replace('\\', '\\\\'))

        return list(iter_double_slashes(sorted_path_mapping))

    @classmethod
    def _adjust_path_file_contents(cls, contents, normalized_path_mapping, is_windows):
        """Replace paths in specified string

        :type normalized_path_mapping: list[tuple[str | unicode]]
        :type contents: str | unicode
        :type is_windows: bool
        :rtype: parallels.core.utils.line_processor.ReplaceResults
        """

        processor = LineProcessor(contents)
        changed_lines = []

        for line in processor.iter_lines():
            old_line_contents = line.contents
            new_line_contents = cls._replace_paths_in_line(normalized_path_mapping, line.contents, is_windows)
            if old_line_contents != new_line_contents:
                line.contents = new_line_contents
                changed_lines.append(ChangedLineInfo(old_line_contents, line.contents, line.number))

        return ReplaceResults(processor.serialize(), changed_lines)

    @classmethod
    def _replace_paths_in_line(cls, normalized_path_mapping, line_contents, is_windows):
        """Replace paths in specified line

        :type normalized_path_mapping: list[tuple[str | unicode]]
        :type line_contents: str | unicode
        :type is_windows: bool
        """
        for path_from, path_to in normalized_path_mapping:
            if isinstance(line_contents, str):
                path_from = safe_utf8_encode(path_from)
                path_to = safe_utf8_encode(path_to)

            normalize_contents = cls._normalize_windows_line(is_windows, line_contents)
            start_index = normalize_contents.find(path_from)
            if start_index != -1:
                end_index = start_index + len(path_from)
                return (
                    # Leave beginning as is
                    line_contents[:start_index] +
                    # Replace middle with new path
                    path_to +
                    # Continue replacement with the end of the string -
                    # if there are many paths on the same line
                    cls._replace_paths_in_line(normalized_path_mapping, line_contents[end_index:], is_windows)
                )

        return line_contents

    @staticmethod
    def _normalize_windows_line(is_windows, line):
        if is_windows:
            return line.lower().replace('/', '\\')
        else:
            return line

    @staticmethod
    def _get_mysql_mapping(database_servers, source_subscription_ips):
        """Get mapping of hostnames for MySQL

        For example the following mapping:
        {'127.0.0.1': '192.168.1.1', 'localhost': '192.168.1.1', '10.50.1.5': '192.168.1.1'}
        means that all '127.0.0.1', 'localhost' and '10.50.1.5' should be replaced with new database
        server hostname '192.168.1.1'

        :type database_servers: parallels.core.application_adjuster.adjuster.DatabaseServers
        :rtype: dict[str | unicode, str | unicode]
        """
        mapping = {}

        for source_server in database_servers.source_mysql_servers:
            local_server_names = {'localhost', '127.0.0.1'} | set(source_subscription_ips)

            if isinstance(source_server.panel_server, SourceServer):
                # when migration from source with known IP-address append it into mapping
                local_server_names |= {source_server.panel_server.ip()}

            if source_server.host() in local_server_names:
                source_names = local_server_names
            else:
                source_names = [ip for ip in resolve_all_safe(source_server.host())] + [source_server.host()]

            target_name = database_servers.target_mysql_server.host()

            for source_name in source_names:
                mapping[source_name] = target_name

        return mapping

    @staticmethod
    def _get_mssql_mapping(database_servers, source_subscription_ips):
        """Get mapping of hostnames for MSSQL

        For example the following mapping:
        {
            '127.0.0.1\MSSQL2005': '192.168.1.1\SQLEXPRESS',
            'localhost\MSSQL2005': '192.168.1.1\SQLEXPRESS',
            '10.50.1.5\MSSQL2005': '192.168.1.1\SQLEXPRESS'
        }
        means that 'MSSQL2005' instance on '127.0.0.1', 'localhost' and '10.50.1.5' should be
        replaced with new database server hostname '192.168.1.1\SQLEXPRESS'

        :type database_servers: parallels.core.application_adjuster.adjuster.DatabaseServers
        :rtype: dict[str | unicode, str | unicode]
        """
        mapping = {}

        for source_server in database_servers.source_mssql_servers:
            hostname, instance, port = split_mssql_host_parts(source_server)
            local_server_names = {
                'localhost', '.', '127.0.0.1', source_server.panel_server.ip()
            } | set(source_subscription_ips)
            if hostname in local_server_names:
                source_names = local_server_names
            else:
                source_names = [ip for ip in resolve_all_safe(hostname)] + [hostname]

            target_name = database_servers.target_mssql_server.host()

            for source_name in source_names:
                mapping[join_mssql_hostname_and_instance(source_name, instance, port)] = target_name

        return mapping


class DatabaseServers(Entity):
    """Information about source and target database servers of subscription"""
    def __init__(self, source_mysql_servers, target_mysql_server, source_mssql_servers, target_mssql_server):
        self._source_mysql_servers = source_mysql_servers
        self._target_mysql_server = target_mysql_server
        self._source_mssql_servers = source_mssql_servers
        self._target_mssql_server = target_mssql_server

    @property
    def source_mysql_servers(self):
        """List of source MySQL servers of subscription

        :rtype: list[parallels.core.connections.database_servers.base.DatabaseServer]
        """
        return self._source_mysql_servers

    @property
    def target_mysql_server(self):
        """Target MySQL server of subscription

        :rtype: parallels.core.connections.database_servers.base.DatabaseServer
        """
        return self._target_mysql_server

    @property
    def source_mssql_servers(self):
        """List of source MSSQL servers of subscription

        :rtype: list[parallels.core.connections.database_servers.base.DatabaseServer]
        """
        return self._source_mssql_servers

    @property
    def target_mssql_server(self):
        """Target MSSQL server of subscription

        :rtype: parallels.core.connections.database_servers.base.DatabaseServer
        """
        return self._target_mssql_server
