# -*- coding: utf-8 -*-

from __future__ import print_function, absolute_import, unicode_literals
import ctypes
import ctypes.util
import logging
import os
import os.path
import platform
import signal
import subprocess
from builtins import str
from .exitcodes import RD_SUCCESS, RD_FAILED, RD_INCOMPLETE, \
    RD_SUBPROCESS_EXECUTE_FAILED
from .utils import ffmpeg_loglevel


logger = logging.getLogger('yledl')


class IOCapability(object):
    RESUME = 'resume'
    PROXY = 'proxy'
    RATELIMIT = 'ratelimit'
    DURATION = 'duration'


class PreferredFileExtension(object):
    def __init__(self, extension):
        assert extension.startswith('.')
        self.extension = extension
        self.is_mandatory = False


class MandatoryFileExtension(object):
    def __init__(self, extension):
        assert extension.startswith('.')
        self.extension = extension
        self.is_mandatory = True


### Base class for downloading a stream to a local file ###


class BaseDownloader(object):
    def __init__(self):
        self.io_capabilities = frozenset()
        self.error_message = None

    def is_valid(self):
        return True

    def warn_on_unsupported_feature(self, io):
        if io.proxy and IOCapability.PROXY not in self.io_capabilities:
            logger.warning('Proxy not supported on this stream. '
                           'Trying to continue anyway')

        if io.download_limits.ratelimit and \
           IOCapability.RATELIMIT not in self.io_capabilities:
            logger.warning('Rate limiting not supported on this stream')

        if io.download_limits.duration and \
           IOCapability.DURATION not in self.io_capabilities:
            logger.warning('--duration will be ignored on this stream')

        # IOCapability.RESUME will be checked later when we know if we
        # are trying to resume a partial download

    def warn_on_unsupported_resume(self, filename, clip, io):
        if (io.resume and
            IOCapability.RESUME not in self.io_capabilities and
            filename != '-' and
            os.path.isfile(filename)):
            logger.warning('Resume not supported on this stream')

    def file_extension(self, preferred):
        return PreferredFileExtension('.mp4')

    def save_stream(self, output_name, clip, io):
        """Deriving classes override this to perform the download"""
        raise NotImplementedError('save_stream must be overridden')

    def pipe(self, io):
        """Derived classes can override this to pipe to stdout"""
        return RD_FAILED

    def stream_url(self):
        """Derived classes can override this to return the URL of the stream"""
        return None

    def full_stream_already_downloaded(self, filename, clip, io):
        ffprobe = io.ffprobe()
        if not ffprobe or not os.path.exists(filename):
            return False

        logger.info('{} already exists.\n'
                    'Checking if the stream is complete...'
                    .format(filename))

        expected_duration = clip.duration_seconds
        if expected_duration is None or expected_duration <= 0:
            return False

        try:
            downloaded_duration = ffprobe.duration_seconds_file(filename)
        except ValueError as ex:
            logger.warning('Failed to get duration for the file'
                           '{}: {}'.format(filename, str(ex)))
            return False

        logger.debug('Downloaded duration {} s, expected {} s'.format(
            downloaded_duration, expected_duration))

        return downloaded_duration >= 0.98*expected_duration


### Base class for downloading a stream to a file using an external program ###


class ExternalDownloader(BaseDownloader):
    def save_stream(self, output_name, clip, io):
        self.warn_on_unsupported_resume(output_name, clip, io)

        env = self.extra_environment(io)
        args = self.build_args(output_name, clip, io)
        return self.external_downloader([args], env)

    def pipe(self, io):
        commands = [self.build_pipe_args(io)]
        env = self.extra_environment(io)
        return self.external_downloader(commands, env)

    def build_args(self, output_name, clip, io):
        raise NotImplementedError('build_args must be overridden')

    def build_pipe_args(self, io):
        raise NotImplementedError('build_pipe_args must be overridden')

    def extra_environment(self, io):
        return None

    def external_downloader(self, commands, env=None):
        return Subprocess().execute(commands, env)


class Subprocess(object):
    def execute(self, commands, extra_environment):
        """Start external processes connected with pipes and wait completion.

        commands is a list commands to execute. commands[i] is a list of shell
        command and arguments.

        extra_environment is a dict of environment variables that are combined
        with os.environ.
        """
        if not commands:
            return RD_SUCCESS

        logger.debug('Executing:')
        shell_command_string = ' | '.join(' '.join(args) for args in commands)
        logger.debug(shell_command_string)

        env = self.combine_envs(extra_environment)
        try:
            process = self.start_process(commands, env)
            return self.exit_code_to_rd(process.wait())
        except KeyboardInterrupt:
            try:
                os.kill(process.pid, signal.SIGINT)
                process.wait()
            except OSError:
                # The process died before we killed it.
                pass
            return RD_INCOMPLETE
        except OSError as exc:
            logger.error('Failed to execute ' + shell_command_string)
            logger.error(exc.strerror)
            return RD_SUBPROCESS_EXECUTE_FAILED

    def combine_envs(self, extra_environment):
        env = None
        if extra_environment:
            env = dict(os.environ)
            env.update(extra_environment)
        return env

    def start_process(self, commands, env):
        """Start all commands and setup pipes."""
        assert commands

        processes = []
        for i, args in enumerate(commands):
            if i == 0 and platform.system() != 'Windows':
                preexec_fn = self._sigterm_when_parent_dies
            else:
                preexec_fn = None

            stdin = processes[-1].stdout if processes else None
            stdout = None if i == len(commands) - 1 else subprocess.PIPE
            processes.append(subprocess.Popen(
                args, stdin=stdin, stdout=stdout,
                env=env, preexec_fn=preexec_fn))

        # Causes the first process to receive SIGPIPE if the seconds
        # process exists
        for p in processes[:-1]:
            p.stdout.close()

        return processes[0]

    def exit_code_to_rd(self, exit_code):
        return RD_SUCCESS if exit_code == 0 else RD_FAILED

    def _sigterm_when_parent_dies(self):
        PR_SET_PDEATHSIG = 1

        libcname = ctypes.util.find_library('c')
        libc = libcname and ctypes.CDLL(libcname)

        try:
            libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
        except AttributeError:
            # libc is None or libc does not contain prctl
            pass


### Download a HLS stream by delegating to ffmpeg ###


class HLSBackend(ExternalDownloader):
    def __init__(self, url, long_probe=False, program_id=0):
        ExternalDownloader.__init__(self)
        self.url = url
        self.long_probe = long_probe
        self.program_id = program_id
        self.io_capabilities = frozenset([IOCapability.DURATION])
        self.name = Backends.FFMPEG

    def file_extension(self, preferred):
        ext = preferred if preferred.startswith('.') else '.' + preferred
        return PreferredFileExtension(ext)

    def _duration_arg(self, download_limits):
        if download_limits.duration:
            return ['-t', str(download_limits.duration)]
        else:
            return []

    def _probe_args(self):
        if self.long_probe:
            return ['-probesize', '80000000']
        else:
            return []

    def _metadata_args(self, clip):
        if not clip:
            return []

        metadata = []
        if clip.description:
            metadata += ['-metadata', 'description=' + clip.description]

        if clip.publish_timestamp:
            metadata += ['-metadata',
                         'creation_time=' + clip.publish_timestamp.isoformat()]

        return metadata

    def _subtitle_args(self, io):
        if ((io.outputfilename and io.outputfilename.endswith('.mp4')) or
            io.preferred_format in ('mp4', '.mp4')):
            scodec = 'mov_text'
        else:
            scodec = 'srt'

        if io.embed_subtitles:
            return ['-scodec', scodec]
        else:
            return ['-sn']

    def _pipe_subtitle_args(self, io):
        if io.embed_subtitles:
            return ['-scodec', 'srt']
        else:
            return ['-sn']

    def build_args(self, output_name, clip, io):
        args = (['-bsf:a', 'aac_adtstoasc',
                 '-codec', 'copy',
                 '-map', '0:p:{}'.format(self.program_id),
                 '-dn'] +
                self._subtitle_args(io) +
                ['file:' + output_name])

        return self.ffmpeg_command_line(clip, io, args)

    def build_pipe_args(self, io):
        args = (['-codec', 'copy', '-acodec', 'aac',
                 '-map', '0:p:{}'.format(self.program_id),
                 '-dn'] +
                self._pipe_subtitle_args(io) +
                ['-f', 'matroska', 'pipe:1'])

        return self.ffmpeg_command_line(None, io, args)

    def pipe(self, io):
        commands = [self.build_pipe_args(io)]
        env = self.extra_environment(io)
        return self.external_downloader(commands, env)

    def ffmpeg_command_line(self, clip, io, output_options):
        args = [io.ffmpeg_binary, '-y',
                '-loglevel', ffmpeg_loglevel(logger.getEffectiveLevel()),
                '-thread_queue_size', '512',
                '-strict', 'experimental']  # For decoding webvtt subtitles
        if logger.getEffectiveLevel() <= logging.WARNING:
            args.append('-stats')
        args.extend(self._probe_args())
        args.extend(['-i', self.url])
        args.extend(self._duration_arg(io.download_limits))
        args.extend(self._metadata_args(clip))
        args.extend(output_options)
        return args

    def stream_url(self):
        return self.url


class HLSAudioBackend(HLSBackend):
    def __init__(self, url):
        HLSBackend.__init__(self, url, False)

    def file_extension(self, preferred):
        return MandatoryFileExtension('.mp3')

    def build_args(self, output_name, clip, io):
        return self.ffmpeg_command_line(
            clip, io,
            ['-map', '0:4?', '-acodec', 'copy',
             '-f', 'mp3', 'file:' + output_name])

    def build_pipe_args(self, io):
        return self.ffmpeg_command_line(
            None, io,
            ['-map', '0:4?', '-acodec', 'copy',
             '-f', 'mp3', 'pipe:1'])


### Download a plain HTTP file ###


class WgetBackend(ExternalDownloader):
    def __init__(self, url, file_extension):
        ExternalDownloader.__init__(self)
        self.url = url

        if not file_extension:
            logger.warning('Mandatory file extension is missing for URL {}'.format(url))
        self._file_extension = MandatoryFileExtension(file_extension or '')
        self.io_capabilities = frozenset([
            IOCapability.RESUME,
            IOCapability.RATELIMIT,
            IOCapability.PROXY
        ])
        self.name = Backends.WGET

    def file_extension(self, preferred):
        return self._file_extension

    def save_stream(self, output_name, clip, io):
        if clip.embedded_subtitles and io.embed_subtitles:
            logger.warning('The wget backend might not be able to download '
                           'subtitles, try --backend=ffmpeg')

        return super(WgetBackend, self).save_stream(output_name, clip, io)

    def build_args(self, output_name, clip, io):
        args = self.shared_wget_args(io.wget_binary, output_name)
        args.extend([
            '--progress=bar',
            '--tries=5',
            '--random-wait'
        ])
        if logger.getEffectiveLevel() > logging.INFO:
            args.append('--no-verbose')
        if logger.getEffectiveLevel() <= logging.WARNING:
            args.append('--show-progress')
        if io.resume:
            args.append('-c')
        if io.download_limits.ratelimit:
            args.append('--limit-rate={}k'.format(io.download_limits.ratelimit))
        args.append(self.url)
        return args

    def build_pipe_args(self, io):
        return self.shared_wget_args(io.wget_binary, '-') + [self.url]

    def shared_wget_args(self, wget_binary, output_filename):
        # Sometimes it seems to be necessary to spoof the user-agent,
        # see the issue #206
        spoofed_user_agent = (
            'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:67.0) '
            'Gecko/20100101 Firefox/67.0')

        return [
            wget_binary,
            '-O', output_filename,
            '--no-use-server-timestamps',
            '--user-agent=' + spoofed_user_agent,
            '--timeout=20'
        ]

    def extra_environment(self, io):
        env = None
        if io.proxy:
            if 'https_proxy' in os.environ:
                logger.warning('--proxy ignored because https_proxy environment variable exists')
            else:
                env = {'https_proxy': io.proxy}
        return env

    def stream_url(self):
        return self.url


### Backend representing a failed stream ###


class FailingBackend(BaseDownloader):
    def __init__(self, error_message):
        BaseDownloader.__init__(self)
        self.error_message = error_message
        self.name = None

    def is_valid(self):
        return False

    def save_stream(self, output_name, clip, io):
        logger.error(self.error_message)
        return RD_FAILED

    def pipe(self, io):
        logger.error(self.error_message)
        return RD_FAILED


class Backends(object):
    FFMPEG = 'ffmpeg'
    WGET = 'wget'

    default_order = [
        WGET,
        FFMPEG,
    ]

    @staticmethod
    def is_valid_backend(backend_name):
        return backend_name in Backends.default_order

    @staticmethod
    def parse_backends(backend_names):
        backends = []
        for bn in backend_names:
            if not Backends.is_valid_backend(bn):
                logger.warning('Invalid backend: ' + bn)
                continue

            if bn not in backends:
                backends.append(bn)

        return backends
