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

from abc import abstractmethod
import datetime
from gettext import gettext as _
import threading

from gi.repository import Adw
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk

from komikku.models import create_db_connection
from komikku.models import Settings
from komikku.reader.pager.page import Page
from komikku.utils import log_error_traceback


class BasePager:
    autohide_controls = True
    default_double_click_time = Gtk.Settings.get_default().get_property('gtk-double-click-time')

    def __init__(self, reader):
        self.reader = reader
        self.window = reader.window

        # Controller to track pointer motion: used to hide pointer during keyboard navigation
        self.controller_motion = Gtk.EventControllerMotion.new()
        self.add_controller(self.controller_motion)
        self.controller_motion.connect('motion', self.on_pointer_motion)
        self.pointer_position = None

    @property
    @abstractmethod
    def pages(self):
        raise NotImplementedError()

    def crop_pages_borders(self):
        raise NotImplementedError()

    @abstractmethod
    def goto_page(self, page_index):
        raise NotImplementedError()

    def hide_cursor(self):
        GLib.timeout_add(1000, self.set_cursor, Gdk.Cursor.new_from_name('none'))

    @abstractmethod
    def init(self):
        raise NotImplementedError()

    def on_pointer_motion(self, _controller, x, y):
        if self.pointer_position == (x, y):
            # Hack? Ignore events generated by Gtk.Carousel during page changes
            return Gdk.EVENT_PROPAGATE

        self.pointer_position = (x, y)

        if self.get_cursor():
            # Cursor is hidden during keyboard navigation
            # Make cursor visible again when mouse is moved
            self.set_cursor(None)

        return Gdk.EVENT_PROPAGATE

    def rescale_pages(self):
        for page in self.pages:
            page.rescale()

    def save_progress(self, read_pages):
        """Save reading progress

        Accepts one or several pages which can be from different chapters"""

        if not isinstance(read_pages, list):
            read_pages = [read_pages,]

        for page in read_pages.copy():
            if page.status == 'disposed':
                # Page is no longer present in pager
                read_pages.remove(page)

        for page in read_pages.copy():
            # Loop as long as a page rendering is not ended
            if page.status in (None, 'rendering', 'allocable'):
                return GLib.SOURCE_CONTINUE

            if page.status == 'offlimit' or page.error is not None:
                read_pages.remove(page)

        if not read_pages:
            return GLib.SOURCE_REMOVE

        read_chapters = dict()
        for page in read_pages.copy():
            chapter = page.chapter
            if chapter.id not in read_chapters:
                read_chapters[chapter.id] = dict(
                    chapter=chapter,
                    pages=[],
                )
            read_chapters[chapter.id]['pages'].append(page.index)
            read_pages.remove(page)

        db_conn = create_db_connection()

        # Update manga last read time
        self.reader.manga.update(
            dict(
                last_read=datetime.datetime.now(datetime.UTC)
            ),
            db_conn=db_conn
        )

        # Update chapters read progress
        for read_chapter in read_chapters.values():
            chapter = read_chapter['chapter']
            pages = read_chapter['pages']

            if not chapter.read:
                # Add chapter to the list of chapters consulted
                # Used by Card page to update chapters rows
                self.reader.chapters_consulted.add(chapter)

                read_progress = chapter.read_progress
                if read_progress is None:
                    # Init and fill with '0'
                    read_progress = '0' * len(chapter.pages)

                # Mark current page as read
                for index in pages:
                    read_progress = read_progress[:index] + '1' + read_progress[index + 1:]
                chapter_is_read = '0' not in read_progress
                if chapter_is_read:
                    read_progress = None

                # Update chapter
                chapter.update(
                    dict(
                        last_page_read_index=page.index if not chapter_is_read else None,
                        last_read=datetime.datetime.now(datetime.UTC),
                        read_progress=read_progress,
                        read=chapter_is_read,
                        recent=0,
                    ),
                    db_conn=db_conn
                )

                for index in pages:
                    self.sync_progress_with_server(chapter, index)
                    self.sync_progress_with_trackers(chapter, index)

        return GLib.SOURCE_REMOVE

    def sync_progress_with_server(self, chapter, index):
        # Sync reading progress with server if function is supported
        def run():
            try:
                res = chapter.manga.server.update_chapter_read_progress(
                    dict(
                        page=index + 1,
                        completed=chapter.read,
                    ),
                    self.reader.manga.slug, self.reader.manga.name, chapter.slug, chapter.url
                )
                if res != NotImplemented and not res:
                    # Failed to save progress
                    on_error('server')
            except Exception as e:
                on_error('connection', *log_error_traceback(e))

        def on_error(_kind, message=None, stack_trace=None):
            if message is not None:
                self.window.add_notification(_(f'Failed to sync read progress with server:\n{message}'), timeout=2)  # noqa E231
            else:
                self.window.add_notification(_('Failed to sync read progress with server'), timeout=2)

                if stack_trace is not None and Settings.get_default().servers_bug_report:
                    context = '\n'.join([
                        'Failed to sync read progress',
                        f'- Server ID: {chapter.manga.server.id}',
                        f'- Server URL: {chapter.manga.server.base_url}',
                    ])
                    self.window.open_server_bug_report_dialog(chapter.manga.server, context, stack_trace)

        thread = threading.Thread(target=run)
        thread.daemon = True
        thread.start()

    def sync_progress_with_trackers(self, chapter, index):
        if not Settings.get_default().tracking or index + 1 != len(chapter.pages):
            return

        for _id, tracker in self.window.trackers.trackers.items():
            granted, _access_token_valid = tracker.is_granted()
            if not granted:
                continue

            if not chapter.manga.tracking or tracker.id not in chapter.manga.tracking or not chapter.number:
                continue

            data = {
                'chapters_progress': int(float(chapter.number)),
                '_synced': False,
            }
            if chapter.manga.tracking[tracker.id]['status'] not in ('reading', 'rereading'):
                data['status'] = 'reading'

            chapter.manga.tracking[tracker.id].update(data)
            chapter.manga.update({
                'tracking': chapter.manga.tracking,
            })

        self.window.trackers.sync()


class Pager(Adw.Bin, BasePager):
    """Classic page by page pager (LTR, RTL, vertical)"""

    __gtype_name__ = 'Pager'

    current_chapter_id = None

    def __init__(self, reader):
        Adw.Bin.__init__(self)
        BasePager.__init__(self, reader)

        self.carousel = Adw.Carousel()
        self.carousel.set_scroll_params(Adw.SpringParams.new(1, 0.025, 10))  # guesstimate
        self.carousel.set_allow_long_swipes(False)
        self.carousel.set_reveal_duration(0)
        self.set_child(self.carousel)

        self.page_changed_handler_id = self.carousel.connect('page-changed', self.on_page_changed)

        # Keyboard navigation
        self.key_pressed_handler_id = self.window.controller_key.connect('key-pressed', self.on_key_pressed)

        # Scroll controller
        self.controller_scroll = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.BOTH_AXES)
        self.controller_scroll.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        self.add_controller(self.controller_scroll)
        self.controller_scroll.connect('scroll', self.on_scroll)

    @GObject.Property(type=bool, default=True)
    def interactive(self):
        return self.carousel.get_interactive()

    @interactive.setter
    def interactive(self, value):
        self.carousel.set_interactive(value)

    @property
    def page_change_in_progress(self):
        return self.carousel.get_progress() != 1

    @property
    def pages(self):
        for index in range(self.carousel.get_n_pages()):
            yield self.carousel.get_nth_page(index)

    def add_page(self, position):
        if position == 'start':
            if page_end := self.carousel.get_nth_page(2):
                page_end.dispose()

            page = self.carousel.get_nth_page(0)
            direction = 1 if self.reader.reading_mode == 'right-to-left' else -1
            new_page = Page(self, page.chapter, page.index + direction)
            self.carousel.prepend(new_page)

            new_page.connect('rendered', self.on_page_rendered)
            new_page.scrolledwindow.connect('edge-overshot', self.on_page_edge_overshotted)
            new_page.render()
        else:
            page = self.carousel.get_nth_page(2)

            def append_after_remove(_carousel, _position):
                if self.carousel.get_position() != 1:
                    return

                self.carousel.disconnect(position_handler_id)

                direction = -1 if self.reader.reading_mode == 'right-to-left' else 1
                new_page = Page(self, page.chapter, page.index + direction)
                self.carousel.append(new_page)

                new_page.connect('rendered', self.on_page_rendered)
                new_page.scrolledwindow.connect('edge-overshot', self.on_page_edge_overshotted)
                new_page.render()

            if page_start := self.carousel.get_nth_page(0):
                # Hack: use a workaround to not lose position
                # Cf. issue https://gitlab.gnome.org/GNOME/libadwaita/-/issues/430
                position_handler_id = self.carousel.connect('notify::position', append_after_remove)
                page_start.dispose()

    def adjust_page_placement(self, page):
        # Only if page is scrollable
        if not page.scrollable:
            return

        if self.reader.reading_mode == 'vertical':
            adj = page.scrolledwindow.get_vadjustment()
        else:
            adj = page.scrolledwindow.get_hadjustment()

        pages = list(self.pages)
        if page in pages:
            if self.current_page is None or page == self.current_page:
                adj.set_value(0 if self.reader.reading_mode == 'left-to-right' else adj.get_upper() - adj.get_page_size())
            elif pages.index(page) > pages.index(self.current_page):
                adj.set_value(0)
            else:
                adj.set_value(adj.get_upper() - adj.get_page_size())

        if self.reader.reading_mode == 'vertical':
            # Center page horizontally
            hadj = page.scrolledwindow.get_hadjustment()
            hadj.set_value((hadj.get_upper() - hadj.get_page_size()) / 2)

    def clear(self):
        page = self.carousel.get_first_child()
        while page:
            next_page = page.get_next_sibling()
            page.dispose()
            page = next_page

    def crop_pages_borders(self):
        for page in self.pages:
            if page.image and page.error is None:
                page.image.crop = self.reader.borders_crop

    def dispose(self):
        self.window.controller_key.disconnect(self.key_pressed_handler_id)
        self.carousel.disconnect(self.page_changed_handler_id)
        self.clear()

    def goto_page(self, index):
        if self.carousel.get_nth_page(0).index == index and self.carousel.get_nth_page(0).chapter == self.current_page.chapter:
            self.scroll_to_direction('left', False)
        elif self.carousel.get_nth_page(2).index == index and self.carousel.get_nth_page(2).chapter == self.current_page.chapter:
            self.scroll_to_direction('right', False)
        else:
            self.init(self.current_page.chapter, index)

    def init(self, chapter, page_index=None):
        self.current_page = None
        self.reader.update_title(chapter)
        self.clear()

        if page_index is None:
            if chapter.read:
                page_index = 0
            elif chapter.last_page_read_index is not None:
                page_index = chapter.last_page_read_index
            else:
                page_index = 0

        direction = 1 if self.reader.reading_mode == 'right-to-left' else -1

        # Left page
        left_page = Page(self, chapter, page_index + direction)
        self.carousel.append(left_page)
        left_page.connect('rendered', self.on_page_rendered)
        left_page.scrolledwindow.connect('edge-overshot', self.on_page_edge_overshotted)

        # Center page
        center_page = Page(self, chapter, page_index)
        self.carousel.append(center_page)
        center_page.connect('rendered', self.on_page_rendered)
        center_page.scrolledwindow.connect('edge-overshot', self.on_page_edge_overshotted)

        # Right page
        right_page = Page(self, chapter, page_index - direction)
        self.carousel.append(right_page)
        right_page.connect('rendered', self.on_page_rendered)
        right_page.scrolledwindow.connect('edge-overshot', self.on_page_edge_overshotted)

        def finalize():
            # Scroll to center page
            self.carousel.scroll_to(self.carousel.get_nth_page(1), False)

            center_page.render()
            GLib.timeout_add(500, left_page.render)
            GLib.timeout_add(500, right_page.render)

        # Hack: use a `GLib.timeout_add` otherwise `scroll_to` (with animate=False) doesn't work!!!
        GLib.timeout_add(250, finalize)

    def on_key_pressed(self, _controller, keyval, _keycode, state):
        if self.window.page != self.reader.props.tag:
            return Gdk.EVENT_PROPAGATE

        if self.page_change_in_progress:
            return Gdk.EVENT_PROPAGATE

        modifiers = Gtk.accelerator_get_default_mod_mask()
        if (state & modifiers) not in (Gdk.ModifierType.NO_MODIFIER_MASK, Gdk.ModifierType.SHIFT_MASK):
            return Gdk.EVENT_PROPAGATE

        if keyval == Gdk.KEY_space:
            keyval = Gdk.KEY_Left if self.reader.reading_mode == 'right-to-left' else Gdk.KEY_Right

        page = self.current_page

        if keyval in (Gdk.KEY_plus, Gdk.KEY_KP_Add, Gdk.KEY_minus, Gdk.KEY_KP_Subtract, Gdk.KEY_0, Gdk.KEY_KP_0):
            # Zoom (+/-/reset)
            self.current_page.image.set_zoom_by_key(keyval, self.reader.reading_mode)
            return Gdk.EVENT_STOP

        if keyval in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_j, Gdk.KEY_Right, Gdk.KEY_KP_Right, Gdk.KEY_k):
            # Hide mouse cursor when using keyboard navigation
            self.hide_cursor()

            hadj = page.scrolledwindow.get_hadjustment()

            if keyval in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_j):
                if hadj.get_value() == 0:
                    self.scroll_to_direction('left')
                    return Gdk.EVENT_STOP

                page.scrolledwindow.emit('scroll-child', Gtk.ScrollType.STEP_LEFT, False)
                return Gdk.EVENT_STOP

            if hadj.get_value() + hadj.get_page_size() == hadj.get_upper():
                self.scroll_to_direction('right')
                return Gdk.EVENT_STOP

            page.scrolledwindow.emit('scroll-child', Gtk.ScrollType.STEP_RIGHT, False)
            return Gdk.EVENT_STOP

        if keyval in (Gdk.KEY_Up, Gdk.KEY_KP_Up, Gdk.KEY_Down, Gdk.KEY_KP_Down):
            # Hide mouse cursor when using keyboard navigation
            self.hide_cursor()

            vadj = page.scrolledwindow.get_vadjustment()

            if keyval in (Gdk.KEY_Down, Gdk.KEY_KP_Down):
                if self.reader.reading_mode == 'vertical' and vadj.get_value() + vadj.get_page_size() == vadj.get_upper():
                    self.scroll_to_direction('right')
                    return Gdk.EVENT_STOP

                # If image height is greater than viewport height, arrow keys should scroll page down
                # Emit scroll signal: one step down
                page.scrolledwindow.emit('scroll-child', Gtk.ScrollType.STEP_DOWN, False)
                return Gdk.EVENT_STOP

            if self.reader.reading_mode == 'vertical' and vadj.get_value() == 0:
                self.scroll_to_direction('left')

                return Gdk.EVENT_STOP

            # If image height is greater than viewport height, arrow keys should scroll page up
            # Emit scroll signal: one step up
            page.scrolledwindow.emit('scroll-child', Gtk.ScrollType.STEP_UP, False)
            return Gdk.EVENT_STOP

        return Gdk.EVENT_PROPAGATE

    def on_page_changed(self, _carousel, index):
        # index != 1 except when:
        # - partial/incomplete mouse drag
        # - come back from offlimit page
        # - come back from inaccessible chapter page

        if self.current_page:
            self.current_page.obscured = True

        page = self.carousel.get_nth_page(index)
        self.current_page = page
        self.current_page.obscured = False

        # Allow navigating if page is not scrollable, disallow otherwise
        self.interactive = not page.scrollable
        # Restore zooming
        page.set_allow_zooming(True)

        if page.status == 'offlimit':
            GLib.idle_add(self.scroll_to_page, self.carousel.get_nth_page(1), True, False)

            if page.index == -1:
                message = _('There is no previous chapter.')
            else:
                message = _('It was the last chapter.')
            self.window.add_notification(message, timeout=2)

            return

        if index != 1:
            if self.autohide_controls:
                # Hide controls
                self.reader.toggle_controls(False)
            else:
                self.autohide_controls = True

            # Hide page numbering if chapter pages are not yet known
            if not page.loadable:
                self.reader.update_page_numbering()

        GLib.idle_add(self.update, page, index)
        GLib.timeout_add(100, self.save_progress, page)

    def on_page_edge_overshotted(self, _scrolledwindow, position):
        # When page is scrollable, scroll events are consumed, so we must manage page changes in place of Adw.Carousel
        if self.reader.reading_mode in ('right-to-left', 'left-to-right'):
            # RTL/LTR
            if position in (Gtk.PositionType.LEFT, Gtk.PositionType.RIGHT):
                self.scroll_to_direction('left' if position == Gtk.PositionType.LEFT else 'right')
        else:
            # Vertical
            if position in (Gtk.PositionType.TOP, Gtk.PositionType.BOTTOM):
                self.scroll_to_direction('left' if position == Gtk.PositionType.TOP else 'right')

    def on_page_rendered(self, page, update, retry):
        if page.status == 'disposed':
            return

        if not update:
            GLib.idle_add(self.adjust_page_placement, page)

        if retry:
            index = self.carousel.get_position()
            self.on_page_changed(None, index)
            return

        if page == self.current_page:
            # Allow navigating if page is not scrollable, disallow otherwise
            self.interactive = not page.scrollable

    def on_scroll(self, _controller, dx, dy):
        page = self.current_page

        modifiers = Gtk.accelerator_get_default_mod_mask()
        state = self.controller_scroll.get_current_event_state()
        if state & modifiers == Gdk.ModifierType.CONTROL_MASK:
            # Propagate event to page: allow zoom with Ctrl + mouse wheel
            page.set_allow_zooming(True)
            return Gdk.EVENT_PROPAGATE

        if page is None:
            return

        if page.scrollable:
            # Page is scrollable (horizontally or vertically)

            # Scroll events are consumed, so we must manage page changes in place of Adw.Carousel
            # In the scroll axis, page changes will be handled via 'edge-overshot' page event

            if page.hscrollable and not page.vscrollable and dy:
                if self.reader.reading_mode == 'right-to-left':
                    # Use vertical scroll event to scroll horizontally in page
                    page.scrolledwindow.emit('scroll-child', Gtk.ScrollType.STEP_LEFT if dy > 0 else Gtk.ScrollType.STEP_RIGHT, False)
                    return Gdk.EVENT_STOP

                if self.reader.reading_mode == 'left-to-right':
                    # Use vertical scroll event to scroll horizontally in page
                    page.scrolledwindow.emit('scroll-child', Gtk.ScrollType.STEP_RIGHT if dy > 0 else Gtk.ScrollType.STEP_LEFT, False)
                    return Gdk.EVENT_STOP

                if self.reader.reading_mode == 'vertical' and dx == 0:
                    # Allow vertical navigation when page is horizontally scrollable
                    self.scroll_to_direction('left' if dy < 0 else 'right')
                    return Gdk.EVENT_STOP

            elif page.vscrollable and not page.hscrollable and dx:
                if self.reader.reading_mode in ('right-to-left', 'left-to-right') and dy == 0:
                    # Allow horizontal navigation when page is vertically scrollable
                    self.scroll_to_direction('left' if dx < 0 else 'right')
                    return Gdk.EVENT_STOP

        else:
            # Page is not scrollable
            if self.reader.reading_mode == 'right-to-left' and dy and dx == 0:
                # Navigation must be inverted in RTL reading mode
                # Do page change in the place of Adw.carousel
                self.scroll_to_direction('left' if dy > 0 else 'right')
                return Gdk.EVENT_STOP

        # Just before zomm gestures (KImage widget), touchscreen swipe or touchpad scrolling events can be accidentally produced.
        # As page is being changed, zooming should not occur.
        page.set_allow_zooming(False)

        # Propagate event to Adw.Carousel
        return Gdk.EVENT_PROPAGATE

    def on_single_click(self, x, _y):
        width, _height = self.reader.size

        if x < width / 3:
            # 1st third of the page
            self.scroll_to_direction('left')
        elif x > 2 * width / 3:
            # Last third of the page
            self.scroll_to_direction('right')
        else:
            # Center part of the page: toggle controls
            self.reader.toggle_controls()

    def reverse_pages(self):
        left_page = self.carousel.get_nth_page(0)
        right_page = self.carousel.get_nth_page(2)

        self.carousel.reorder(left_page, 2)
        self.carousel.reorder(right_page, 0)

        for page in self.pages:
            self.adjust_page_placement(page)

    def scroll_to_direction(self, direction, autohide_controls=True):
        if self.page_change_in_progress:
            return

        position = self.carousel.get_position()
        page = None

        if direction == 'left' and position > 0:
            page = self.carousel.get_nth_page(position - 1)
        elif direction == 'right' and position < 2:
            page = self.carousel.get_nth_page(position + 1)

        if page:
            self.scroll_to_page(page, True, autohide_controls)

    def scroll_to_page(self, page, animate=True, autohide_controls=True):
        self.autohide_controls = autohide_controls
        self.carousel.scroll_to(page, animate)

    def set_orientation(self, orientation):
        self.carousel.set_orientation(orientation)

    def update(self, page, index):
        if self.window.page != 'reader' or page.status == 'disposed' or page != self.current_page:
            return GLib.SOURCE_REMOVE

        if not page.loadable and page.error is None:
            # Loop until page is loadable or page is on error
            return GLib.SOURCE_CONTINUE

        if page.loadable and index != 1:
            # Add next page depending of navigation direction
            self.add_page('start' if index == 0 else 'end')

        # Update title, initialize controls and notify user if chapter changed
        if self.current_chapter_id != page.chapter.id:
            self.current_chapter_id = page.chapter.id

            self.reader.update_title(page.chapter)
            self.window.add_notification(page.chapter.title, timeout=3)
            self.reader.controls.init(page.chapter)

        if not page.loadable:
            self.window.add_notification(_('This chapter is inaccessible.'), timeout=2)

        # Update page number and controls page slider
        self.reader.update_page_numbering(page.index + 1, len(page.chapter.pages) if page.loadable else None)
        self.reader.controls.set_scale_value(page.index + 1)

        return GLib.SOURCE_REMOVE
