import json
import logging
import ssl
import urllib2
import os
import sys
from contextlib import contextmanager, closing

import time

from web_stream_downloader import messages
from web_stream_downloader.config_reader import ConfigReader
from web_stream_downloader.issue import Issue
from web_stream_downloader.packet.directory import PacketDirectory
from web_stream_downloader.packet.error import PacketError
from web_stream_downloader.packet.file_chunk import PacketFileChunk
from web_stream_downloader.packet.file_continue import PacketFileContinue
from web_stream_downloader.packet.file_end import PacketFileEnd
from web_stream_downloader.packet.file_start import PacketFileStart
from web_stream_downloader.packet.stream_end import PacketStreamEnd
from web_stream_downloader.packet.symlink import PacketSymlink
from web_stream_downloader.stream_packet_readers.plain_stream_packet_reader import PlainStreamPacketReader
from web_stream_downloader.utils import mkdir_p, safe_string_repr, safe_format, is_run_on_windows

logger = logging.getLogger('plesk')


def configure_logging():
    plesk_logger = logging.getLogger('plesk')
    plesk_logger.setLevel(logging.DEBUG)
    console_handler = logging.StreamHandler(stream=sys.stderr)
    console_handler.setLevel(logging.DEBUG)
    console_handler.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s"))
    plesk_logger.addHandler(console_handler)


def main():
    configure_logging()

    config = ConfigReader().read_from_args_and_file()
    if not config.ready_for_copy:
        logger.error('Required: ' + ' '.join(config.list_empty_properties))
        return

    issues = []
    result = {}

    try:
        download_issues = download_dir(
            config.password, config.stream_url, config.remote_path, config.local_path, config.excludes,
            config.ssl_verify_certificates, config.ssl_ca_bundle
        )
        issues.extend(download_issues)
    except Exception as e:
        logger.debug(messages.LOG_EXCEPTION, exc_info=True)
        message = safe_format(
            messages.COMPLETELY_FAILED_TO_DOWNLOAD_DIRECTORY,
        )
        details = safe_format(
            messages.COMPLETELY_FAILED_TO_DOWNLOAD_DIRECTORY_DETAILS,
            error_message=safe_string_repr(e),
            source_path=config.remote_path,
            target_path=config.local_path,
            stream_url=config.stream_url
        )
        logger.error(message)
        logger.error(details)
        issues.append(Issue(message, details))

    result['issues'] = [issue.as_dictionary() for issue in issues]

    print(json.dumps(result, indent=4))


def download_dir(password, stream_url, remote_path, local_path, excludes, ssl_verify_certificates, ssl_ca_bundle):
    """
    :type password: str | unicode
    :type stream_url: str | unicode
    :type remote_path: str | unicode
    :type local_path: str | unicode
    :type excludes: list[str | unicode] | None
    :type ssl_verify_certificates: bool
    :type ssl_ca_bundle: str | unicode | None
    :rtype: list[web_stream_downloader.issue.Issue]
    """
    finished = False
    issues = []
    last_position = LastPosition(path=None, position=None)
    old_last_position = LastPosition(path=None, position=None)

    mkdir_p(local_path)

    download_attempts_for_position = 0
    current_filename = None
    current_fp = None

    while True:
        if old_last_position != last_position:  # re-starting with new position, reset attempts count
            download_attempts_for_position = 0
        old_last_position = last_position.clone()

        download_attempts_for_position += 1

        try:
            with _open_stream_packet_reader(
                password, stream_url, remote_path, excludes, last_position,
                ssl_ca_bundle, ssl_verify_certificates
            ) as packet_reader:
                for packet in packet_reader.iter_packets():
                    if isinstance(packet, PacketDirectory):
                        last_position.path = packet.directory_path
                        last_position.position = None

                        try:
                            full_directory_path = os.path.join(local_path, packet.directory_path)
                            mkdir_p(full_directory_path)
                        except Exception as e:
                            issues.append(Issue(
                                problem_text=safe_format(
                                    messages.FAILED_TO_CREATE_DIRECTORY,
                                    directory=packet.directory_path,
                                    reason=e
                                )
                            ))
                    elif isinstance(packet, PacketFileStart):
                        last_position.path = packet.file_path
                        last_position.position = 0

                        if current_fp is not None:
                            # close file if PacketFileEnd was not received
                            # due to any reason (e.g. bug in remote script)
                            current_fp.close()
                            current_fp = None

                        current_filename = packet.file_path
                        try:
                            full_filename_path = os.path.join(local_path, current_filename)
                            current_fp = open(full_filename_path, 'wb')
                        except Exception as e:
                            issues.append(Issue(
                                problem_text=safe_format(
                                    messages.FAILED_TO_WRITE_FILE,
                                    filename=current_filename,
                                    reason=e
                                )
                            ))
                    elif isinstance(packet, PacketFileContinue):
                        last_position.path = packet.file_path
                        last_position.position = packet.start_position

                        if current_fp is not None:
                            # close file if PacketFileEnd was not received
                            # due to any reason (e.g. bug in remote script)
                            current_fp.close()
                            current_fp = None

                        current_filename = packet.file_path
                        try:
                            full_filename_path = os.path.join(local_path, current_filename)
                            current_fp = open(full_filename_path, 'ab')
                            current_fp.seek(packet.start_position)
                        except Exception as e:
                            issues.append(Issue(
                                problem_text=safe_format(
                                    messages.FAILED_TO_WRITE_FILE,
                                    filename=current_filename,
                                    reason=e
                                )
                            ))
                    elif isinstance(packet, PacketFileChunk):
                        last_position.position += len(packet.data)

                        if current_fp is not None:
                            try:
                                current_fp.write(packet.data)
                            except Exception as e:
                                # set current_fp to None - do not write more chunks as we failed to write one chunk:
                                # the file is already corrupted, no sense to waste resources to do anything else
                                # with that file
                                current_fp = None
                                issues.append(Issue(
                                    problem_text=safe_format(
                                        messages.FAILED_TO_WRITE_FILE,
                                        filename=current_filename,
                                        reason=e
                                    )
                                ))
                    elif isinstance(packet, PacketFileEnd):
                        if current_fp is not None:
                            current_fp.close()
                            current_fp = None
                        current_filename = None
                    elif isinstance(packet, PacketSymlink):
                        if not is_run_on_windows():  # transfer symbolic links to target Linux servers only
                            full_symlink_path = os.path.join(local_path, packet.symlink_path)
                            if not os.path.exists(full_symlink_path) and not os.path.islink(full_symlink_path):
                                os.symlink(packet.target, full_symlink_path)
                    elif isinstance(packet, PacketError):
                        issues.append(Issue(problem_text=packet.message.encode('utf-8', errors='replace')))
                    elif isinstance(packet, PacketStreamEnd):
                        finished = True
                        break
                    else:
                        raise Exception(messages.INVALID_PACKET_TYPE)

                if not finished:
                    raise Exception(messages.UNFINISHED_STREAM)
        except Exception as e:
            if current_fp is not None:
                current_fp.close()
                current_fp = None

            max_attempts = 10
            attempts_before_wait = 2

            if download_attempts_for_position > max_attempts:
                # All attempts failed, seems to be a permanent issue, stop transfer
                raise e
            elif download_attempts_for_position > attempts_before_wait:
                # Several attempts failed on the same file/position -
                # probably a network problem, let's wait for some time for network to restore
                wait_seconds = 2
                logger.warn(safe_format(
                    messages.RETRY_SEVERAL_ATTEMPTS,
                    reason=e, seconds=wait_seconds, attempts=download_attempts_for_position
                ))
                time.sleep(wait_seconds)
            else:
                # Less than 3 attempts on the same file/position -
                # just retry once more (possible cause - PHP max execution time)
                logger.warn(safe_format(messages.RETRY_INITIAL, reason=e))

        if finished:
            logger.info(messages.FINISHED)
            break

    return issues


@contextmanager
def _open_stream_packet_reader(
    password, stream_url, remote_path, excludes, last_position,
    ssl_ca_bundle, ssl_verify_certificates
):
    """
    :type password: str | unicode
    :type stream_url: str
    :type remote_path: str
    :type excludes: list[str | unicode] | None
    :type last_position: LastPosition
    :type ssl_ca_bundle: str | None
    :type ssl_verify_certificates: bool
    """
    http_data = _compose_http_data(password, remote_path, excludes, last_position)
    http_data_str = json.dumps(http_data)

    http_data_for_log = _compose_http_data("***hidden***", remote_path, excludes, last_position)
    http_data_for_log_str = json.dumps(http_data_for_log)

    logger.info(safe_format(messages.DOWNLOADING_FROM, url=stream_url, data=http_data_for_log_str))

    context = _create_ssl_context(ssl_ca_bundle, ssl_verify_certificates)

    https_handler = urllib2.HTTPSHandler(context=context)
    request = urllib2.Request(stream_url, data=http_data_str)
    opener = urllib2.build_opener(https_handler)

    with closing(opener.open(request)) as fp:
        yield PlainStreamPacketReader(fp)


def _compose_http_data(password, remote_path, excludes, last_position):
    """
    :type password: str | unicode
    :type remote_path: str
    :type excludes: list[str | unicode] | None
    :type last_position: LastPosition
    :rtype: dict
    """
    args = dict(
        directory=remote_path
    )
    if password is not None:
        args['password'] = password
    if excludes is not None and len(excludes) > 0:
        args['excludes'] = excludes
    if last_position.path is not None:
        args['start_path'] = last_position.path
    if last_position.position is not None:
        args['start_position'] = last_position.position
    return args


def _create_ssl_context(ssl_ca_bundle, ssl_verify_certificates):
    """
    :type ssl_ca_bundle: str | None
    :type ssl_verify_certificates: bool
    :rtype: ssl.SSLContext
    """
    if ssl_verify_certificates:
        context = ssl._create_unverified_context(
            cert_reqs=ssl.CERT_REQUIRED, cafile=ssl_ca_bundle, check_hostname=True
        )
    else:
        context = ssl._create_unverified_context()
    return context


class LastPosition(object):
    def __init__(self, path, position):
        self.path = path
        self.position = position

    def clone(self):
        return LastPosition(self.path, self.position)

    def __eq__(self, other):
        return isinstance(other, LastPosition) and self.path == other.path and self.position == other.position

    def __ne__(self, other):
        return not self.__eq__(other)
