# -*- coding: UTF-8 -*-
"""
Name: utils.py
Porpose: It groups useful functions that are called several times
Compatibility: Python3, wxPython Phoenix
Author: Gianluca Pernigotto <jeanlucperni@gmail.com>
Copyleft - 2025 Gianluca Pernigotto <jeanlucperni@gmail.com>
license: GPL3
Rev: Feb.05.2024
Code checker: flake8, pylint .

This file is part of Vidtuber.

   Vidtuber is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.

   Vidtuber 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 General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with Vidtuber.  If not, see <http://www.gnu.org/licenses/>.
"""
import subprocess
import platform
import re
import shutil
import os
import glob
import math


class Popen(subprocess.Popen):
    """
    Inherit subprocess.Popen class to set _startupinfo.
    This avoids displaying a console window on MS-Windows
    using GUI's .

    NOTE MS Windows:

    subprocess.STARTUPINFO()

    https://stackoverflow.com/questions/1813872/running-
    a-process-in-pythonw-with-popen-without-a-console?lq=1>

    """
    if platform.system() == 'Windows':
        _startupinfo = subprocess.STARTUPINFO()
        _startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
    else:
        _startupinfo = None

    def __init__(self, *args, **kwargs):
        """Constructor
        """
        super().__init__(*args, **kwargs, startupinfo=self._startupinfo)

    # def communicate_or_kill(self, *args, **kwargs):
        # return process_communicate_or_kill(self, *args, **kwargs)
# ------------------------------------------------------------------------


def open_default_application(pathname):
    """
    Given a path to a specific file or directory, opens the
    operating system's default application according to the
    user-set file type association. Currently supported platforms
    are Windows, Darwin and Linux. Note that Linux uses xdg-open
    which should also be used by other OSes that may support
    it, eg freebsd.

    Return error if any error, None otherwise.
    """
    if platform.system() == 'Windows':
        try:
            os.startfile(os.path.realpath(pathname))
        except FileNotFoundError as error:
            return str(error)

        return None

    if platform.system() == "Darwin":
        cmd = ['open', pathname]
    else:  # Linux, FreeBSD or any supported
        cmd = ['xdg-open', pathname]
    try:
        subprocess.run(cmd, check=True, shell=False, encoding='utf-8')
    except subprocess.CalledProcessError as error:
        return str(error)

    return None
# ------------------------------------------------------------------------


def format_bytes(num):
    """
    Given a float number (bytes) returns size output
    strings human readable, e.g.
    out = format_bytes(9909043.20)
    It return a string digit with metric suffix

    """
    unit = ["B", "KiB", "MiB", "GiB", "TiB",
            "PiB", "EiB", "ZiB", "YiB"]
    const = 1024.0
    if num == 0.0:  # if 0.0 or 0 raise ValueError: math domain error
        exponent = 0
    else:
        exponent = int(math.log(num, const))  # get unit index

    suffix = unit[exponent]  # unit index
    output_value = num / (const ** exponent)

    # return "%.2f%s" % (output_value, suffix)
    return f"{output_value:.2f}{suffix}"
# ------------------------------------------------------------------------


def to_bytes(string, key='ydl'):
    """
    Convert given size string to bytes, e.g.
    out = to_bytes('9.45MiB')
    It return a number 'float' object.
    Updated on March 23 2022:
        added key default arg.

    """
    value = 0.0
    unit = ["byte", "Kibyte", "Mibyte", "Gibyte", "Tibyte",
            "Pibyte", "Eibyte", "Zibyte", "Yibyte"]

    if key == 'ydl':
        unit = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]

    const = 1024.0

    for index, metric in enumerate(reversed(unit)):
        if metric in string:
            value = float(string.split(metric)[0])
            exponent = index * (-1) + (len(unit) - 1)
            break

    return round(value * (const ** exponent), 2)
# ------------------------------------------------------------------------


def time_to_integer(timef: str = '0', sec=False, rnd=False) -> int:
    """
    Converts strings representing the 24-hour format to an
    integer equivalent to the time duration in milliseconds
    (default).

    ARGUMENTS:
    ---------
    timef: Accepts a string representing the 24-hour clock in the
           following valid string formats:

            '0' | '00' | '00.0' | '00.00' | 00:0 | '00:00:00'
            '0:00:00' | '0:0:0' | '00:00.000' | '00:00:00.000

           Default is '0' .
           Any other representation passed as a string argument will
           be returned as an integer object equal to 0 (int).
           If the argument passed is an object other than the string
           object (str) the exception `TypeError` will be raised.

    sec: if `True`, returns the duration in seconds instead
         of milliseconds.

    rnd: if `True` rounds the result, i.e. "00:00:02.999" > 3000
         milliseconds instead of 2999 milliseconds or 3 seconds
         instead of 2 seconds using `sec=True` argument.

    Return an int object.
    Raise `TypeError` if argument != `str` .

    """
    if not isinstance(timef, str):
        raise TypeError("Only a string (str) object is expected.")

    # adds leading zeros to fill up possibly non-existing fields.
    hours, minutes, seconds = (["0", "0"] + timef.split(":"))[-3:]

    try:
        hours = int(hours)
        minutes = int(minutes)
        seconds = float(seconds)
        if rnd:
            seconds = round(seconds)
    except ValueError:
        return 0

    if sec:
        return int(hours * 3600 + minutes * 60 + seconds)
    return int(hours * 3600000 + minutes * 60000 + seconds * 1000)
# ------------------------------------------------------------------------


def integer_to_time(integer: int = 0, mills=True, rnd=False) -> str:
    """
    Converts integers to 24-hour clock format calculating in
    sexagesimal format. Accept an `int` object, such as 2998.
    Float numbers, such as 2000.999, must be rounded using
    `round()` function first.

    ARGUMENTS:
    ---------
    integer: Any int object, default is 0.

    mills: Include milliseconds like "HH:MM:SS.MIL" (default).
           Milliseconds will be excluded and ignored if set
           to `False`. You could instead use the `rnd` argument
           to round them and add the offset to the seconds.

    rnd: If `True` rounds the result. This argument will have
         no effect with `mills=True` default argument.

    Returns a string object.
    Raise `TypeError` if argument != `int` .

    """
    if not isinstance(integer, int):
        raise TypeError("An integer (int) object is expected.")

    minutes, sec = divmod(integer, 60000)
    hours, minutes = divmod(minutes, 60)

    if mills:
        seconds = float(sec) / 1000
        # return "%02d:%02d:%06.3f" % (hours, minutes, seconds)
        return f"{hours:02}:{minutes:02}:{seconds:06.3f}"

    if rnd:
        seconds = round(sec / 1000)
    else:
        seconds = int(sec / 1000)

    return f"{hours:02}:{minutes:02}:{seconds:02}"
# ------------------------------------------------------------------------


def copy_missing_data(srcd, destd):
    """
    Copy missing files and directories to a given destination
    path using the same names as the source path.

    """
    srclist = os.listdir(srcd)
    destlist = os.listdir(destd)
    for f in srclist:
        if f not in destlist:
            if os.path.isfile(os.path.join(srcd, f)):
                copy_restore(os.path.join(srcd, f), os.path.join(destd, f))
            elif os.path.isdir(os.path.join(srcd, f)):
                copydir_recursively(os.path.join(srcd, f), destd)
# ------------------------------------------------------------------------


def copy_restore(srcfile, destfile):
    """
    Copy the contents (no metadata) of the file named
    srcfile to a file named destfile. Please visit doc webpage at
    <https://docs.python.org/3/library/shutil.html#shutil.copyfile>

    """
    try:
        shutil.copyfile(str(srcfile), str(destfile))
    except FileNotFoundError as err:
        # file srcfile not exists
        return str(err)
    except shutil.SameFileError as err:
        # srcfile and destfile are the same file and same dir.
        return str(err)
    except OSError as err:
        # The destfile location must be writable
        return str(err)

    return None
# ------------------------------------------------------------------#


def copydir_recursively(srcdir, destdir, extraname=None):
    """
    recursively copies an entire directory tree rooted
    at `srcdir`. If you do not provide the `extraname`
    argument, the destination will have the same name as
    the file in srcdir, otherwise `extraname` is assumed
    as the final name.

    """
    if extraname:
        dest = os.path.join(destdir, extraname)
    else:
        dest = os.path.join(destdir, os.path.basename(srcdir))
    try:
        shutil.copytree(str(srcdir), str(dest))

    except FileExistsError as err:  # dest dir already exists
        return err
    except FileNotFoundError as err:  # srcdir not exists
        return err

    return None
# ------------------------------------------------------------------#


def copy_on(ext, sourcedir, destdir, overw=True):
    """
    This function copy file based on given extension (`ext`).
    It use `glob` module to finds in the `sourcedir` pathname
    all the files matching a specified `ext`.
    If default "overw" argument is `False`, it does not
    overwrite existing file names in the destination path.

    Returns None on success, returns the error otherwise.

    ARGUMENTS:
    ext: files extension without dot
    sourcedir: path to the source directory
    destdir: path to the destination directory
    overw: `True`, overwrite file destination

    """
    destpath = os.listdir(destdir)
    files = glob.glob(f"{sourcedir}/*.{ext}")
    if not files:
        return f'ERROR: No files found in this format: ".{ext}"'
    for fln in files:
        if not overw:
            if os.path.basename(fln) in destpath:
                continue
        try:
            shutil.copy(fln, f'{destdir}')
        except IOError as error:
            # problems with permissions
            return error
    return None
# ------------------------------------------------------------------#


def del_filecontents(filename):
    """
    Delete the contents of the file if it is not empty.
    Please be careful as it assumes the file exists.

    HOW to USE:

        if fileExists:
            try:
                del_filecontents(logfile)
            except Exception as err:
                print("Unexpected error while deleting "
                      "file contents:\n\n{0}").format(err)

    MODE EXAMPLE SCHEME:

    |          Mode          |  r   |  r+  |  w   |  w+  |  a   |  a+  |
    | :--------------------: | :--: | :--: | :--: | :--: | :--: | :--: |
    |          Read          |  +   |  +   |      |  +   |      |  +   |
    |         Write          |      |  +   |  +   |  +   |  +   |  +   |
    |         Create         |      |      |  +   |  +   |  +   |  +   |
    |         Cover          |      |      |  +   |  +   |      |      |
    | Point in the beginning |  +   |  +   |  +   |  +   |      |      |
    |    Point in the end    |      |      |      |      |  +   |  +   |

    """
    with open(filename, "r+", encoding='utf-8') as fname:
        content = fname.read()
        if content:
            fname.flush()  # clear previous content readed
            fname.seek(0)  # it places the file pointer to position 0
            fname.write("")
            fname.truncate()  # truncates the file to the current file point.
# ------------------------------------------------------------------#


def trailing_name_with_prog_digit(destpath, argname) -> str:
    """
    Returns a new name with the same name as `argname`
    but adds a trailing progressive digit to the new name
    starting from `01`. If the new name already exists, the
    progressive digit will start from `02`, and so on.
    This function is useful for renaming both files and
    directories as it uses a filter to file system sanitize
    to removing illegal chars for some OS like:

            ^` ~ " # ' % & * : < > ? / \\ { | }.
    Args:
    -----
    destpath (str): destination path as a string object.
    argname (str): It expects an item name as a string object.

    Raise `TypeError` if args not type `str`
    Returns str(newdirname)
    """
    if not isinstance(destpath, str) or not isinstance(argname, str):
        raise TypeError("Only a string (str) object is expected.")

    name, ext = os.path.splitext(argname)
    name = re.sub(r"[\'\^\`\~\"\#\'\%\&\*\:\<\>\?\/\\\{\|\}]", '', name)
    name = name.strip().strip('.')  # removes lead./trail. spaces and dots
    ext = ext.strip()  # removes lead./trail. spaces
    alreadynumbered = []

    for items in os.listdir(destpath):
        match = items.rsplit(sep=' - ', maxsplit=1)
        if len(match) == 2:
            if match[0] == name and match[1].strip().isdigit():
                alreadynumbered.append(int(match[1]))  # append digit as int

    if alreadynumbered:
        prog = max(alreadynumbered) + 1
        newdirname = os.path.join(destpath,
                                  f'{name} - {str(prog).zfill(2)}' + ext)
    else:
        newdirname = os.path.join(destpath, f'{name} - 01' + ext)

    return newdirname
# ------------------------------------------------------------------#


def leading_name_with_prog_digit(destpath, argname) -> str:
    """
    Returns a new name with the same name as `argname`
    but adds a leading progressive digit to the new name
    starting from `01`. If the new name already exists, the
    progressive digit will start from `02`, and so on.
    This function is useful for renaming both files and
    directories as it uses a filter to file system sanitize
    to removing illegal chars for some OS like:

            ^` ~ " # ' % & * : < > ? / \\ { | }.
    Args:
    -----
    destpath (str): destination path as a string object.
    argname (str): It expects an item name as a string object.

    Raise `TypeError` if args not type `str`
    Returns str(newdirname)
    """
    if not isinstance(destpath, str) or not isinstance(argname, str):
        raise TypeError("Only a string (str) object is expected.")

    name, ext = os.path.splitext(argname)
    name = re.sub(r"[\'\^\`\~\"\#\'\%\&\*\:\<\>\?\/\\\{\|\}]", '', name)
    name = name.strip().strip('.')  # removes lead./trail. spaces and dots
    ext = ext.strip()  # removes lead./trail. spaces
    alreadynumbered = []

    for items in os.listdir(destpath):
        match = items.split(sep=' - ', maxsplit=1)
        if len(match) == 2:
            if match[1] == name and match[0].strip().isdigit():
                alreadynumbered.append(int(match[0]))

    if alreadynumbered:
        prog = max(alreadynumbered) + 1
        newdirname = os.path.join(destpath,
                                  f'{str(prog).zfill(2)} - {name}' + ext)
    else:
        newdirname = os.path.join(destpath, f'01 - {name}' + ext)

    return newdirname
# ------------------------------------------------------------------#


def clockset(duration, fileclock):
    """
    Evaluate the consistency between overall tempo values on
    different media that have the same name but different
    contents and a possible time position previously saved to
    file. Returns a clock object of type dict conforming to the
    referenced media file.
    """
    duration = duration.split('.')[0]
    millis = time_to_integer(duration)
    if not millis:
        clock = {'duration': '00:00:00', 'millis': 0}
    else:
        if os.path.exists(fileclock):
            with open(fileclock, "r", encoding='utf-8') as atime:
                clockread = atime.read().strip()
                if time_to_integer(clockread) <= millis:
                    clock = {'duration': clockread, 'millis': millis}
                else:
                    clock = {'duration': duration, 'millis': millis}
        else:
            clock = {'duration': '00:00:00', 'millis': millis}

    return clock
# ------------------------------------------------------------------#


def update_timeseq_duration(time_seq, duration):
    """
    Return an updated dict with time/duration
    """
    if time_seq:
        ms = time_to_integer(time_seq.split()[3])  # -t duration
        splseq = time_seq.split()
        tseq = f'{splseq[0]} {splseq[1]}', f'{splseq[2]} {splseq[3]}'
        dur = [ms for n in duration]
        duration = dur
        timestart = tseq[0]
        endtime = tseq[1]
    else:
        timestart, endtime = '', ''

    return duration, timestart, endtime
# ------------------------------------------------------------------#


def detect_binaries(name, extradir=None):
    """
    <https://stackoverflow.com/questions/11210104/check-if
    -a-program-exists-from-a-python-script>

    name = name of executable without extension
    extradir = additional dirname to perform search

    Given an executable (binary) file name, it looks for it
    in the operating system using the `which` function, if it
    doesn't find it, it tries to look for it in the optional
    `extradir` .

        Return (None, path) if found in the OS $PATH.
        Return ('provided', path) if found on the `extradir`
        Return ('not installed', None) if both failed.

    """
    execpath = shutil.which(name)
    if execpath:
        return None, execpath
    if extradir:  # check onto extradir
        execpath = os.path.join(extradir, "bin", name)
        if os.path.isfile(execpath):
            return 'provided', execpath
    return 'not installed', None
