# This file is part of Gajim.
#
# SPDX-License-Identifier: GPL-3.0-only

from __future__ import annotations

import typing

import logging
import sys
from pathlib import Path

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

try:
    from gi.repository import Gst
except Exception:
    if typing.TYPE_CHECKING:
        from gi.repository import Gst

from gajim.common import app
from gajim.common.i18n import _
from gajim.common.preview import AudioSampleT
from gajim.common.util.text import format_duration

from gajim.gtk.builder import get_builder
from gajim.gtk.preview_audio_analyzer import AudioAnalyzer
from gajim.gtk.preview_audio_visualizer import AudioVisualizerWidget
from gajim.gtk.util import SignalManager

log = logging.getLogger('gajim.gtk.preview_audio')

# Padding to align the visualizer drawing with the seekbar
SEEK_BAR_PADDING = 11


class AudioWidget(Gtk.Box, SignalManager):
    def __init__(self, file_path: Path) -> None:
        Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        SignalManager.__init__(self)

        self._playbin = Gst.ElementFactory.make('playbin', 'bin')
        self._bus_watch_id: int = 0
        self._timeout_id: int = -1
        self._timeout_delay: int = 50  # in ms

        if self._playbin is None:
            log.warning('Could not create GST playbin')
            label = Gtk.Label(label=_('Audio preview is not available'))
            self.append(label)
            return

        self._file_path = file_path
        self._id = hash(self._file_path)

        app.preview_manager.register_audio_stop_func(self._id, self._set_ready)

        self._seek_pos = -1.0
        self._cursor_pos = 0.0
        self._offset_backward = -10e9  # in ns
        self._offset_forward = 10e9
        self._pause_seek = False
        self._is_ready = True
        self._next_state_is_playing = False
        self._pause_on_eos_delay = 100  # in ms

        # Constants which define player's behaviour
        self._speed_min = 0.25
        self._speed_max = 2.00
        self._speed_inc_step = 0.25
        self._speed_dec_step = 0.25

        self._query = Gst.Query.new_position(Gst.Format.TIME)
        self._setup_audio_player(file_path)

        self._ui = get_builder('preview_audio.ui')
        self._is_LTR = self._ui.seek_bar.get_direction() == Gtk.TextDirection.LTR
        self._enable_controls(False)

        cursor = Gdk.Cursor.new_from_name('pointer')
        self._ui.seek_bar.set_cursor(cursor)
        self._ui.progress_label.set_cursor(cursor)
        self._ui.speed_bar.set_cursor(cursor)

        self.append(self._ui.preview_box)
        self._setup_audio_visualizer()

        # Initialize with restored audio state or defaults
        self._state = app.preview_manager.get_audio_state(self._id)
        self._audio_analyzer = None

        if not self._state.is_audio_analyzed:
            # Analyze the audio to determine samples and duration,
            # calls self._update_audio_data when done.
            self._audio_analyzer = AudioAnalyzer(
                file_path, self._update_duration, self._update_samples)
        else:
            self._audio_visualizer.set_samples(self._state.samples)
            self._update_ui()

        self._ui.speed_bar_adj.configure(
            value=self._state.speed,
            lower=self._speed_min,
            upper=self._speed_max,
            step_increment=self._speed_inc_step,
            page_increment=self._speed_inc_step,
            page_size=0
        )

        self._ui.speed_bar.set_value(self._state.speed)
        self._set_playback_speed(self._state.speed)

        self._ui.speed_bar.add_mark(0.25, Gtk.PositionType.BOTTOM, '0.25')
        self._ui.speed_bar.add_mark(0.5, Gtk.PositionType.BOTTOM, '')
        self._ui.speed_bar.add_mark(0.75, Gtk.PositionType.BOTTOM, '')
        self._ui.speed_bar.add_mark(1, Gtk.PositionType.BOTTOM, '1.0')
        self._ui.speed_bar.add_mark(1.25, Gtk.PositionType.BOTTOM, '')
        self._ui.speed_bar.add_mark(1.5, Gtk.PositionType.BOTTOM, '1.5')
        self._ui.speed_bar.add_mark(1.75, Gtk.PositionType.BOTTOM, '')
        self._ui.speed_bar.add_mark(2, Gtk.PositionType.BOTTOM, '2')

        self._ui.progress_label.set_xalign(1.0)

        self._connect(self._ui.seek_bar, 'change-value', self._on_seek)
        self._connect(self._ui.seek_bar, 'value-changed', self._on_seek_bar_moved)
        self._connect(self._ui.rewind_button, 'clicked', self._on_rewind_clicked)
        self._connect(self._ui.play_pause_button, 'clicked', self._on_play_clicked)
        self._connect(self._ui.forward_button, 'clicked', self._on_forward_clicked)
        self._connect(self._ui.speed_dec_button, 'clicked', self._on_speed_dec_clicked)
        self._connect(self._ui.speed_inc_button, 'clicked', self._on_speed_inc_clicked)
        self._connect(self._ui.speed_bar, 'change-value', self._on_speed_change)

        gesture_seek_click = Gtk.GestureClick(
            button=Gdk.BUTTON_PRIMARY, propagation_phase=Gtk.PropagationPhase.CAPTURE
        )
        self._connect(gesture_seek_click, 'pressed', self._on_seek_bar_button_pressed)
        self._connect(gesture_seek_click, 'released', self._on_seek_bar_button_released)
        self._ui.seek_bar_box.add_controller(gesture_seek_click)

        gesture_visualizer_click = Gtk.GestureClick(
            button=Gdk.BUTTON_PRIMARY, propagation_phase=Gtk.PropagationPhase.CAPTURE
        )
        self._connect(
            gesture_visualizer_click, 'pressed', self._on_visualizer_button_pressed
        )
        self._audio_visualizer.add_controller(gesture_visualizer_click)

        controller_motion = Gtk.EventControllerMotion()
        self._connect(controller_motion, 'motion', self._on_seek_bar_cursor_move)
        self._ui.seek_bar.add_controller(controller_motion)

        controller_scroll = Gtk.EventControllerScroll(
            flags=Gtk.EventControllerScrollFlags.VERTICAL
        )
        self._connect(controller_scroll, 'scroll', self._on_seek_bar_scrolled)
        self._ui.seek_bar.add_controller(controller_scroll)

        gesture_timestamp_click = Gtk.GestureClick(button=Gdk.BUTTON_PRIMARY)
        self._connect(
            gesture_timestamp_click, 'pressed', self._on_timestamp_label_clicked
        )
        self._ui.progress_label.add_controller(gesture_timestamp_click)

    def do_unroot(self) -> None:
        self._disconnect_all()

        if self._playbin is not None:
            self._playbin.set_state(Gst.State.NULL)
            bus = self._playbin.get_bus()

            if bus is not None:
                bus.remove_signal_watch()
                bus.disconnect(self._bus_watch_id)

        if self._audio_analyzer is not None:
            self._audio_analyzer.destroy()

        self._remove_seek_bar_update_idle()

        app.preview_manager.unregister_audio_stop_func(self._id)
        Gtk.Box.do_unroot(self)
        app.check_finalize(self)

    def load_audio_file(self, file_path: Path) -> None:
        assert self._playbin is not None

        self._playbin.send_event(Gst.Event.new_eos())
        self._playbin.set_state(Gst.State.NULL)

        self._playbin.set_property('uri', file_path.as_uri())

        self._audio_analyzer = AudioAnalyzer(
            file_path, self._update_duration, self._update_samples)

        self._playbin.set_state(Gst.State.READY)
        self._is_ready = True
        self._next_state_is_playing = False
        self._seek(-1.0)
        self._query.set_position(Gst.Format.TIME, 0)

        self._ui.seek_bar.set_value(0.0)
        self._update_ui()

    def set_pause(self, paused: bool) -> None:
        assert Gst is not None
        assert self._playbin is not None
        if paused:
            self._playbin.set_state(Gst.State.PAUSED)
            self._remove_seek_bar_update_idle()
            self._ui.play_icon.set_from_icon_name(
                'media-playback-start-symbolic')
        else:
            self._playbin.set_state(Gst.State.PLAYING)
            self._add_seek_bar_update_idle()
            self._ui.play_icon.set_from_icon_name(
                'media-playback-pause-symbolic')

    def _enable_controls(self, status: bool) -> None:
        self._ui.seek_bar.set_sensitive(status)
        self._ui.progress_label.set_sensitive(status)
        self._ui.play_pause_button.set_sensitive(status)
        self._ui.rewind_button.set_sensitive(status)
        self._ui.forward_button.set_sensitive(status)
        self._ui.speed_dec_button.set_sensitive(status)
        self._ui.speed_inc_button.set_sensitive(status)
        self._ui.speed_menubutton.set_sensitive(status)

    def _update_ui(self) -> None:

        if not self._state.duration > 0:
            log.debug('Could not successfully load audio. Duration is zero.')
            return

        self._enable_controls(True)

        self._audio_visualizer.render_static_graph(
            self._state.position / self._state.duration)

        self._ui.seek_bar_adj.configure(
            value=self._state.position,
            lower=0.0,
            upper=self._state.duration,
            step_increment=5e9,  # for hold+position left/right
            page_increment=0,  # determines scrolling and click behaviour
            page_size=0
        )

        # Calculate max string length to prevent timestamp label from jumping
        formatted = format_duration(0.0, self._state.duration)
        self._ui.progress_label.set_width_chars(
            len(f'-{formatted}/{formatted}'))
        self._update_timestamp_label()

    def _update_samples(self,
                        samples: AudioSampleT,
                        ) -> None:
        self._state.samples = samples
        self._audio_visualizer.set_samples(self._state.samples)
        self._state.is_audio_analyzed = True
        self._update_ui()

    def _update_duration(self, duration: float):
        self._state.duration = duration
        self._update_ui()

    def _setup_audio_player(self, file_path: Path) -> None:
        assert self._playbin is not None

        # Set up the whole pipeline
        # For reference see
        # https://gstreamer.freedesktop.org/
        # documentation/audiofx/scaletempo.html
        audio_sink = Gst.Bin.new('audiosink')
        audioconvert = Gst.ElementFactory.make('audioconvert', 'audioconvert')
        scaletempo = Gst.ElementFactory.make('scaletempo', 'scaletempo')
        audioresample = Gst.ElementFactory.make(
            'audioresample', 'audioresample')
        autoaudiosink = Gst.ElementFactory.make(
            'autoaudiosink', 'autoaudiosink')

        pipeline_elements = [
            audio_sink,
            audioconvert,
            scaletempo,
            audioresample,
            autoaudiosink
        ]

        if any(element is None for element in pipeline_elements):
            # If it fails there will be
            # * a delay until playback starts
            # * a chipmunk effect when speeding up the playback
            log.warning('Could not set up full audio preview pipeline.')
        else:
            assert autoaudiosink is not None
            assert audioconvert is not None
            assert scaletempo is not None
            assert audioresample is not None

            # On Windows the first fraction of an audio
            # would not play if not synced.
            # On Linux there can be a delay before playback starts with sync,
            # which is however not the case on Windows.
            if sys.platform == 'win32':
                autoaudiosink.set_property('sync', True)
            else:
                autoaudiosink.set_property('sync', False)

            audio_sink.add(audioconvert)
            audio_sink.add(scaletempo)
            audio_sink.add(audioresample)
            audio_sink.add(autoaudiosink)

            audioconvert.link(scaletempo)
            scaletempo.link(audioresample)
            audioresample.link(autoaudiosink)

            sink_pad = audioconvert.get_static_pad('sink')
            assert sink_pad is not None
            ghost_pad = Gst.GhostPad.new('sink', sink_pad)
            assert ghost_pad is not None
            audio_sink.add_pad(ghost_pad)
            self._playbin.set_property('audio-sink', audio_sink)

        if file_path.is_file():
            self._playbin.set_property('uri', file_path.as_uri())
        self._playbin.no_more_pads()

        state_return = self._playbin.set_state(Gst.State.READY)
        if state_return == Gst.StateChangeReturn.FAILURE:
            log.debug('Could not setup GST playbin')
            return

        bus = self._playbin.get_bus()
        if bus is None:
            log.debug('Could not get GST Bus')
            return

        bus.add_signal_watch()
        self._bus_watch_id = bus.connect('message', self._on_bus_message)

    def _setup_audio_visualizer(self) -> None:
        width, height = self._ui.seek_bar.get_size_request()

        if width == -1:
            # width = self._ui.seek_bar.get_preferred_width()[1] TODO GTK4
            width = 100

        if height == -1:
            # height = self._ui.seek_bar.get_preferred_height()[1]
            height = 100

        width -= 2 * SEEK_BAR_PADDING
        self._audio_visualizer = AudioVisualizerWidget(
            width,
            height,
            SEEK_BAR_PADDING
        )
        self._ui.drawing_box.append(self._audio_visualizer)

    def _update_timestamp_label(self) -> None:
        cur = self._state.position
        dur = self._state.duration

        dur_str = format_duration(dur, dur)
        ltr_char = '\u200E'

        if self._state.is_timestamp_positive:
            cur_str = f'{format_duration(cur, dur)}'
            self._ui.progress_label.set_text(f'{cur_str}/{dur_str}')
        else:
            cur_str = f'{format_duration(dur - cur, dur)}'
            self._ui.progress_label.set_text(f'{ltr_char}-{cur_str}/{dur_str}')

    def _update_seek_bar_and_visualisation(self) -> bool:
        assert self._playbin is not None
        if self._playbin.query(self._query):
            _fmt, position = self._query.parse_position()

            if not self._pause_seek:
                self._state.position = position
                self._ui.seek_bar.set_value(self._state.position)

            self._audio_visualizer.render_static_graph(
                position / self._state.duration,
                self._seek_pos / self._state.duration)

            if self._state.is_eos:
                self._audio_visualizer.render_static_graph(
                    self._state.position / self._state.duration)
                self._remove_seek_bar_update_idle()

        return True

    def _add_seek_bar_update_idle(self) -> None:
        if self._timeout_id != -1:
            return

        self._timeout_id = \
            GLib.timeout_add(self._timeout_delay,
                             self._update_seek_bar_and_visualisation)

    def _remove_seek_bar_update_idle(self) -> None:
        if self._timeout_id != -1:
            GLib.source_remove(self._timeout_id)
        self._timeout_id = -1

    def _get_constrained_position(self, pos: float) -> float:
        if pos >= self._state.duration:
            return self._state.duration
        if pos < 0:
            return 0.0
        return pos

    def _get_constrained_speed(self, speed: float) -> tuple[bool, float]:
        if self._speed_min <= speed <= self._speed_max:
            return True, speed
        return False, self._state.speed

    def _set_playback_speed(self, speed: float) -> bool:
        success, self._state.speed = self._get_constrained_speed(speed)
        if not success:
            return False

        self._ui.speed_label.set_text(f'{self._state.speed:.2f}x')

        assert self._playbin is not None
        self._playbin.seek(self._state.speed,
                           Gst.Format.TIME,
                           Gst.SeekFlags.FLUSH,
                           Gst.SeekType.SET,
                           int(self._state.position),
                           Gst.SeekType.NONE,
                           0)
        return True

    def _get_paused(self) -> bool:
        assert self._playbin is not None
        _, state, _ = self._playbin.get_state(timeout=40)
        return state == Gst.State.PAUSED

    def _get_ready(self) -> bool:
        assert self._playbin is not None
        _, state, _ = self._playbin.get_state(timeout=40)
        return state == Gst.State.READY

    def _pause_on_eos(self) -> bool:
        assert self._playbin is not None
        self._ui.play_icon.set_from_icon_name('media-playback-start-symbolic')

        self._state.position = self._state.duration
        self._playbin.set_state(Gst.State.PAUSED)

        return False

    def _error_info(self, message: Gst.Message) -> tuple[str, int]:
        structure = message.get_structure()
        assert structure is not None
        gerror = structure.get_value('gerror')
        assert gerror is not None
        return gerror.domain, gerror.code

    def _handle_error_on_playback(self, message: Gst.Message):
        domain, code = self._error_info(message)
        if domain == 'gst-resource-error-quark':
            if code == 10:
                # On Windows: AUDCLNT_E_DEVICE_INVALIDATED (10)
                # GST_RESOURCE_ERROR_WRITE (10)
                # used when the resource can't be written to.
                self._set_ready()

    def _set_ready(self) -> None:
        assert self._playbin is not None
        self._playbin.set_state(Gst.State.READY)
        self._is_ready = True

        # State order is READY -> PAUSE -> PLAYING
        # I.e. we need to pause first, but keep in mind, that we want to
        # go a state further
        self._next_state_is_playing = True

        self._ui.play_icon.set_from_icon_name(
            'media-playback-start-symbolic')

    def _seek(self, position: float) -> None:
        '''
        Used in:
        * _on_seek: When the slider is dragged
        * _on_seek_bar_button_released:
        * _on_play_clicked
        '''
        assert self._playbin is not None

        self._state.position = self._get_constrained_position(position)
        self._state.is_eos = False

        if self._pause_seek:
            return

        self._playbin.seek(self._state.speed,
                           Gst.Format.TIME,
                           Gst.SeekFlags.FLUSH,
                           Gst.SeekType.SET,
                           int(self._state.position), Gst.SeekType.NONE, 0
                           )

        if self._state.position >= self._state.duration:
            self._state.is_eos = True
            self._playbin.send_event(Gst.Event.new_eos())

    def _seek_unconditionally(self, position: float) -> None:
        '''
        Used in:
        * _on_seek_bar_button_pressed
        * _on_rewind_clicked
        * _on_forward_clicked
        '''
        assert self._playbin is not None

        self._state.position = self._get_constrained_position(position)
        self._state.is_eos = self._state.position >= self._state.duration

        if not self._is_ready:
            self._playbin.seek(self._state.speed,
                               Gst.Format.TIME,
                               Gst.SeekFlags.FLUSH,
                               Gst.SeekType.SET,
                               int(self._state.position),
                               Gst.SeekType.NONE,
                               0)

        self._ui.seek_bar.set_value(self._state.position)
        self._audio_visualizer.render_static_graph(
            self._state.position / self._state.duration)

    def _on_bus_message(self, _bus: Gst.Bus, message: Gst.Message) -> None:
        assert self._playbin is not None

        if message.type == Gst.MessageType.EOS:
            self._state.is_eos = True
            GLib.timeout_add(self._pause_on_eos_delay, self._pause_on_eos)
        elif message.type == Gst.MessageType.STATE_CHANGED:
            is_paused = self._get_paused()

            if self._is_ready and is_paused:
                # State changed from READY --> PAUSED
                self._is_ready = False
                self._playbin.seek(self._state.speed,
                                   Gst.Format.TIME,
                                   Gst.SeekFlags.FLUSH,
                                   Gst.SeekType.SET,
                                   int(self._state.position),
                                   Gst.SeekType.NONE,
                                   0)
                self._ui.seek_bar.set_value(self._state.position)

                if not self._next_state_is_playing:
                    return

                # Continue from state PAUSED --> PLAYING
                self.set_pause(False)
                self._next_state_is_playing = False
        elif message.type == Gst.MessageType.ERROR:
            self._handle_error_on_playback(message)

    def _on_speed_change(self,
                         _range: Gtk.Range,
                         _scroll: Gtk.ScrollType,
                         value: float
                         ) -> None:

        self._set_playback_speed(value)

    def _on_speed_inc_clicked(self, _button: Gtk.Button) -> None:
        speed = self._state.speed + self._speed_inc_step
        if self._set_playback_speed(speed):
            self._ui.speed_bar.set_value(speed)

    def _on_speed_dec_clicked(self, _button: Gtk.Button) -> None:
        speed = self._state.speed - self._speed_inc_step
        if self._set_playback_speed(speed):
            self._ui.speed_bar.set_value(speed)
        else:
            log.debug('Could not set speed!')

    def _on_seek_bar_moved(self, _scake: Gtk.Scale) -> None:
        self._update_timestamp_label()

    def _on_timestamp_label_clicked(
        self,
        _gesture_click: Gtk.GestureClick,
        _n_press: int,
        _x: float,
        _y: float,
    ) -> int:
        self._state.is_timestamp_positive = \
            not self._state.is_timestamp_positive
        self._update_timestamp_label()
        return Gdk.EVENT_STOP

    def _on_seek_bar_button_pressed(
        self,
        _gesture_click: Gtk.GestureClick,
        _n_press: int,
        _x: float,
        _y: float,
    ) -> int:
        assert self._cursor_pos is not None
        # There are two cases when the user clicks on the seek bar:
        # 1) Press and immediately release: Jump to the new position and
        #   continue playing
        # 2) Start of dragging the slider
        # In case of 2) pause active seeking to prevent audio scrubbing and
        # instead continue playing
        self._pause_seek = True
        width = self._ui.seek_bar.get_width() - 2 * SEEK_BAR_PADDING
        new_pos = self._state.duration * self._cursor_pos / width
        self._seek_unconditionally(new_pos)
        return Gdk.EVENT_STOP

    def _on_visualizer_button_pressed(
        self,
        _gesture_click: Gtk.GestureClick,
        _n_press: int,
        x: float,
        _y: float,
    ) -> int:
        assert self._cursor_pos is not None
        width = self._audio_visualizer.get_width() - SEEK_BAR_PADDING
        new_pos = self._state.duration * x / width
        self._seek_unconditionally(new_pos)
        return Gdk.EVENT_STOP

    def _on_seek_bar_button_released(
        self,
        _gesture_click: Gtk.GestureClick,
        _n_press: int,
        _x: float,
        _y: float,
    ) -> int:
        self._pause_seek = False
        self._seek(self._state.position)

        # Set the seek position to -1 to indicate that the user isn't about
        # to change the position
        self._seek_pos = -1
        return Gdk.EVENT_STOP

    def _on_seek_bar_cursor_move(
        self,
        _event_controller: Gtk.EventControllerMotion,
        x: float,
        _y: float,
    ) -> None:
        # Used to determine the click position on the seekbar
        if self._is_LTR:
            self._cursor_pos = x - SEEK_BAR_PADDING
        else:
            width = self._ui.seek_bar.get_width()
            self._cursor_pos = width - (x + SEEK_BAR_PADDING)

    def _on_seek_bar_scrolled(
        self,
        _event_controller: Gtk.EventControllerScroll,
        _dx: float,
        dy: float,
    ) -> None:
        if dy > 0:
            new_pos = self._state.position + self._offset_backward
        else:
            new_pos = self._state.position + self._offset_forward
        self._seek_unconditionally(new_pos)

    def _on_seek(self,
                 _scale: Gtk.Scale,
                 _scroll: Gtk.ScrollType,
                 value: float
                 ) -> None:
        self._seek(value)

        if not (self._get_paused() or self._get_ready()):
            self._seek_pos = value
        else:
            self._audio_visualizer.render_static_graph(
                self._state.position / self._state.duration)

    def _on_play_clicked(self, _button: Gtk.Button) -> None:
        app.preview_manager.stop_audio_except(self._id)
        if self._get_ready():
            # The order is always READY -> PAUSE -> PLAYING
            self.set_pause(True)
            self._next_state_is_playing = True

            if self._state.is_eos:
                new_pos = 0.0
            else:
                new_pos = self._state.position

            self._seek(new_pos)
            self._query.set_position(Gst.Format.TIME, int(new_pos))
            self._ui.seek_bar.set_value(new_pos)
            return

        if self._state.is_eos and self._get_paused():
            self._seek(0.0)
            self._ui.seek_bar.set_value(0.0)

        self.set_pause(not self._get_paused())

    def _on_rewind_clicked(self, _button: Gtk.Button) -> None:
        new_pos = self._get_constrained_position(
            self._state.position + self._offset_backward)
        self._seek_unconditionally(new_pos)

    def _on_forward_clicked(self, _button: Gtk.Button) -> None:
        new_pos = self._get_constrained_position(
            self._state.position + self._offset_forward)
        self._seek_unconditionally(new_pos)
