import errno
import logging
import os
import posixpath
import ssl
import string
import types
from contextlib import contextmanager
from ftplib import FTP, error_perm, FTP_TLS

import messages
from ftp_migrator.issue import Issue
from ftp_migrator.tools import safe_format, safe_string_repr, is_run_on_windows

logger = logging.getLogger('plesk')


class Ftp(object):
    MAX_RETRY = 3

    def __init__(self, host, username, password, use_ftps, encoding='utf-8', ignore_passive_mode_server_ip=False):
        """

        When ignore_passive_mode_server_ip is set, IP address returned by
        FTP server when opening data channel in passive mode is ignored. That option could be necessary
        because some misconfigured FTP servers return internal IPs, which could not be used
        to connect to the server. When the option is set, hostname specified for control connection
        to the FTP server will be used for data connections.

        :type host: str | unicode
        :type username: str | unicode
        :type password: str | unicode
        :type use_ftps: bool
        :type encoding: str | unicode | None
        """
        self._host = host
        self._username = username
        self._password = password
        self._use_ftps = use_ftps
        self._encoding = encoding
        if not self._encoding:
            self._encoding = 'utf-8'
        self._ignore_passive_mode_server_ip = ignore_passive_mode_server_ip
        self._ftp_timeout = 30
        self._is_mlsd_supported = None
        self._is_utf8_supported = None
        self._feat_command_output = None
        self._ftp = None
        self._server_version = None
        self.connect()

    def connect(self):
        """Setup connection with remote FTP server

        :return:
        """
        logger.info(safe_format(messages.CONNECT, host=self._host))
        if self._use_ftps:
            self._ftp = PatchedFtps(
                timeout=self._ftp_timeout, context=ssl._create_unverified_context(),
                ignore_passive_mode_server_ip=self._ignore_passive_mode_server_ip
            )
        else:
            self._ftp = PatchedFtp(
                timeout=self._ftp_timeout,
                ignore_passive_mode_server_ip=self._ignore_passive_mode_server_ip
            )

        self._server_version = self._ftp.connect(self._host)
        logger.info(safe_format(messages.CONNECTION_ESTABLISHED, host=self._host, reply=self._server_version))
        logger.info(safe_format(messages.LOGIN, host=self._host, username=self._username))
        self._ftp.login(self._username, self._password)

        if self._use_ftps:
            self._ftp.prot_p()  # secure the data connection

        logger.info(safe_format(messages.LOGIN_SUCCESS, host=self._host, username=self._username))

        if self._encoding == 'utf-8' and self.is_utf8_supported():
            try:
                self._ftp.voidcmd('OPTS UTF8 ON')
            except Exception as e:
                logger.debug(messages.LOG_EXCEPTION, exc_info=True)
                logger.warn(messages.FAILED_TO_ENABLE_UTF8.format(reason=safe_string_repr(e)))

    def close(self):
        """Close connection

        :return:
        """
        try:
            self._ftp.close()
            logger.info(safe_format(messages.CONNECTION_CLOSED, host=self._host))
        except Exception:
            # There is nothing bad if error occurred when closing connection; ignore any error here
            pass

    @property
    def server_version(self):
        """Returns reply of FTP server on 'connect' command.
        FTP server version is expected here.
        If connection was never established, return None

        :rtype: str | unicode | None
        """
        return self._server_version

    def download_path(self, remote_path, local_path, excludes=None):
        """
        :type remote_path: str
        :type local_path: str
        :type excludes: list[str | unicode] | None
        :rtype: list[ftp_migrator.issue.Issue]
        """
        if is_run_on_windows():
            # To work with Unicode paths on Windows we must use Unicode strings, not binary strings:
            # binary strings must be in Windows ANSI codepage, and they can't encode whole range of Unicode symbols,
            # so, for example, you can't encode Cyrillic symbols when Windows has CP1252 ANSI codepage.
            # So, convert local path (used to work with local filesystem) from binary string
            # in UTF-8 encoding (as provided to FTP migrator's input) to Unicode string.
            local_path = local_path.decode('utf-8')

        # Remote path is provided in UTF-8 encoding to FTP migrator' input. Convert it to encoding
        # which is actually used by FTP server to communicate with the FTP server correctly.
        remote_path = remote_path.decode('utf-8').encode(self._encoding)

        with self._modify_umask():
            return self._download_path(remote_path, local_path, excludes)

    @staticmethod
    @contextmanager
    def _modify_umask(umask=0o022):
        if is_run_on_windows():
            yield
        else:
            previous_umask = umask
            logger.info(safe_format(messages.SET_UMASK, umask=oct(umask)))
            try:
                previous_umask = os.umask(umask)
            except Exception as e:
                logger.debug(messages.LOG_EXCEPTION, exc_info=True)
                logger.error(safe_format(
                    messages.FAILED_TO_SET_UMASK, umask=oct(umask), details=safe_string_repr(e)
                ))
            try:
                yield
            finally:
                logger.info(safe_format(messages.RESTORE_UMASK, umask=oct(previous_umask)))
                try:
                    os.umask(previous_umask)
                except Exception as e:
                    logger.debug(messages.LOG_EXCEPTION, exc_info=True)
                    logger.error(safe_format(
                            messages.FAILED_TO_RESTORE_UMASK, umask=oct(previous_umask), details=safe_string_repr(e)
                    ))

    def _download_path(self, remote_path, local_path, excludes=None):
        """
        :type remote_path: str
        :type local_path: str | unicode
        :type excludes: list[str | unicode] | None
        :rtype: list[ftp_migrator.issue.Issue]
        """
        issues = []

        abs_remote_path = posixpath.join('/', remote_path)

        if excludes is not None:
            for exclude in excludes:
                if path_startswith(abs_remote_path, exclude):
                    logger.info(safe_format(
                        messages.SKIP_DIRECTORY, path=abs_remote_path
                    ))
                    return issues

        mkdir_p(local_path)
        # retrieve list of nested items (both files and directories) and process it
        for item in self._list_file_and_directory_paths(remote_path):
            if item.name.startswith('plesk-migrator-agent'):
                # skip migrator agent files when transferring - they are garbage for the target server
                # now simply match by name pattern, better implementation should pass list of excluded
                # directories as argument to the FTP migrator
                continue

            item_remote_full_path = posixpath.join(abs_remote_path, item.name)

            if is_run_on_windows():
                # To work with Unicode paths on Windows we must use Unicode strings, not binary strings:
                # binary strings must be in Windows ANSI codepage, and they can't encode whole range of Unicode symbols,
                # so, for example, you can't encode Cyrillic symbols when Windows has CP1252 ANSI codepage.
                # So, convert path provided by FTP server from binary string
                # in FTP server's encoding to Unicode string.
                # If there are encoding/decoding issues - replace all symbols which could not be encoded/decoded
                # with a special symbol, so at least we transfer file contents.
                item_name_encoded = item.name.decode(self._encoding, 'replace')
            else:
                # Consider that most of modern Linux distributions use UTF-8 on filesystem, and accept
                # file names as binary strings in UTF-8. Non-UTF-8 sequences are allowed too.
                if self._encoding == 'utf-8':
                    # If FTP server works in UTF-8 encoding (which is default) - take the binary string "as-is".
                    # That also allows to transfer file names which are not correct UTF-8 sequences.
                    item_name_encoded = item.name
                else:
                    # If FTP server works in other encoding - convert it to UTF-8.
                    # If there are encoding/decoding issues - replace all symbols which could not be encoded/decoded
                    # with a special symbol, so at least we transfer file contents.
                    item_name_encoded = item.name.decode(self._encoding, 'replace').encode('utf-8', 'replace')

            item_local_full_path = os.path.join(local_path, item_name_encoded)

            if item.is_dir:
                try:
                    issues += self._download_path(item_remote_full_path, item_local_full_path, excludes)
                except Exception as e:
                    logger.debug(messages.LOG_EXCEPTION, exc_info=True)
                    message = safe_format(
                        messages.FAILED_TO_DOWNLOAD_DIR,
                        source_path=item_remote_full_path, target_path=item_local_full_path
                    )
                    logger.error(message)
                    issues.append(Issue(problem_text=message, details_text=safe_string_repr(e)))
            else:
                try:
                    issues += self.download_file(item_remote_full_path, item_local_full_path, excludes)
                except Exception as e:
                    logger.debug(messages.LOG_EXCEPTION, exc_info=True)
                    message = safe_format(
                        messages.FAILED_TO_DOWNLOAD_FILE,
                        source_path=item_remote_full_path, target_path=item_local_full_path
                    )
                    logger.error(message)
                    issues.append(Issue(problem_text=message, details_text=safe_string_repr(e)))

        return issues

    def download_file(self, remote_path, local_path, excludes=None):
        """
        :type remote_path: str
        :type local_path: str | unicode
        :type excludes: list[str | unicode] | None
        :rtype: list[ftp_migrator.issue.Issue]
        """
        issues = []

        if excludes is not None:
            for exclude in excludes:
                if path_startswith(remote_path, exclude):
                    logger.info(safe_format(
                        messages.SKIP_FILE, path=remote_path
                    ))
                    return issues

        # Do not log each transferred file  now to avoid too large output.
        # In the future it is proposed to add debug mode which enabled/disables detailed output
        # or write process to file.
        # logger.info(safe_format(messages.DOWNLOAD_FILE, source_path=remote_path, target_path=local_path))

        def transfer_file():
            try:
                with open(local_path, 'wb') as fp:
                    def _write_target_file_content(content):
                        fp.write(content)
                    self._ftp.retrbinary('RETR %s' % remote_path, _write_target_file_content)
            except error_perm as e:
                message = safe_format(
                    messages.DOWNLOAD_FILE_FAILED, source_path=remote_path,
                    target_path=local_path, message=e.message
                )
                logger.error(message)
                issues.append(Issue(problem_text=message, details_text=safe_string_repr(e)))

        self._run_ftp_operation(
            transfer_file,
            safe_format(messages.DOWNLOAD_FILE_FAILED, source_path=remote_path, target_path=local_path)
        )

        return issues

    def _list_file_and_directory_paths(self, path):
        """
        :type path: str | unicode
        :rtype: list[parallels.core.utils.ftp.ItemInfo]
        """
        # Do not log each listed directory now to avoid too large output.
        # In the future it is proposed to add debug mode which enabled/disables detailed output.
        # logger.info(safe_format(messages.LIST, path=path))

        abs_path = posixpath.join('/', path)

        if not self.is_mlsd_supported():
            def ftp_dir():
                lines = []

                def consume_lines(line):
                    lines.append(line)

                self._ftp.dir('-a', abs_path, consume_lines)

                return lines

            dir_lines = self._run_ftp_operation(
                ftp_dir, safe_format(messages.LIST_FAILED, path=path)
            )

            return self._parse_list_output(dir_lines)
        else:
            def mlsd_dir():
                lines = []

                def consume_lines(line):
                    lines.append(line)

                self._ftp.retrlines('MLSD %s' % abs_path, consume_lines)

                return lines

            mlsd_lines = self._run_ftp_operation(
                mlsd_dir, safe_format(messages.LIST_FAILED, path=path)
            )

            return self._parse_mlsd_output(mlsd_lines)

    def _run_ftp_operation(self, ftp_operation_function, error_message):
        error = None
        result = None
        for retry in range(self.MAX_RETRY):
            error = None
            try:
                result = ftp_operation_function()
                if retry > 0:
                    logger.info(safe_format(messages.OPERATION_SUCCESS, retry=retry))
                break
            except Exception as e:
                logger.info(safe_format(messages.OPERATION_FAILED, message=e, retry=retry + 1))
                error = e
                self.close()
                self.connect()
        if error:
            raise Exception(safe_format(error_message, message=error))
        else:
            return result

    def is_utf8_supported(self):
        if self._is_utf8_supported is None:
            self._is_utf8_supported = 'utf8' in self.get_feat_command_output()

        return self._is_utf8_supported

    def is_mlsd_supported(self):
        if self._is_mlsd_supported is None:
            self._is_mlsd_supported = 'mlsd' in self.get_feat_command_output()
            if self._is_mlsd_supported:
                logger.info(messages.FTP_MLSD_ENABLED)

        return self._is_mlsd_supported

    def get_feat_command_output(self):
        if self._feat_command_output is None:
            try:
                self._feat_command_output = self._ftp.sendcmd('FEAT').lower()
            except Exception:
                self._feat_command_output = ''
        return self._feat_command_output

    @staticmethod
    def _parse_list_output(lines):
        """Parse output of LIST command, return list of ItemInfo objects.

        The function parses both Windows and Unix formats, format is detected automatically.
        The function is fault tolerant - in case it failed to parse any line,
        it is simply skipped, no exception is raise.

        :type lines: list[str | unicode]
        :rtype: list[parallels.core.utils.ftp.ItemInfo]
        """
        items = []

        for line in lines:
            if line.strip() == '':
                # ignore empty lines
                continue

            if line.startswith('total'):
                # ignore lines like "total 123" stating items count in the directory
                continue

            if line[0] in string.digits:
                # Windows format
                line_parts = line.split(None, 3)

                if len(line_parts) != 4:
                    # invalid line - skip it
                    continue

                name = line_parts[3]

                if line_parts[2].lower() == '<dir>':
                    item_type = ItemInfo.TYPE_DIR
                else:
                    item_type = ItemInfo.TYPE_FILE
            else:
                # Unix format

                # Two formats are possible: 8-item and 9-item. First detect format by checking the 4-th item,
                # which in case of 9-item format should digits, while in case of 8-digit format should contain
                # letters (month name).
                #
                # Example of 9-item format:
                # drwxr-x--- 5 wpabaturin 99 4096 Apr 26 09:36 public_html
                #
                # Example of 8-item format:
                # -rw-r--r-- 1 staff 3015 May 5 16:08 .bash_aliases
                #
                # See http://ftputil.sschwarzer.net/trac/ticket/12 for details on these formats.
                line_parts = line.split(None, 8)

                if len(line_parts) < 7:
                    # invalid line - skip it
                    continue

                if not all(c in string.digits for c in line_parts[4]):
                    line_parts = line.split(None, 7)

                name = line_parts[len(line_parts) - 1]

                file_type = line_parts[0].lower()[:1]

                if file_type == 'd':
                    item_type = ItemInfo.TYPE_DIR
                elif file_type == '-':
                    item_type = ItemInfo.TYPE_FILE
                else:
                    # we are not interested in sockets, character devices, symbolic links, etc
                    continue

            if name in ('.', '..'):
                continue

            items.append(ItemInfo(name, item_type))

        return items

    @staticmethod
    def _parse_mlsd_output(lines):
        """Parse output of MSDL command according to RFC3659, return list of ItemInfo objects.

        :type lines: list[str | unicode] 
        :rtype: list[parallels.core.utils.ftp.ItemInfo]
        """
        items = []

        for line in lines:
            if line.strip() == '':
                # ignore empty lines; MLSD output should not contain such lines, just for better fault tolerance
                continue

            parts = line.split(None, 1)

            if len(parts) != 2:
                # ignore invalid lines; MLSD output should not contain such lines, just for better fault tolerance
                continue

            facts = {}

            facts_str, filename = parts
            facts_str_list = facts_str.split(';')

            for fact_str_parts in facts_str_list:
                fact_str_parts = fact_str_parts.split('=', 1)

                if len(fact_str_parts) != 2:
                    continue

                name, value = fact_str_parts

                facts[name.lower()] = value

            if facts.get('type') == 'dir':
                item_type = ItemInfo.TYPE_DIR
            elif facts.get('type') == 'file':
                item_type = ItemInfo.TYPE_FILE
            else:
                continue

            items.append(ItemInfo(filename, item_type))

        return items


class ItemInfo(object):
    TYPE_FILE = 'file'
    TYPE_DIR = 'dir'

    def __init__(self, name, item_type):
        self._name = name
        self._item_type = item_type

    @property
    def name(self):
        return self._name

    @property
    def item_type(self):
        return self._item_type

    @property
    def is_dir(self):
        return self.item_type == ItemInfo.TYPE_DIR


def mkdir_p(path):
    """Create directory recursive

    :rtype: str | unicode
    :rtype: None
    """
    try:
        os.makedirs(path)
    except OSError as exc:
        if exc.errno == errno.EEXIST and os.path.isdir(path):
            pass
        else:
            raise


class PatchedFtp(FTP):
    """Class to workaround ftplib.FTP issues.

    Addressed issues:
    1) Added an option to allow ignoring IP address returned by FTP server when opening data channel in passive mode.
    That option could be necessary because some misconfigured FTP servers return internal IPs,
    which could not be used to connect to the server. When the option is set,
    hostname specified for control connection to the FTP server will be used for data connections.
    """
    def __init__(self, timeout, ignore_passive_mode_server_ip=False):
        self._ignore_passive_mode_server_ip = ignore_passive_mode_server_ip
        FTP.__init__(self, timeout=timeout)

    def makepasv(self):
        host, port = FTP.makepasv(self)
        if self._ignore_passive_mode_server_ip:
            return self.host, port
        else:
            return host, port


class PatchedFtps(FTP_TLS):
    """Class to workaround ftplib.FTP_TLS issues

    Addressed issues:
    1) Added an option to allow ignoring IP address returned by FTP server when opening data channel in passive mode.
    That option could be necessary because some misconfigured FTP servers return internal IPs,
    which could not be used to connect to the server. When the option is set,
    hostname specified for control connection to the FTP server will be used for data connections.

    2) Fixed compatibility between FTP_TLS class and IIS FTP

    FTP_TLS class tries to call "unwrap" method of SSL socket once the socket for data connection
    is not needed anymore. But IIS seems to close connection before we call "unwrap" for SSL shutdown.
    It makes "unwrap" function to fail which leads to failure of every command which uses FTP data channels.

    Actually, it seems that it is not required to call "unwrap" - connection will be closed anyway. So we replace
    unwrap function with empty one for each data channel socket.

    See http://bugs.python.org/issue10808 for additional details.
    """
    def __init__(self, timeout, context, ignore_passive_mode_server_ip=False):
        self._ignore_passive_mode_server_ip = ignore_passive_mode_server_ip
        FTP_TLS.__init__(self, timeout=timeout, context=context)

    def makepasv(self):
        host, port = FTP_TLS.makepasv(self)
        if self._ignore_passive_mode_server_ip:
            return self.host, port
        else:
            return host, port

    def transfercmd(self, *args, **kwargs):
        sock = FTP_TLS.transfercmd(self, *args, **kwargs)
        # vsftpd of some versions (e.g. 3.0.3) is not compatible with the w/a -
        # it starts to give "426 Failure reading network stream" error,
        # so apply the w/a for all servers except for vsftpd
        if self.welcome is None or 'vsftpd' not in self.welcome.lower():
            sock.unwrap = types.MethodType(lambda _: None, sock)
        return sock


def path_startswith(path, prefix):
    """Check whether the first part of the path matches specified prefix

    Works both for Windows and for Linux. It is considered that prefix contains entire item
    (filename or directory) - for example:
    path_startswith('/var/www/vhosts/mysite.tld/wordpress/', '/var/www/vhosts/mysite.tld/') will match
    path_startswith('/var/www/vhosts/mysite.tld/mywordpress/', '/var/www/vhosts/mysite') won't match

    :type path: str | unicode
    :type prefix: str | unicode
    :rtype: boolean
    """
    normalized_path = '%s/' % path.replace('\\', '/').rstrip('/ ')
    normalized_prefix = '%s/' % prefix.replace('\\', '/').rstrip('/ ')
    return normalized_path.startswith(normalized_prefix)
