import itertools
import posixpath
import random
import string
import threading

from parallels.core import messages, MigrationError, MigrationNoRepeatError
from parallels.core import safe_format
from parallels.core.logging import get_logger
from parallels.core.runners.base import BaseRunner
from parallels.core.runners.entities import ExecutionOptions
from parallels.core.runners.exceptions.non_zero_exit_code import NonZeroExitCodeException
from parallels.core.utils.cagefs import is_cagefs_enabled_for_server, disable_cagefs
from parallels.core.utils.common import is_empty
from parallels.core.utils.common_constants import PASSWD_FIELD_UID, PASSWD_FIELD_GID, PASSWD_FIELD_HOMEDIR
from parallels.core.utils.entity import Entity
from parallels.core.utils.unix_utils import format_command

logger = get_logger(__name__)


class UserSSHAccessInfo(Entity):
    def __init__(self, username, ssh_private_key_filename, source_server, target_server):
        """
        :type username: str | unicode
        :type ssh_private_key_filename: str | unicode
        :type source_server: parallels.core.connections.source_server.SourceServer
        :type target_server: parallels.core.connections.target_servers.TargetServer
        """
        self._username = username
        self._ssh_private_key_filename = ssh_private_key_filename
        self._source_server = source_server
        self._target_server = target_server

    @property
    def username(self):
        """Username to connect to the server

        :rtype: str | unicode
        """
        return self._username

    @property
    def ssh_private_key_filename(self):
        """SSH private key to connect to the server

        :rtype: str | unicode
        """
        return self._ssh_private_key_filename

    @property
    def source_server(self):
        """Source server

        :rtype: parallels.core.connections.source_server.SourceServer
        """
        return self._source_server

    @property
    def target_server(self):
        """Target server

        :rtype: parallels.core.connections.target_servers.TargetServer
        """
        return self._target_server


class UserSSHAccessPool(object):
    """Provide secure way to copy user files from source server to target server

    Copying files under root user is not secure:
    - when you execute copy command under root at the source server, you may accidentally copy file that was not
    accessible to user.
    - when you execute copy command under root at the target server, you may accidentally overwrite file for which
    user does not have access.

    So copying files that could be modified by user under superuser is bad idea.

    Usually we copy files with rsync. As transport it uses SSH.
    But SSH access may be disabled for user on the source server, or may be broken.

    The may idea used here is:
    1. Create special additional user on the source server
    The additional user should have the same user ID and group ID as main user. Its login is auto-generated.
    As it is a new user, SSH login for it is enabled. As it has the same UID and GID, it could read the same
    files as original user.
    2. Set up SSH key authentication from target to source server:
    - Generate SSH keys pair (on behalf of user on target server).
    - Upload public part of the key to source server (on behalf of additional user on source server)
    3. Perform file transfer, executing rsync, scp or any other utility, on behalf of user on the target server.
    Connect to the server under additional user using the generated private SSH key.

    :type _user_by_login_infos: dict[tuple, parallels.core.actions.content.web.copy_unix_content.SourceSystemUserInfo]
    :type _user_by_ids_infos: dict[tuple, parallels.core.actions.content.web.copy_unix_content.SourceSystemUserInfo]
    """
    def __init__(self):
        self._user_by_login_infos = dict()
        self._user_by_ids_infos = dict()
        self._lock = threading.Lock()

    def get_by_login(self, source_server, target_server, source_server_user, target_server_user):
        """Configure SSH access for files of specified system user on source server.

        SSH key to access the source server is given to specified target system user.

        :type source_server: parallels.core.connections.source_server.SourceServer
        :type target_server: parallels.core.connections.target_servers.TargetServer
        :type source_server_user: str | unicode
        :type target_server_user: str | unicode
        :rtype: parallels.core.utils.user_ssh_access_pool.UserSSHAccessInfo
        """
        with self._lock:
            key = (source_server, target_server, source_server_user, target_server_user)
            if key not in self._user_by_login_infos:
                self._user_by_login_infos[key] = self._configure_ssh_access_by_username(
                    source_server, target_server, source_server_user, target_server_user
                )

            return self._user_by_login_infos[key]

    def get_by_uid_gid(self, source_server, target_server, source_uid, source_gid, target_server_user):
        """Configure SSH access for files by UID (user ID) and GID (group ID) on source server.

        SSH key to access the source server is given to specified target system user.

        :type source_server: parallels.core.connections.source_server.SourceServer
        :type target_server: parallels.core.connections.target_servers.TargetServer
        :type source_uid: str | unicode
        :type source_gid: str | unicode
        :type target_server_user: str | unicode
        :rtype: parallels.core.utils.user_ssh_access_pool.UserSSHAccessInfo
        """
        with self._lock:
            key = (source_server, target_server, source_uid, source_gid, target_server_user)
            if key not in self._user_by_ids_infos:
                self._user_by_ids_infos[key] = self._configure_ssh_access_by_uid_gid(
                    source_server, target_server, source_uid, source_gid, target_server_user
                )

            return self._user_by_ids_infos[key]

    def remove_all(self):
        """Remove all temporary entities created. Call on shutdown.

        :rtype: None
        """
        with self._lock:
            for user_info in itertools.chain(
                self._user_by_login_infos.values(), self._user_by_ids_infos.values()
            ):
                self._remove(user_info)

    def _configure_ssh_access_by_username(
        self, source_server, target_server, source_server_user, target_server_user
    ):
        """
        :type source_server: parallels.core.connections.source_server.SourceServer
        :type target_server: parallels.core.connections.target_servers.TargetServer
        :type source_server_user: str | unicode
        :type target_server_user: str | unicode
        :rtype: parallels.core.utils.user_ssh_access_pool.UserSSHAccessInfo
        """
        source_user_info = self._get_user_passwd_info(source_server, source_server_user)

        source_uid = source_user_info[PASSWD_FIELD_UID]
        source_gid = source_user_info[PASSWD_FIELD_GID]

        if is_empty(source_uid):
            raise MigrationError(safe_format(
                messages.USER_EMPTY_UID, user=source_server_user, server=source_server.description()
            ))
        if is_empty(source_gid):
            raise MigrationError(safe_format(
                messages.USER_EMPTY_GID, user=source_server_user, server=source_server.description()
            ))

        return self._configure_ssh_access_by_uid_gid(
            source_server, target_server, source_uid, source_gid, target_server_user
        )

    def _configure_ssh_access_by_uid_gid(
        self, source_server, target_server, source_uid, source_gid, target_server_user
    ):
        """
        :type source_server: parallels.core.connections.source_server.SourceServer
        :type target_server: parallels.core.connections.target_servers.TargetServer
        :type source_uid: str | unicode
        :type source_gid: str | unicode
        :type target_server_user: str | unicode
        :rtype: parallels.core.utils.user_ssh_access_pool.UserSSHAccessInfo
        """

        additional_source_username = self._generate_source_username()
        self._create_user(source_server, additional_source_username, source_uid, source_gid)

        target_user_info = self._get_user_passwd_info(target_server, target_server_user)
        target_homedir = target_user_info[PASSWD_FIELD_HOMEDIR]
        private_key_filepath = posixpath.join(target_homedir, u'plesk-migrator-key')
        public_key_contents = self._generate_ssh_key_pair(target_server, target_server_user, private_key_filepath)

        self._upload_public_key(source_server, additional_source_username, public_key_contents)

        return UserSSHAccessInfo(
            additional_source_username, private_key_filepath, source_server, target_server
        )

    def _upload_public_key(self, server, username, public_key_contents):
        """Upload public SSH key for specified user.

        Upload is executed under provided user to avoid security issues.

        :type server: parallels.core.connections.server.Server
        :type username: str | unicode
        :type public_key_contents: str | unicode
        :rtype: None
        """
        user_info = self._get_user_passwd_info(server, username)
        user_homedir = user_info[PASSWD_FIELD_HOMEDIR]
        ssh_dir = posixpath.join(user_homedir, u'.ssh')
        keys_file = posixpath.join(ssh_dir, u'authorized_keys')

        with server.runner() as runner:
            assert isinstance(runner, BaseRunner)
            runner.execute_command(
                u'su {username} --shell /bin/sh -c {command}', dict(
                    username=username, command=format_command(
                        u'mkdir -m 755 {ssh_dir} && '
                        u'echo {public_key_contents} > {keys_file} && '
                        u'chmod 644 {keys_file}',
                        ssh_dir=ssh_dir, public_key_contents=public_key_contents, keys_file=keys_file
                    )
                )
            )

    @staticmethod
    def _generate_ssh_key_pair(server, username, private_key_filepath):
        """Generate SSH key pair, put private key to specified file, return public key as string

        Key generation is executed under provided user to avoid security issues.

        :type server: parallels.core.connections.server.Server
        :type username: str | unicode
        :type private_key_filepath: str | unicode
        :rtype: str | unicode
        """
        public_key_filepath = u'%s.pub' % private_key_filepath
        with server.runner() as runner:
            assert isinstance(runner, BaseRunner)
            runner.execute_command(
                u'su {username} --shell /bin/sh -c {command}', dict(
                    username=username,
                    command=format_command(
                        u'echo y | ssh-keygen -q -t rsa -P "" -f {private_key_filepath}',
                        private_key_filepath=private_key_filepath
                    )
                )
            )
            public_key_contents = runner.get_file_contents(public_key_filepath).strip()
            runner.remove_file(public_key_filepath)
            return public_key_contents

    @classmethod
    def _create_user(cls, server, username, uid, gid):
        """Create system user for SSH access at specified server with specified username, user ID and group ID

        :type server: parallels.core.connections.server.Server
        :type username: str | unicode
        :type uid: str | unicode
        :type gid: str | unicode
        """
        with server.runner() as runner:
            assert isinstance(runner, BaseRunner)

            # Some servers do no allow to login by SSH if user has no password. So we generate a random password
            # (which is not used by migrator) for all new users. That password is not plain text password
            # so it can't be used directly (see "man useradd" for details).
            random_password = "".join(random.choice(string.digits + string.ascii_letters) for _ in range(24))

            runner.execute_command(
                # $(cat) means to read the password from stdin
                u'useradd {username} '
                u'-m -g {gid} -o -u {uid} -s /bin/sh -p $(cat)',
                dict(username=username, uid=uid, gid=gid),
                ExecutionOptions(
                    stdin_content=random_password
                )
            )

        # CageFS is a CloudLinux feature which allows to restrict access to files of other users.
        # When it is enabled, it is not possible to properly configure SSH for temporary user
        # to copy files. So we disable CageFS for the temporary user.
        if is_cagefs_enabled_for_server(server):
            try:
                disable_cagefs(server, username)
            except Exception as e:
                logger.exception()
                logger.fwarn(
                    messages.FAILED_TO_DISABLE_CAGEFS_FOR_SOURCE_TEMP_USER,
                    reason=e, username=username, server=server.description()
                )

    @staticmethod
    def _get_user_passwd_info(server, username):
        """Get information about specified user from /etc/passwd file as list of strings.

        Use PASSWD_FIELD_* constants to access fields of the list.

        :type server: parallels.core.connections.server.Server
        :type username: str | unicode
        :raises: parallels.core.utils.user_ssh_access_pool.UserNotFoundException
        :raises: parallels.core.MigrationError
        :rtype: list[str | unicode]
        """
        with server.runner() as runner:
            assert isinstance(runner, BaseRunner)
            try:
                user_info = runner.execute_command(
                    u'getent passwd {username}', dict(username=username)
                ).stdout.strip().split(':')
            except NonZeroExitCodeException as e:
                if e.exit_code == 2:
                    # According to "man gentent", 2 means:
                    # "One or more supplied key could not be found in the database"
                    raise UserNotFoundException(safe_format(
                        messages.USER_DOES_NOT_EXIST_ON_SERVER, user=username, server=server.description()
                    ))
                else:
                    raise

            if len(user_info) < PASSWD_FIELD_HOMEDIR:
                raise MigrationError(safe_format(
                    messages.FAILED_TO_GET_INFO_ABOUT_USER,
                    user=username, server=server.description()
                ))
            return user_info

    def _generate_source_username(self):
        """Generate random username for the temporary user created on source server

        :rtype: str | unicode
        """
        prefix = u'plesk-migrator'
        random_suffix_length = 16

        used_usernames = {user_info.username for user_info in self._user_by_login_infos.values()}
        generated_username = None

        while generated_username is None or generated_username in used_usernames:
            suffix = u''.join(
                random.choice(string.digits + string.ascii_lowercase) for _ in range(random_suffix_length)
            )
            generated_username = u'%s-%s' % (prefix, suffix)

        return generated_username

    def _remove(self, user_info):
        """Remove temporary entities used to connect to the source server: user and SSH private key.

        Temporary user is removed from source server.
        SSH private key file is remove from target server.

        If error occurs - log it as warning, but do not throw any exception, so migrator could
        try to remove other temporary entities.

        :type user_info: parallels.core.utils.user_ssh_access_pool.UserSSHAccessInfo
        :rtype: None
        """
        # 1) Remove temporary user
        with user_info.source_server.runner() as runner:
            assert isinstance(runner, BaseRunner)
            try:
                runner.execute_command(
                    # -f to force user removing, we want to remove user anyway, ignoring any factors
                    # -r to remove home directory and other files of the user
                    u'userdel -f -r {additional_source_username}',
                    dict(
                        additional_source_username=user_info.username,
                    )
                )
            except Exception as e:
                # User remove command failed
                logger.exception()

                def log_user_was_not_removed():
                    logger.fwarn(
                        messages.FAILED_TO_REMOVE_TEMP_USER,
                        user=user_info.username, server=user_info.source_server.description(),
                        reason=e
                    )

                def log_remove_command_failed_but_user_removed():
                    logger.fdebug(
                        messages.REMOVE_TEMP_USER_COMMAND_FAILED,
                        user=user_info.username, server=user_info.source_server.description(),
                        reason=e
                    )

                try:
                    # Check whether user was actually removed - whether it exists in /etc/passwd
                    self._get_user_passwd_info(user_info.source_server, user_info.username)
                except UserNotFoundException:
                    # The user was actually removed - do not emit a warning, rather emit a debug message
                    log_remove_command_failed_but_user_removed()
                except:
                    # Command to remove the user failed, also failed to check if user was actually removed:
                    # emit a warning
                    log_user_was_not_removed()
                else:
                    # Command to remove the user failed, and user was not actually removed: emit a warning
                    log_user_was_not_removed()

        # 2) Remove SSH private key
        with user_info.target_server.runner() as runner:
            try:
                runner.remove_file(user_info.ssh_private_key_filename)
            except Exception as e:
                logger.exception()
                logger.fwarn(
                    messages.FAILED_TO_REMOVE_TEMP_SSH_KEY,
                    server=user_info.source_server.description(), reason=e
                )


class UserNotFoundException(MigrationNoRepeatError):
    pass