#!/opt/local/bin/python313
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019-2025 A S Lewis
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation; either version 2.1 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.


"""Info operation classes."""


# Import Gtk modules
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import GObject


# Import other modules
import os
import queue
import re
import requests
import signal
import subprocess
import threading
import time


# Import our modules
import __main__
import downloads
import ttutils
# Use same gettext translations
from mainapp import _


# Classes


class InfoManager(threading.Thread):

    """Called by mainapp.TartubeApp.info_manager_start().

    Python class to create a system child process, to do one of three jobs:

    1. Fetch a list of available formats for a video, directly from youtube-dl

    2. Fetch a list of available subtitles for a video, directly from
        youtube-dl

    3. Test youtube-dl with specified download options; everything is
        downloaded into a temporary directory

    4. Check the Tartube website, and inform the user if a new release is
        available

    Reads from the child process STDOUT and STDERR, having set up a
    downloads.PipeReader object to do so in an asynchronous way.

    Args:

        app_obj (mainapp.TartubeApp): The main application

        info_type (str): The type of information to fetch: 'formats' for a list
            of video formats, 'subs' for a list of subtitles, or 'test_ytdl'
            to test youtube-dl with specified options, 'version' to check for a
            new release of Tartube

        media_data_obj (media.Video): For 'formats' and 'subs', the media.Video
            object for which formats/subtitles should be fetched. For
            'test_ytdl', set to None

        url_string (str): For 'test_ytdl', the video URL to download (can be
            None or an empty string, if no download is required, for example
            'youtube-dl --version'. For 'formats' and 'subs', set to None

        options_string (str): For 'test_ytdl', a string containing one or more
            youtube-dl download options. The string, generated by a
            Gtk.TextView, typically contains newline and/or multiple whitespace
            characters; the info.InfoManager code deals with that. Can be None
            or an empty string, if no download options are required. For
            'formats' and 'subs', set to None

    """


    # Standard class methods


    def __init__(self, app_obj, info_type, media_data_obj, url_string,
    options_string):

        super(InfoManager, self).__init__()

        # IV list - class objects
        # -----------------------
        # The mainapp.TartubeApp object
        self.app_obj = app_obj
        # The video for which information will be fetched (None if
        #   self.info_type is 'test_ytdl')
        self.video_obj = media_data_obj

        # The child process created by self.run()
        self.child_process = None

        # Read from the child process STDOUT (i.e. self.child_process.stdout)
        #   and STDERR (i.e. self.child_process.stderr) in an asynchronous way
        #   by polling this queue.PriorityQueue object
        self.queue = queue.PriorityQueue()
        self.stdout_reader = downloads.PipeReader(self.queue, 'stdout')
        self.stderr_reader = downloads.PipeReader(self.queue, 'stderr')


        # IV list - other
        # ---------------
        # The time (in seconds) between iterations of the loop in self.run()
        self.sleep_time = 0.1

        # The type of information to fetch: 'formats' for a list of video
        #   formats, 'subs' for a list of subtitles, 'test_ytdl' to test
        #   youtube-dl with specified options, or 'version' to check for a new
        #   release of Tartube
        self.info_type = info_type
        # For 'test_ytdl', the video URL to download (can be None or an empty
        #   string, if no download is required, for example
        #   'youtube-dl --version'. For 'formats' and 'subs', set to None
        self.url_string = url_string
        # For 'test_ytdl', a string containing one or more youtube-dl download
        #   options. The string, generated by a Gtk.TextView, typically
        #   contains newline and/or multiple whitespace characters; the
        #   info.InfoManager code deals with that. Can be None or an empty
        #   string, if no download options are required. For 'formats' and
        #   'subs', set to None
        self.options_string = options_string
        # For 'version', the version numbers (e.g. 1.2.003) retrieved from the
        #   main website (representing a stable release), and from github
        #   (representing a development release)
        self.stable_version = None
        self.dev_version = None

        # Flag set to True if the info operation succeeds, False if it fails
        self.success_flag = False

        # The list of formats/subtitles extracted from STDOUT
        self.output_list = []

        # (For debugging purposes, store any STDOUT/STDERR messages received;
        #   otherwise we would just set a flag if a STDERR message was
        #   received)
        self.stdout_list = []
        self.stderr_list = []


        # Code
        # ----

        # Let's get this party started!
        self.start()


    # Public class methods


    def run(self):

        """Called as a result of self.__init__().

        Creates a child process to run the youtube-dl system command.

        Reads from the child process STDOUT and STDERR, and calls the main
        application with the result of the process (success or failure).
        """

        # Checking for a new release of Tartube doesn't involve any system
        #   commands or child processes, so it is handled by a separate
        #   function
        if self.info_type == 'version':

            return self.run_check_version()

        # Show information about the info operation in the Output tab
        if self.info_type == 'test_ytdl':

            msg = _(
                'Starting info operation, testing downloader with specified' \
                + ' options',
            )

        else:

            if self.info_type == 'formats':

                msg = _(
                    'Starting info operation, fetching list of video/audio'\
                    + ' formats for \'{0}\'',
                ).format(self.video_obj.name)

            else:

                msg = _(
                    'Starting info operation, fetching list of subtitles'\
                    + ' for \'{0}\'',
                ).format(self.video_obj.name)

        self.app_obj.main_win_obj.output_tab_write_stdout(1, msg)

        # Convert a path beginning with ~ (not on MS Windows)
        ytdl_path = self.app_obj.check_downloader(self.app_obj.ytdl_path)
        if os.name != 'nt':
            ytdl_path = re.sub(r'^\~', os.path.expanduser('~'), ytdl_path)

        # Prepare the system command...
        if self.info_type == 'formats':

            cmd_list = [
                ytdl_path,
                '--list-formats',
                self.video_obj.source,
            ]

        elif self.info_type == 'subs':

            cmd_list = [
                ytdl_path,
                '--list-subs',
                self.video_obj.source,
            ]

        else:

            if self.app_obj.ytdl_path_custom_flag:
                cmd_list = ['python3'] + [ytdl_path]
            else:
                cmd_list = [ytdl_path]

            if self.options_string is not None \
            and self.options_string != '':

                # Parse the string into a list. It was obtained from a
                #   Gtk.TextView, so it can contain newline and/or multiple
                #   whitepsace characters. Whitespace characters within
                #   double quotes "..." must be preserved
                option_list = ttutils.parse_options(self.options_string)
                for item in option_list:
                    cmd_list.append(item)

            if self.url_string is not None \
            and self.url_string != '':

                cmd_list.append('-o')
                cmd_list.append(
                    os.path.join(
                        self.app_obj.temp_test_dir,
                        '%(title)s.%(ext)s',
                    ),
                )

                cmd_list.append(self.url_string)

        # ...display it in the Output tab (if required)
        space = ' '
        self.app_obj.main_win_obj.output_tab_write_system_cmd(
            1,
            space.join(cmd_list),
        )

        # Create a new child process using that command...
        self.create_child_process(cmd_list)
        # ...and set up the PipeReader objects to read from the child process
        #   STDOUT and STDERR
        if self.child_process is not None:
            self.stdout_reader.attach_fh(self.child_process.stdout)
            self.stderr_reader.attach_fh(self.child_process.stderr)

        while self.is_child_process_alive():

            # Pause a moment between each iteration of the loop (we don't want
            #   to hog system resources)
            time.sleep(self.sleep_time)

            # Read from the child process STDOUT and STDERR, in the correct
            #   order, until there is nothing left to read
            while self.read_child_process():
                pass

        # (Generate our own error messages for debugging purposes, in certain
        #   situations)
        if self.child_process is None:

            msg = _('System process did not start')
            self.stderr_list.append(msg)
            self.app_obj.main_win_obj.output_tab_write_stdout(
                1,
                msg,
            )

        elif self.child_process.returncode > 0:

            msg = _('Child process exited with non-zero code: {}').format(
                self.child_process.returncode,
            )
            self.app_obj.main_win_obj.output_tab_write_stdout(
                1,
                msg,
            )

        # Operation complete. self.success_flag is checked by
        #   mainapp.TartubeApp.info_manager_finished()
        if not self.stderr_list:
            self.success_flag = True

        # Show a confirmation in the the Output tab
        self.app_obj.main_win_obj.output_tab_write_stdout(
            1,
            _('Info operation finished'),
        )

        # Let the timer run for a few more seconds to prevent Gtk errors
        GObject.timeout_add(
            0,
            self.app_obj.info_manager_halt_timer,
        )


    def run_check_version(self):

        """Called by self.run().

        Checking for a new release of Tartube doesn't involve any system
        commands or child processes, so it is handled separately by this
        function.

        There is a stable release at Sourceforge, and a development release at
        Github. Fetch the VERSION file from each, and store the stable/
        development versions, so that mainapp.TartubeApp.info_manager_finished
        can display them.
        """

        # Show information about the info operation in the Output tab
        self.app_obj.main_win_obj.output_tab_write_stdout(
            1,
            _('Starting info operation, checking for new releases of Tartube'),
        )

        # Check the stable version, http://tartube.sourceforge.io/VERSION
        stable_path = __main__.__website__ + '/VERSION'

        self.app_obj.main_win_obj.output_tab_write_stdout(
            1,
            _('Checking stable release...'),
        )

        self.app_obj.main_win_obj.output_tab_write_system_cmd(1, stable_path)

        try:
            request_obj = requests.get(
                stable_path,
                timeout = self.app_obj.request_get_timeout,
            )

            response = ttutils.strip_whitespace(request_obj.text)
            if not re.search(r'^\d+\.\d+\.\d+\s*$', response):

                self.app_obj.main_win_obj.output_tab_write_stdout(
                    1,
                    _('Ignoring invalid version'),
                )

            else:

                self.stable_version = response

                self.app_obj.main_win_obj.output_tab_write_stdout(
                    1,
                    _('Retrieved version:') + ' ' + str(response),
                )

        except:

            self.app_obj.main_win_obj.output_tab_write_stdout(
                1,
                _('Connection failed'),
            )

        # Check the development version,
        #   http://raw.githubusercontent.com/axcore/tartube/master/VERSION
        dev_path = __main__.__website_dev__ + '/VERSION'

        self.app_obj.main_win_obj.output_tab_write_stdout(
            1,
            _('Checking development release...'),
        )

        self.app_obj.main_win_obj.output_tab_write_system_cmd(1, dev_path)

        try:
            request_obj = requests.get(
                dev_path,
                timeout = self.app_obj.request_get_timeout,
            )

            response = ttutils.strip_whitespace(request_obj.text)
            if not re.search(r'^\d+\.\d+\.\d+\s*$', response):

                self.app_obj.main_win_obj.output_tab_write_stdout(
                    1,
                    _('Ignoring invalid version'),
                )

            else:

                self.dev_version = response

                self.app_obj.main_win_obj.output_tab_write_stdout(
                    1,
                    _('Retrieved version:') + ' ' + str(response),
                )

        except:

            self.app_obj.main_win_obj.output_tab_write_stdout(
                1,
                _('Connection failed'),
            )

        # Operation complete. self.success_flag is checked by
        #   mainapp.TartubeApp.info_manager_finished()
        self.success_flag = True

        # Show a confirmation in the the Output tab
        self.app_obj.main_win_obj.output_tab_write_stdout(
            1,
            _('Info operation finished'),
        )

        # Let the timer run for a few more seconds to prevent Gtk errors
        GObject.timeout_add(
            0,
            self.app_obj.info_manager_halt_timer,
        )


    def create_child_process(self, cmd_list):

        """Called by self.run().

        Based on code from downloads.VideoDownloader.create_child_process().

        Executes the system command, creating a new child process which
        executes youtube-dl.

        Args:

            cmd_list (list): Python list that contains the command to execute

        """

        # Strip double quotes from arguments
        # (Since we're sending the system command one argument at a time, we
        #   don't need to retain the double quotes around any single argument
        #   and, in fact, doing so would cause an error)
        cmd_list = ttutils.strip_double_quotes(cmd_list)

        # Create the child process
        info = preexec = None

        if os.name == 'nt':
            # Hide the child process window that MS Windows helpfully creates
            #   for us
            info = subprocess.STARTUPINFO()
            info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        else:
            # Make this child process the process group leader, so that we can
            #   later kill the whole process group with os.killpg
            preexec = os.setsid

        try:
            self.child_process = subprocess.Popen(
                cmd_list,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                preexec_fn=preexec,
                startupinfo=info,
            )

        except (ValueError, OSError) as error:
            # (The code in self.run() will spot that the child process did not
            #   start)
            self.stderr_list.append(_('Child process did not start'))


    def is_child_process_alive(self):

        """Called by self.run() and .stop_info_operation().

        Based on code from downloads.VideoDownloader.is_child_process_alive().

        Called continuously during the self.run() loop to check whether the
        child process has finished or not.

        Return values:

            True if the child process is alive, otherwise returns False.

        """

        if self.child_process is None:
            return False

        return self.child_process.poll() is None


    def read_child_process(self):

        """Called by self.run().

        Reads from the child process STDOUT and STDERR, in the correct order.

        Return values:

            True if either STDOUT or STDERR were read, None if both queues were
                empty

        """

        # mini_list is in the form [time, pipe_type, data]
        try:
            mini_list = self.queue.get_nowait()

        except:
            # Nothing left to read
            return None

        # Failsafe check
        if not mini_list \
        or (mini_list[1] != 'stdout' and mini_list[1] != 'stderr'):

            # Just in case...
            GObject.timeout_add(
                0,
                self.app_obj.system_error,
                601,
                'Malformed STDOUT or STDERR data',
            )

        # STDOUT or STDERR has been read
        data = mini_list[2].rstrip()
        # On MS Windows we use cp1252, so that Tartube can communicate with the
        #   Windows console
        data = data.decode(ttutils.get_encoding(), 'replace')

        # STDOUT
        if mini_list[1] == 'stdout':

            self.output_list.append(data)
            self.stdout_list.append(data)

            # Show command line output in the Output tab
            self.app_obj.main_win_obj.output_tab_write_stdout(
                1,
                data,
            )

        # STDERR (ignoring any empty error messages)
        elif data != '':

            # While testing youtube-dl, don't treat anything as an error
            if self.info_type == 'test_ytdl':
                self.stdout_list.append(data)

            # When fetching subtitles from a video that has none, don't treat
            #   youtube-dl WARNING: messages as something that makes the info
            #   operation fail
            elif self.info_type == 'subs':

                if not re.search(r'^WARNING\:', data):
                    self.stderr_list.append(data)

            # When fetching formats, recognise all warnings as errors
            else:
                self.stderr_list.append(data)

            # Show command line output in the Output tab
            self.app_obj.main_win_obj.output_tab_write_stderr(
                1,
                data,
            )

        # Either (or both) of STDOUT and STDERR were non-empty
        self.queue.task_done()
        return True


    def stop_info_operation(self):

        """Called by mainapp.TartubeApp.do_shutdown(), .stop_continue(),
        .on_button_stop_operation() and mainwin.MainWin.on_stop_menu_item().

        Based on code from downloads.VideoDownloader.stop().

        Terminates the child process.
        """

        if self.is_child_process_alive():

            if os.name == 'nt':
                # os.killpg is not available on MS Windows (see
                #   https://bugs.python.org/issue5115 )
                self.child_process.kill()

                # When we kill the child process on MS Windows the return code
                #   gets set to 1, so we want to reset the return code back to
                #   0
                self.child_process.returncode = 0

            else:
                os.killpg(self.child_process.pid, signal.SIGKILL)
