# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
# SPDX-License-Identifier: GPL-3.0-or-later
# Author: Valéry Febvre <vfebvre@easter-eggs.com>

import datetime
from functools import cache
from functools import cached_property
from functools import wraps
from gettext import gettext as _
import html
from io import BytesIO
import logging
import os
import subprocess
import traceback

import gi
from PIL import Image
import magic
import requests
from requests.adapters import HTTPAdapter
from requests.adapters import TimeoutSauce
from urllib3.util.retry import Retry

gi.require_version('Gdk', '4.0')
gi.require_version('Gsk', '4.0')
gi.require_version('Gtk', '4.0')
gi.require_version('GdkPixbuf', '2.0')
gi.require_version('Graphene', '1.0')

from gi.repository import Gdk
from gi.repository import Gio
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Graphene
from gi.repository import Gsk
from gi.repository import Gtk
from gi.repository.GdkPixbuf import PixbufAnimation

COVER_WIDTH = 180
COVER_HEIGHT = 256
LOGO_SIZE = 32
MISSING_IMG_RESOURCE_PATH = '/info/febvre/Komikku/images/missing_file.png'
REQUESTS_TIMEOUT = 5

logger = logging.getLogger('komikku')
logging.getLogger('PIL.Image').propagate = False
logging.getLogger('PIL.PngImagePlugin').propagate = False
logging.getLogger('PIL.TiffImagePlugin').propagate = False


def check_cmdline_tool(cmd):
    try:
        p = subprocess.Popen(cmd, bufsize=0, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
        out, _ = p.communicate()
    except Exception:
        return False, None
    else:
        return p.returncode == 0, out.decode('utf-8').strip()


def convert_and_resize_image(buffer, width, height, keep_aspect_ratio=True, dominant_color=True, format='JPEG'):
    """Convert and resize an image (except animated GIF)

    :param keep_aspect_ratio: Force to keep aspect ratio of the image
    :type keep_aspect_ratio: bool

    :param dominant_color: If scaling is not allowed, use image dominant color as background color of added borders
    :type dominant_color: bool

    :return: Converted image data
    :rtype: bytes
    """

    def get_dominant_color(img):
        # Resize image to reduce number of colors
        colors = img.resize((150, 150), resample=0).getcolors(150 * 150)
        sorted_colors = sorted(colors, key=lambda t: t[0])

        return sorted_colors[-1][1]

    def remove_alpha(img):
        if img.mode not in ('P', 'RGBA'):
            return img

        img = img.convert('RGBA')
        background = Image.new('RGBA', img.size, (255, 255, 255))

        return Image.alpha_composite(background, img)

    try:
        img = Image.open(BytesIO(buffer))
    except Exception as exc:
        logger.error('Failed to open image (Pillow)', exc_info=exc)
        return None

    if img.format == 'GIF' and img.is_animated:
        return buffer

    old_width, old_height = img.size
    if keep_aspect_ratio and old_width >= old_height:
        img = remove_alpha(img)

        new_ratio = height / width

        new_img = Image.new(img.mode, (old_width, int(old_width * new_ratio)), get_dominant_color(img))
        new_img.paste(img, (0, (int(old_width * new_ratio) - old_height) // 2))
        new_img.thumbnail((width, height), Image.LANCZOS)
    else:
        new_img = img.resize((width, height), Image.LANCZOS)

    new_buffer = BytesIO()
    if format == 'JPEG':
        new_img.convert('RGB').save(new_buffer, 'JPEG', quality=90)
    else:
        # Assume format supports alpha channel (transparency)
        new_img.convert('RGBA').save(new_buffer, format)
    new_img.close()

    return new_buffer.getvalue()


def folder_size(path):
    if not os.path.exists(path):
        return 0

    res = subprocess.run(['du', '-sh', path], stdout=subprocess.PIPE, check=False)
    size = res.stdout.split()[0].decode()

    return f'{size[:-1]} {size[-1]}iB'


def get_buffer_mime_type(buffer):
    """
    Returns the MIME type of a buffer

    :param buffer: A binary string
    :type buffer: bytes

    :return: The detected MIME type, empty string otherwise
    :rtype: str
    """
    try:
        if hasattr(magic, 'detect_from_content'):
            # Using file-magic module: https://github.com/file/file
            return magic.detect_from_content(buffer[:128]).mime_type  # noqa: TC300

        # Using python-magic module: https://github.com/ahupp/python-magic
        return magic.from_buffer(buffer[:128], mime=True)  # noqa: TC300
    except Exception:
        return ''


@cache
def get_cache_dir():
    cache_dir_path = GLib.get_user_cache_dir()

    # Check if inside flatpak sandbox
    if is_flatpak():
        return cache_dir_path

    cache_dir_path = os.path.join(cache_dir_path, 'komikku')
    if not os.path.exists(cache_dir_path):
        os.mkdir(cache_dir_path)

    return cache_dir_path


@cache
def get_cached_data_dir():
    cached_data_dir_path = os.path.join(get_cache_dir(), 'tmp')
    if not os.path.exists(cached_data_dir_path):
        os.mkdir(cached_data_dir_path)

    return cached_data_dir_path


@cache
def get_cached_logos_dir():
    cached_logos_dir_path = os.path.join(get_cache_dir(), 'logos')
    if not os.path.exists(cached_logos_dir_path):
        os.mkdir(cached_logos_dir_path)

    return cached_logos_dir_path


@cache
def get_data_dir():
    data_dir_path = GLib.get_user_data_dir()
    app_profile = Gio.Application.get_default().profile

    if not is_flatpak():
        base_path = data_dir_path
        data_dir_path = os.path.join(base_path, 'komikku')
        if app_profile == 'development':
            data_dir_path += '-devel'
        elif app_profile == 'beta':
            data_dir_path += '-beta'

        if not os.path.exists(data_dir_path):
            os.mkdir(data_dir_path)

    # Create folder for 'local' server
    data_local_dir_path = os.path.join(data_dir_path, 'local')
    if not os.path.exists(data_local_dir_path):
        os.mkdir(data_local_dir_path)

    return data_dir_path


def get_file_mime_type(path):
    """
    Returns the MIME type of a file

    :param path: A file path
    :type path: str

    :return: The detected MIME type, empty string otherwise
    :rtype: str
    """
    try:
        if hasattr(magic, 'detect_from_filename'):
            # Using file-magic module: https://github.com/file/file
            return magic.detect_from_filename(path).mime_type  # noqa: TC300

        # Using python-magic module: https://github.com/ahupp/python-magic
        return magic.from_file(path, mime=True)  # noqa: TC300
    except Exception:
        return ''


def get_image_info(path_or_bytes):
    try:
        if isinstance(path_or_bytes, str):
            img = Image.open(path_or_bytes)
        else:
            img = Image.open(BytesIO(path_or_bytes))
    except Exception as exc:
        # Pillow doesn´t support SVG images
        # Get content type to identify an image
        if isinstance(path_or_bytes, str):
            gfile = Gio.File.new_for_path(path_or_bytes)
            content_type = gfile.query_info('standard::content-type', Gio.FileQueryInfoFlags.NONE, None).get_content_type()
        else:
            content_type, _result_uncertain = Gio.content_type_guess(None, path_or_bytes)

        if content_type.startswith('image'):
            info = {
                'width': -1,
                'height': -1,
                'is_animated': False,
            }
        else:
            logger.warning('Failed to open or identify image', exc_info=exc)
            info = None
    else:
        info = {
            'width': img.width,
            'height': img.height,
            'is_animated': hasattr(img, 'is_animated') and img.is_animated,
        }

        img.close()

    return info


def get_response_elapsed(r):
    """
    Returns the response time (in seconds) of a request
    regardless of the request type (requests, curl_cffi)

    :param r: A response
    :type r: requests.models.Response or curl_cffi.requests.models.Response

    :return: How many seconds the request cost
    :rtype: float
    """
    elapsed = r.elapsed
    if isinstance(elapsed, datetime.timedelta):
        # requests HTTP client
        return elapsed.total_seconds()

    # curl_cffi HTTP client
    return elapsed


def html_escape(s):
    return html.escape(html.unescape(s), quote=False)


def if_network_available(func_=None, only_notify=False):
    """Decorator to disable an action when network is not avaibable"""

    def _decorator(func):

        @wraps(func)
        def wrapper(*args, **kwargs):
            window = args[0].parent if hasattr(args[0], 'parent') else args[0].window
            if not window.network_available:
                window.add_notification(_('You are currently offline'), timeout=3, priority=1)
                if not only_notify:
                    return None

            return func(*args, **kwargs)
        return wrapper

    if callable(func_):
        return _decorator(func_)
    elif func_ is None:
        return _decorator
    else:
        raise RuntimeWarning('Positional arguments are not supported')


def is_flatpak():
    return os.path.exists(os.path.join(GLib.get_user_runtime_dir(), 'flatpak-info'))


def is_number(s):
    return s is not None and str(s).replace('.', '', 1).isdigit()


def log_error_traceback(e):
    from komikku.servers.exceptions import ServerException

    if isinstance(e, requests.exceptions.RequestException):
        return _('No Internet connection, timeout or server down')
    if isinstance(e, ServerException):
        return e.message

    logger.info(traceback.format_exc())

    return None


def remove_number_leading_zero(str_num):
    """Remove leading zero in a number string

    '00123' => '123' (int)
    '00123.45' => '123.45' (float)
    """
    return str(int(float(str_num))) if int(float(str_num)) == float(str_num) else str(float(str_num))


def retry_session(session=None, retries=3, allowed_methods=['GET'], backoff_factor=0.3, status_forcelist=None):
    if session is None:
        session = requests.Session()
    elif not getattr(session, 'adapters', None) or session.adapters['https://'].max_retries.total == retries:
        # Retry adapter is already modified or session is not a `requests (HTTP client)` session
        return session

    retry = Retry(
        total=retries,
        read=retries,
        connect=retries,
        allowed_methods=allowed_methods,
        backoff_factor=backoff_factor,
        status_forcelist=status_forcelist,
    )
    adapter = HTTPAdapter(max_retries=retry)

    session.mount('http://', adapter)
    session.mount('https://', adapter)

    return session


def skip_past(haystack, needle):
    if (idx := haystack.find(needle)) >= 0:
        return idx + len(needle)

    return None


def trunc_filename(filename):
    """Reduce filename length to 255 (common FS limit) if it's too long"""
    return filename.encode('utf-8')[:255].decode().strip()


# https://discourse.gnome.org/t/how-do-you-run-a-blocking-method-asynchronously-with-gio-task-in-a-python-gtk-app/10651/4
class AsyncWorker(GObject.Object):
    """Represents an asynchronous worker.

    An async worker's job is to run a blocking operation in the
    background using a Gio.Task to avoid blocking the app's main thread
    and freezing the user interface.

    The terminology used here is closely related to the Gio.Task API.

    There are two ways to specify the operation that should be run in
    the background:

    1. By passing the blocking operation (a function or method) to the
       constructor.
    2. By defining the work() method in a subclass.

    Constructor parameters:

    OPERATION (callable)
      The function or method that needs to be run asynchronously. This
      is only necessary when using a direct instance of AsyncWorker, not
      when using an instance of a subclass of AsyncWorker, in which case
      an AsyncWorker.work() method must be defined by the subclass
      instead.

    OPERATION_INPUTS (tuple)
      Input data for OPERATION, if any.

    OPERATION_CALLBACK (callable)
      A function or method to call when the OPERATION is complete.

      See AppWindow.on_lunch_finished for an example of such callback.

    OPERATION_CALLBACK_INPUTS (tuple)
      Optional. Additional input data for OPERATION_CALLBACK.

    CANCELLABLE (Gio.Cancellable)
      Optional. It defaults to None, meaning that the blocking
      operation is not cancellable.
    """

    def __init__(
            self,
            operation=None,
            operation_inputs=(),
            operation_callback=None,
            operation_callback_inputs=(),
            cancellable=None
    ):
        super().__init__()
        self.operation = operation
        self.operation_inputs = operation_inputs
        self.operation_callback = operation_callback
        self.operation_callback_inputs = operation_callback_inputs
        self.cancellable = cancellable

        # Holds the actual data referenced from the Gio.Task created
        # in the AsyncWorker.start method.
        self.pool = {}

    def start(self):
        """Schedule the blocking operation to be run asynchronously.

        The blocking operation is either self.operation or self.work,
        depending on how the AsyncWorker was instantiated.

        This method corresponds to the function referred to as
        "blocking_function_async" in GNOME Developer documentation.

        """
        task = Gio.Task.new(
            self,
            self.cancellable,
            self.operation_callback,
            self.operation_callback_inputs
        )

        if self.cancellable is None:
            task.set_return_on_cancel(False)  # The task is not cancellable.

        data_id = id(self.operation_inputs)
        self.pool[data_id] = self.operation_inputs
        task.set_task_data(
            data_id,
            # FIXME: Data destroyer function always gets None as argument.
            #
            # This function is supposed to take as an argument the
            # same value passed as data_id to task.set_task_data, but
            # when the destroyer function is called, it seems it always
            # gets None as an argument instead. That's why the "key"
            # parameter is not being used in the body of the anonymous
            # function.
            lambda key: self.pool.pop(data_id)
        )

        task.run_in_thread(self._thread_callback)

    def _thread_callback(self, task, worker, task_data, cancellable):
        """Run the blocking operation in a worker thread."""
        # FIXME: task_data is always None for Gio.Task.run_in_thread callback.
        #
        # The value passed to this callback as task_data always seems to
        # be None, so we get the data for the blocking operation as
        # follows instead.
        data_id = task.get_task_data()
        data = self.pool.get(data_id)

        # Run the blocking operation.
        if self.operation is None:  # Assume AsyncWorker was extended.
            outcome = self.work(*data)
        else:  # Assume AsyncWorker was instantiated directly.
            outcome = self.operation(*data)

        task.return_value(outcome)

    def return_value(self, result):
        """Return the value of the operation that was run
        asynchronously.

        This method corresponds to the function referred to as
        "blocking_function_finish" in GNOME Developer documentation.

        This method is called from the view where the asynchronous
        operation is started to update the user interface according
        to the resulting value.

        RESULT (Gio.AsyncResult)
          The asynchronous result of the blocking operation that is
          run asynchronously.

        RETURN VALUE (object)
          Any of the return values of the blocking operation. If
          RESULT turns out to be invalid, return an error dictionary
          in the form

          {'AsyncWorkerError': 'Gio.Task.is_valid returned False.'}

        """
        value = None

        if Gio.Task.is_valid(result, self):
            value = result.propagate_value().value
        else:
            error = 'Gio.Task.is_valid returned False.'
            value = {'AsyncWorkerError': error}

        return value


class BaseServer:
    id: str
    name: str

    headers = None
    headers_images = None
    http_client = 'requests'  # HTTP client
    status = 'enabled'

    __sessions = {}  # to cache all existing sessions

    @property
    def session(self):
        return BaseServer.__sessions.get(self.id)

    @session.setter
    def session(self, value):
        BaseServer.__sessions[self.id] = value

    @cached_property
    def sessions_dir(self):
        dir_path = os.path.join(get_cache_dir(), 'sessions')
        if not os.path.exists(dir_path):
            os.mkdir(dir_path)

        return dir_path

    def get_image(self, url, etag=None):
        """
        Get an image

        :param url: The image URL
        :type url: str

        :param etag: The current image ETag
        :type etag: str or None

        :return: The image content, the image ETag if exists, the request time (seconds)
        :rtype: tuple
        """
        if url is None:
            return None, None, None

        if self.headers_images is not None:
            headers = self.headers_images
        else:
            headers = {
                'Accept': 'image/avif,image/webp,*/*',
                'Referer': f'{self.base_url}/',
            }
        if etag:
            headers['If-None-Match'] = etag

        if self.session:
            r = self.session.get(url, headers=headers)
        else:
            # Session object has not yet been instantiated (servers with login or using webview to complete challenge)
            r = requests.get(url, headers=headers)
        if not r.ok:
            return None, None, get_response_elapsed(r)

        buffer = r.content
        mime_type = get_buffer_mime_type(buffer)
        if not mime_type.startswith('image'):
            return None, None, get_response_elapsed(r)

        return buffer, r.headers.get('ETag'), get_response_elapsed(r)

    def save_image(self, url, dir_path, name, width, height, keep_aspect_ratio=True, dominant_color=True, format='JPEG'):
        if url is None:
            return False

        # If image has already been retrieved
        # Check first if it has changed using ETag
        current_etag = None
        etag_fs_path = os.path.join(dir_path, f'{name}.etag')
        if os.path.exists(etag_fs_path):
            with open(etag_fs_path, 'r') as fp:
                current_etag = fp.read()

        # Save image file
        try:
            data, etag, _rtime = self.get_image(url, current_etag)
        except Exception:
            return False
        if data is None:
            return False

        data = convert_and_resize_image(
            data, width, height, keep_aspect_ratio=keep_aspect_ratio, dominant_color=dominant_color, format=format
        )
        if data is None:
            return False

        if not os.path.exists(dir_path):
            os.mkdir(dir_path)

        fs_path = os.path.join(dir_path, f'{name}.{"jpg" if format == "JPEG" else format.lower()}')
        with open(fs_path, 'wb') as fp:
            fp.write(data)

        if etag:
            with open(etag_fs_path, 'w') as fp:
                fp.write(etag)
        elif os.path.exists(etag_fs_path):
            os.remove(etag_fs_path)

        return True

    def session_get(self, *args, **kwargs):
        try:
            r = retry_session(session=self.session).get(*args, **kwargs)
        except Exception as error:
            logger.debug(error)
            raise

        return r

    def session_patch(self, *args, **kwargs):
        try:
            r = self.session.patch(*args, **kwargs)
        except Exception as error:
            logger.debug(error)
            raise

        return r

    def session_post(self, *args, **kwargs):
        try:
            r = self.session.post(*args, **kwargs)
        except Exception as error:
            logger.debug(error)
            raise

        return r

    def session_put(self, *args, **kwargs):
        try:
            r = self.session.put(*args, **kwargs)
        except Exception as error:
            logger.debug(error)
            raise

        return r


class CustomTimeout(TimeoutSauce):
    def __init__(self, *args, **kwargs):
        if kwargs['connect'] is None:
            kwargs['connect'] = REQUESTS_TIMEOUT
        if kwargs['read'] is None:
            kwargs['read'] = REQUESTS_TIMEOUT * 2
        super().__init__(*args, **kwargs)


# Set requests timeout globally, instead of specifying ``timeout=..`` kwarg on each call
requests.adapters.TimeoutSauce = CustomTimeout


class CoverLoader(GObject.GObject):
    __gtype_name__ = 'CoverLoader'

    def __init__(self, path, texture, pixbuf, width=None, height=None):
        super().__init__()

        self.path = path
        self.texture = texture
        self.pixbuf = pixbuf

        if texture:
            self.orig_width = self.texture.get_width()
            self.orig_height = self.texture.get_height()
        else:
            self.orig_width = self.pixbuf.get_width()
            self.orig_height = self.pixbuf.get_height()

        # Compute size
        if width is None and height is None:
            self.width = self.orig_width
            self.height = self.orig_height

        elif width is None or height is None:
            ratio = self.orig_width / self.orig_height
            if width is None:
                self.width = int(height * ratio)
                self.height = height
            else:
                self.width = width
                self.height = int(width / ratio)

        else:
            self.width = width
            self.height = height

    @classmethod
    def new_from_data(cls, data, width=None, height=None, static_animation=False):
        info = get_image_info(data)
        if not info:
            return None

        try:
            if info['is_animated'] and not static_animation:
                stream = Gio.MemoryInputStream.new_from_data(data, None)
                pixbuf = PixbufAnimation.new_from_stream(stream)
                stream.close()
                texture = None
            else:
                pixbuf = None
                texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(data))
        except Exception:
            # Invalid image, corrupted image, unsupported image format,...
            return None

        return cls(None, texture, pixbuf, width, height)

    @classmethod
    def new_from_file(cls, path, width=None, height=None, static_animation=False):
        info = get_image_info(path)
        if not info:
            return None

        try:
            if info['is_animated'] and not static_animation:
                pixbuf = PixbufAnimation.new_from_file(path)
                texture = None
            else:
                pixbuf = None
                texture = Gdk.Texture.new_from_filename(path)
        except Exception:
            # Invalid image, corrupted image, unsupported image format,...
            return None

        return cls(path, texture, pixbuf, width, height)

    @classmethod
    def new_from_resource(cls, path, width=None, height=None):
        try:
            texture = Gdk.Texture.new_from_resource(path)
        except Exception:
            # Invalid image, corrupted image, unsupported image format,...
            return None

        return cls(None, texture, None, width, height)

    def dispose(self):
        self.texture = None
        self.pixbuf = None


class CoverPaintable(CoverLoader, Gdk.Paintable):
    __gtype_name__ = 'CoverPaintable'

    corners_radius = 8

    def __init__(self, path, texture, pixbuf, width=None, height=None):
        CoverLoader.__init__(self, path, texture, pixbuf, width, height)

        self.rect = Graphene.Rect().alloc()
        self.rounded_rect = Gsk.RoundedRect()
        self.rounded_rect_size = Graphene.Size().alloc()
        self.rounded_rect_size.init(self.corners_radius, self.corners_radius)

        if isinstance(self.pixbuf, PixbufAnimation):
            self._animation_iter = self.pixbuf.get_iter(None)
            self.__animation_timeout_id = None
        else:
            self._animation_iter = None

    def _start_animation(self):
        if self._animation_iter is None or self.__animation_timeout_id:
            return

        self.__animation_timeout_id = GLib.timeout_add(self._animation_iter.get_delay_time(), self.on_delay)

    def _stop_animation(self):
        if self._animation_iter is None or self.__animation_timeout_id is None:
            return

        GLib.source_remove(self.__animation_timeout_id)
        self.__animation_timeout_id = None

    def dispose(self):
        CoverLoader.dispose(self)
        self._animation_iter = None

    def do_get_intrinsic_height(self):
        return self.height

    def do_get_intrinsic_width(self):
        return self.width

    def do_snapshot(self, snapshot, width, height):
        self.rect.init(0, 0, width, height)

        if self._animation_iter:
            # Get next frame (animated GIF)
            pixbuf = self._animation_iter.get_pixbuf()
            self.texture = Gdk.Texture.new_for_pixbuf(pixbuf)

        # Append cover (rounded)
        self.rounded_rect.init(self.rect, self.rounded_rect_size, self.rounded_rect_size, self.rounded_rect_size, self.rounded_rect_size)
        snapshot.push_rounded_clip(self.rounded_rect)
        snapshot.append_texture(self.texture, self.rect)
        snapshot.pop()  # remove the clip

    def on_delay(self):
        if self._animation_iter.get_delay_time() == -1:
            return GLib.SOURCE_REMOVE

        # Check if it's time to show the next frame
        if self._animation_iter.advance(None):
            self.invalidate_contents()

        return GLib.SOURCE_CONTINUE


class CoverPicture(Gtk.Picture):
    def __init__(self, paintable):
        super().__init__()
        self.set_paintable(paintable)

        if self.is_animated:
            self.connect('map', self.on_map)
            self.connect('unmap', self.on_unmap)

        self.connect('unrealize', self.on_unrealize)

    @classmethod
    def new_from_data(cls, data, width=None, height=None, static_animation=False):
        if paintable := CoverPaintable.new_from_data(data, width, height, static_animation):
            return cls(paintable)

        return None

    @classmethod
    def new_from_file(cls, path, width=None, height=None, static_animation=False):
        if paintable := CoverPaintable.new_from_file(path, width, height, static_animation):
            return cls(paintable)

        return None

    @classmethod
    def new_from_resource(cls, path, width=None, height=None):
        if paintable := CoverPaintable.new_from_resource(path, width, height):
            return cls(paintable)

        return None

    @cached_property
    def is_animated(self):
        return self.get_paintable()._animation_iter is not None

    def on_map(self, _self):
        self.get_paintable()._start_animation()

    def on_unmap(self, _self):
        self.get_paintable()._stop_animation()

    def on_unrealize(self, _self):
        if self.is_animated:
            self.disconnect_by_func(self.on_map)
            self.disconnect_by_func(self.on_unmap)
            self.get_paintable()._stop_animation()

        self.disconnect_by_func(self.on_unrealize)

        self.get_paintable().dispose()
