import posixpath
import random
import string
# Python 2/3 compatibility
try:
    from urlparse import urljoin, urlunparse, urlparse
except ImportError:
    from urllib.parse import urljoin, urlunparse, urlparse
from contextlib import contextmanager
from urllib2 import URLError

from parallels.core.logging import get_logger
from parallels.core.runners.unix.ssh import SSHRunner
from parallels.core.utils.common import strip_suffix, is_empty, format_table, default, safe_format, safe_string_repr, \
    cached
from parallels.core.utils.entity import Entity
from parallels.core.utils.http_client import HttpClientError, perform_http_request_response
from parallels.core.utils.migrator_utils import get_optional_option_value
from parallels.core.utils.ssl_utils import is_ssl_validation_error
from parallels.plesk.source.web import messages
from parallels.plesk.source.web.actions.base.site_migration import SiteMigrationAction
from parallels.plesk.source.web.utils.site_migration_exception import SiteMigrationError
from parallels.plesk.source.web.utils.status_storage import StatusStorage

logger = get_logger(__name__)


class RetrieveDocumentRootAndURL(SiteMigrationAction):
    """Retrieve path to document root of each web site and detect it base URL;
    if document root path specified explicitly via the migration configuration file, it will be used,
    otherwise this action will try to determine document root path by probing approach: place
    specific files on the source and try to access it via web server
    """

    # directory traversal depth
    MAX_DEPTH = 3

    def get_description(self):
        """Get short description of action as string

        :rtype: str | unicode
        """
        return messages.ACTION_RETRIEVE_DOCUMENT_ROOT_AND_URL_DESCRIPTION

    def get_failure_message(self, global_context):
        """Get message for situation when action failed

        :type global_context: parallels.core.global_context.GlobalMigrationContext
        :rtype: str | unicode
        """
        return messages.ACTION_RETRIEVE_DOCUMENT_ROOT_AND_URL_FAILURE

    def is_critical(self):
        """If action is critical or not

        :rtype: bool
        """
        # This action is critical - we are not able to proceed further without the document root,
        # as we will not be able to run web RPC agent.
        return True

    def run(self, global_context):
        """
        :type global_context: parallels.plesk.source.web.global_context.WebGlobalMigrationContext
        """
        try:
            if (
                not get_optional_option_value(global_context.options, 'reload_source_data', False) and
                global_context.site_info_storage.get_document_root() is not None and
                global_context.site_info_storage.get_base_url() is not None
            ):
                logger.finfo(
                    messages.ACTION_RETRIEVE_DOCUMENT_ROOT_SOURCE_CACHED,
                    document_root=global_context.site_info_storage.get_document_root()
                )
                logger.finfo(
                    messages.ACTION_RETRIEVE_BASE_URL_SOURCE_CACHED,
                    base_url=global_context.site_info_storage.get_base_url()
                )
            else:
                self._retrieve_document_root_and_url(global_context)
        except Exception as e:
            global_context.status_storage.set_operation_failure(
                StatusStorage.OPERATION_SCAN_CONTENT,
                safe_string_repr(e)
            )
            raise

    def _retrieve_document_root_and_url(self, global_context):
        source = global_context.conn.get_source()
        runner = source.get_filesystem_runner()

        if not is_empty(source.config.document_root_explicit):
            logger.finfo(
                messages.ACTION_RETRIEVE_DOCUMENT_ROOT_SOURCE_SPECIFIED,
                document_root=source.config.document_root_explicit
            )
            start_filesystem_root = source.config.document_root_explicit
            if not start_filesystem_root.endswith('/'):
                start_filesystem_root += '/'
            if not start_filesystem_root.startswith('/'):
                start_filesystem_root = '/' + start_filesystem_root
        else:
            start_filesystem_root = source.get_files_root()

        base_url_explicit = not is_empty(source.config.base_url_explicit)
        if base_url_explicit:
            # use base URL from config if it's defined
            base_url = source.config.base_url_explicit
            logger.finfo(
                messages.ACTION_RETRIEVE_BASE_URL_SOURCE_SPECIFIED,
                base_url=base_url
            )
            if not base_url.endswith('/'):
                base_url += '/'
        else:
            base_url = 'http://' + source.config.host + '/'

        logger.finfo(
            messages.LOG_DETECT_DOCROOT_URL,
            file=self.probe_file_name, path=start_filesystem_root, url=base_url
        )
        checked_paths = []
        document_root, new_base_url = self._scan_for_document_root_and_final_url(
            runner, [start_filesystem_root], base_url, checked_paths,
            check_http_and_https=not base_url_explicit
        )

        if base_url_explicit and new_base_url is not None and new_base_url != base_url:
            # New URL was detected. That could happen in case of HTTP redirection:
            # 1) SEO-redirection.
            # For example, customer specified site "example.com" in GUI/config.ini, while "example.com"
            # always redirects to "www.example.com"
            # 2) HTTPS-redirection
            # For example, customer specified site URL http://example.com/ (you even can't specify HTTPS from GUI),
            # while site always redirects to https://example.com/
            # 3) Forwarding withing the same domain
            # For example, customer specified URL "http://blog.example.com",
            # while it redirects to "http://example.com/blog"
            #
            # We must use the new URL to:
            # 1) Run web RPC agent. We must have URL which does not redirect, because Python looses POST data
            # when doing HTTP redirection.
            # 2) Do post-migration checks. Both target site and source site must return the same HTTP codes for
            # post-migration checks to pass. Checks will fail if source site redirects with 301/302 code, while
            # target site returns 200 code.
            logger.finfo(messages.NEW_BASE_URL, url=new_base_url)

        if document_root is None:
            logger.fwarn(messages.CHECKED_PATHS, table=self._format_checked_paths(checked_paths))
            if is_empty(source.config.document_root_explicit) and not base_url_explicit:
                message = messages.ACTION_RETRIEVE_DOCUMENT_ROOT_SOURCE_NOT_FOUND_INITIAL
                issue_id = StatusStorage.ISSUE_ID_FAILED_TO_DETECT_DOCROOT
            else:
                if source.is_ssh_available():
                    message = messages.ACTION_RETRIEVE_DOCUMENT_ROOT_SOURCE_NOT_FOUND_SSH
                else:
                    message = messages.ACTION_RETRIEVE_DOCUMENT_ROOT_SOURCE_NOT_FOUND_FTP
                issue_id = None

            global_context.status_storage.set_operation_failure(
                StatusStorage.OPERATION_SCAN_CONTENT, message,
                issue_id=issue_id
            )
            raise SiteMigrationError(message)

        logger.fdebug(messages.CHECKED_PATHS, table=self._format_checked_paths(checked_paths))
        logger.finfo(
            messages.ACTION_RETRIEVE_DOCUMENT_ROOT_SOURCE_FOUND,
            document_root=document_root, base_url=new_base_url
        )

        self._check_connection_security(global_context, new_base_url)

        global_context.site_info_storage.set_base_url(new_base_url)
        global_context.site_info_storage.set_document_root(document_root)

    def _check_connection_security(self, global_context, new_base_url):
        source = global_context.conn.get_source()
        if source.config.effective_allow_insecure_connections:
            return

        new_base_url_scheme = urlparse(new_base_url).scheme
        explicit_base_url = source.config.base_url_explicit
        explicit_base_url_scheme = urlparse(explicit_base_url).scheme
        if new_base_url_scheme == 'http' and explicit_base_url_scheme != 'http':
            message = safe_format(messages.NEW_BASE_URL_HTTP_DETECTED, url=new_base_url)
            issue_id = StatusStorage.ISSUE_ID_INSECURE_HTTP_DETECTED
            global_context.status_storage.set_operation_failure(
                StatusStorage.OPERATION_SCAN_CONTENT, message,
                issue_id=issue_id
            )
            raise SiteMigrationError(message)

        if new_base_url_scheme == 'https' and not self._has_valid_ssl(new_base_url):
            message = safe_format(messages.NEW_BASE_URL_INVALID_CERTIFICATE, url=new_base_url)
            issue_id = StatusStorage.ISSUE_ID_INSECURE_HTTPS_DETECTED
            global_context.status_storage.set_operation_failure(
                StatusStorage.OPERATION_SCAN_CONTENT, message,
                issue_id=issue_id
            )
            raise SiteMigrationError(message)

    def _scan_for_document_root_and_final_url(
            self, runner, paths, base_url, checked_paths, check_http_and_https=False, depth=0
    ):
        """
        Check each directory starting from paths by uploading probe file with random content
        and calling probe file by HTTP/HTTPS. If probe file is found, consider current path is a document root.
        If check_http_and_https is set as True, the probe file will be scanned via both HTTP and HTTPS protocols,
        The first found result will be returned
        If check_http_and_https is set as False, protocol to search will be extracted from base_url.
        """
        # Prepare list of URLs for check (2 URLs if auto mode)
        probe_url = urljoin(base_url, self.probe_file_name)
        if check_http_and_https:
            # the order here matters, want to check HTTPS firstly
            urls_to_check = [
                self._replace_protocol_in_url(probe_url, 'https'),
                self._replace_protocol_in_url(probe_url, 'http')
            ]
        else:
            urls_to_check = [probe_url]

        # check given paths
        for path in paths:
            probe_path = posixpath.join(path, self.probe_file_name)
            with self._upload_probe(runner, probe_path) as probe_content:
                if probe_content is None:
                    checked_paths.append(CheckedPath(
                        path=probe_path, url='', result=CheckedPath.RESULT_FAILED_TO_UPLOAD_PROBE
                    ))
                    continue

                for url in urls_to_check:
                    final_url, checked_path_result = self._check_single_url(url, probe_content, check_http_and_https)
                    if checked_path_result is not None:
                        checked_paths.append(CheckedPath(path=probe_path, url=url, result=checked_path_result))
                    if final_url is not None:
                        return path, final_url

        if depth >= self.MAX_DEPTH:
            return None, None
        # list nested paths
        nested_paths = []
        for path in paths:
            directories_list = []
            try:
                directories_list = runner.get_directories_list(path)
            except Exception:
                logger.exception()
            nested_paths.extend([posixpath.join(path, subdir) for subdir in directories_list])
        if len(nested_paths) == 0:
            return None, None
        # recursively check nested paths
        return self._scan_for_document_root_and_final_url(
            runner, nested_paths, base_url, checked_paths, check_http_and_https, depth + 1
        )

    def _check_single_url(self, url, expected_probe_content, auto_mode):
        try:
            # skip certificates validation here, it is safe to request probe file with constant link
            response = perform_http_request_response(url, verify_certificates=False)
        except HttpClientError:
            return None, CheckedPath.RESULT_HTTP_ERROR
        except URLError:
            return None, CheckedPath.RESULT_URL_ERROR
        except Exception:
            logger.exception()
            return None, CheckedPath.RESULT_UNKNOWN_ERROR

        # skip probe file with unexpected content
        if response.response_text != expected_probe_content:
            return None, CheckedPath.RESULT_DIFFERENT_HTTP_RESPONSE

        final_url = default(strip_suffix(response.final_url, self.probe_file_name), url)
        final_url_scheme = urlparse(final_url).scheme
        # final URL is HTTPS - good result for all cases
        if final_url_scheme == 'https':
            return final_url, CheckedPath.RESULT_SUCCESS
        # final URL is HTTP
        elif final_url_scheme == 'http':
            # remember result and continue to search HTTPS solution for auto mode
            if auto_mode:
                return final_url, CheckedPath.RESULT_HTTP_SUCCESS
            # if desirable protocol is defined explicitly
            else:
                url_scheme = urlparse(url).scheme
                # desirable protocol is HTTPS - HTTP is unsuitable solution
                if url_scheme == 'https':
                    return None, CheckedPath.RESULT_HTTP_REDIRECT
                # desirable protocol is HTTP - HTTP is suitable solution
                elif url_scheme == 'http':
                    return final_url, CheckedPath.RESULT_SUCCESS
                else:
                    # Non HTTP and non HTTPS base URL.
                    # Base URL must be checked outside of this function, consider this case as impossible.
                    return None, None
        else:
            # non HTTP and non HTTPS final URL
            return None, CheckedPath.RESULT_OTHER_REDIRECT

    @contextmanager
    def _upload_probe(self, runner, probe_path):
        """Upload probe file by FTP/SSH, return contents of probe file in case of success and None in case of failure

        :type runner: parallels.core.runners.base.BaseRunner
        :type probe_path: str | unicode
        """
        probe_content = ''.join([random.choice(string.lowercase) for _ in range(32)])
        try:
            runner.upload_file_content(probe_path, probe_content)
            try:
                self._safe_chmod_if_sshrunner(runner, probe_path, chmod=0655)
                yield probe_content
            finally:
                self._safe_remove_probe_file(runner, probe_path)
        except Exception:
            # Ignore any exceptions when uploading file, continue to the next directory.
            # Examples of situations when we are not able to upload file:
            # - no filesystem permissions
            # - disk quota exceeded
            logger.exception()
            yield None

    @staticmethod
    def _safe_remove_probe_file(runner, path):
        try:
            runner.remove_file(path)
        except Exception:
            logger.fdebug(messages.REMOVE_PROBE_FILE_FAILED, path=path)

    @staticmethod
    def _safe_chmod_if_sshrunner(runner, path, chmod=0655):
        try:
            if isinstance(runner, SSHRunner):
                # when uploading files by SSH, they get too low permission so web server can't access them,
                # change permissions to the probe file so we can work with it
                runner.chmod(path, chmod)
        except Exception:
            logger.fdebug(messages.SSHRUNNER_CHANGE_PROBE_FILE_PERMISSIONS_FAILED, path=path)

    @staticmethod
    def _format_checked_paths(checked_paths):
        """
        :type checked_paths: list[parallels.plesk.source.web.actions.configure.document_root_and_url.CheckedPath]
        :rtype: str | unicode
        """
        rows = [
            [messages.CHECKED_PATH, messages.CHECKED_PATH_URL, messages.CHECKED_PATH_RESULT]
        ]
        result_messages = {
            CheckedPath.RESULT_SUCCESS: messages.CHECKED_PATH_RESULT_SUCCESS,
            CheckedPath.RESULT_FAILED_TO_UPLOAD_PROBE: messages.CHECKED_PATH_RESULT_FAILED_TO_UPLOAD_PROBE,
            CheckedPath.RESULT_HTTP_ERROR: messages.CHECKED_PATH_HTTP_ERROR,
            CheckedPath.RESULT_URL_ERROR: messages.CHECKED_PATH_URL_ERROR,
            CheckedPath.RESULT_UNKNOWN_ERROR: messages.CHECKED_PATH_UNKNOWN_ERROR,
            CheckedPath.RESULT_DIFFERENT_HTTP_RESPONSE: messages.CHECKED_PATH_DIFFERENT_HTTP_RESPONSE,
            CheckedPath.RESULT_HTTP_SUCCESS: messages.CHECKED_HTTP_PATH_SUCCESS,
            CheckedPath.RESULT_HTTP_REDIRECT: messages.CHECKED_PATH_HTTP_REDIRECT,
            CheckedPath.RESULT_OTHER_REDIRECT: messages.CHECKED_PATH_OTHER_REDIRECT
        }
        for checked_path in checked_paths:
            rows.append([
                checked_path.path,
                checked_path.url,
                result_messages.get(checked_path.result, checked_path.result)
            ])

        return format_table(rows)

    @staticmethod
    def _replace_protocol_in_url(url, new_protocol):
        _, netloc, some_url, params, query, fragment = urlparse(url)
        return urlunparse((new_protocol, netloc, some_url, params, query, fragment))

    @staticmethod
    def _has_valid_ssl(url):
        try:
            perform_http_request_response(url, verify_certificates=True)
        except URLError as e:
            if is_ssl_validation_error(e):
                return False
        return True

    @property
    @cached
    def probe_file_name(self):
        # Make probe file name unique per migrator execution, to avoid problems
        # when there were previous executions of migrator and old content of the probe file
        # is returned because of caching configuration on the source server.
        # Also may be helpful to minimize probability of race condition when the same source
        # is migrated into 2 targets at the same time.
        unique_id_len = 8
        probe_file_id = ''.join(
            random.choice(string.ascii_lowercase + string.digits) for _ in range(unique_id_len)
        )

        return 'plesk-migrator-probe-%s.html' % probe_file_id


class CheckedPath(Entity):
    RESULT_FAILED_TO_UPLOAD_PROBE = 'failed-to-upload-probe'
    RESULT_DIFFERENT_HTTP_RESPONSE = 'different-http-response'
    RESULT_HTTP_ERROR = 'http-error'
    RESULT_URL_ERROR = 'url-error'
    RESULT_UNKNOWN_ERROR = 'unknown-error'
    RESULT_SUCCESS = 'success'
    RESULT_HTTP_SUCCESS = 'http-success'
    RESULT_HTTP_REDIRECT = 'http-redirect'
    RESULT_OTHER_REDIRECT = 'other-redirect'

    def __init__(self, path, url, result):
        """
        :type path: str | unicode
        :type url: str | unicode | None
        :type result: str | unicode
        """
        self._path = path
        self._url = url
        self._result = result

    @property
    def path(self):
        """Path to which probe was uploaded by FTP/SSH

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

    @property
    def url(self):
        """URL that was checked by HTTP

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

    @property
    def result(self):
        """Check result - constant from RESULT_*

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