#!/usr/bin/python3
# gTranscribe is a software focused on easy transcription of spoken words.
# Copyright (C) 2013-2020 Philip Rinn <rinni@inventati.org>
# Copyright (C) 2010 Frederik Elwert <frederik.elwert@web.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

# pylint: disable=wrong-import-position
import sys
import os
import re
import logging
import argparse
import locale
import datetime
import gettext
from gettext import gettext as _
import signal
import inspect
from typing import Any
import dbus
import dbus.service
from dbus.mainloop.glib import DBusGMainLoop
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Gdk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import GLib, Gtk, Gdk, Gio, Adw # NOQA: E402
gi.require_version('GtkSource', '5')
from gi.repository import GtkSource # NOQA: E402
gi.require_version('Spelling', '1')
from gi.repository import Spelling # NOQA: E402


# Add project root directory to sys.path.
current_frame = inspect.currentframe()
if current_frame is not None:
    PROJECT_ROOT_DIRECTORY = os.path.dirname(os.path.dirname(
        os.path.realpath(inspect.getfile(current_frame))))
else:
    PROJECT_ROOT_DIRECTORY = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

if PROJECT_ROOT_DIRECTORY not in sys.path:
    sys.path.insert(0, PROJECT_ROOT_DIRECTORY)

from gtranscribe.helpers import (trim, ns_to_time, time_to_ns, # NOQA: E402
    get_open_filename, get_save_filename, error_message, md5_of_file, 
    get_data_file, duration)
from gtranscribe.player import gTranscribePlayer # NOQA: E402
from gtranscribe.metadata import MetaData # NOQA: E402
from gtranscribe.mpris import MPRISInterface # NOQA: E402

locale.setlocale(locale.LC_ALL, '')
gettext.textdomain('gTranscribe')
DBusGMainLoop(set_as_default=True)

# pylint: disable=invalid-name
class gTranscribe(Adw.Application):

    def __init__(self, audiofile: str | None, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.connect('activate', self.on_activate)
        self.connect('open', self.on_open)
        self.set_flags(Gio.ApplicationFlags.HANDLES_OPEN)
        self.time_str: str = '00:00.0'

        self.oldstate: Any | None = None
        self.seeking: bool = False
        self._update_id: int | None = None

        self.filename: str | None = None
        self.md5: str | None = None

        self.position: int = 0

        # TODO: Make these configurable
        self.jump_back_interval: datetime.time | duration = datetime.time(second=1)
        self.seek_interval: datetime.time | duration = datetime.time(second=1)

        # Initialize database for meta data
        MetaData.init_db(self)

        self.mpris: MPRISInterface | None = None

        # Code for other initialization actions should be added here.
        self.player: gTranscribePlayer = gTranscribePlayer()
        self.player.connect('ready', self.on_file_ready)
        self.player.connect('ended', self.on_file_ended)
        self.player.connect('duration_changed', self.on_duration_changed)

        # Store audiofile to open after UI is initialized
        self.initial_audiofile = audiofile

    def on_activate(self, app: Adw.Application) -> None:
        """Run main application window."""
        GtkSource.init()
        ui = get_data_file(PROJECT_ROOT_DIRECTORY, 'ui', 'gTranscribe.ui')
        builder = Gtk.Builder.new_from_file(ui)
        self.window = builder.get_object('gtranscribe_window')
        app.add_window(self.window)
        self.window.connect("close-request", self.quit)
        self.window.connect("destroy", self.quit)

        # create app actions
        self.create_action("open", self.open, ("<primary>o",))
        self.create_action("open_text", self.open_text, None)
        self.create_action("save", self.save_text, ("<primary>s",))
        self.create_action("shortcuts", self.show_shortcuts, ("<primary>question",))
        self.create_action("about", self.about, None)
        self.create_action("toggle_play", self.toggle_play_action, ("<primary>space", "F9"))
        self.create_action("pause", self.pause_action, ("F4",))
        self.create_action("rewind", self.rewind, ("<primary><shift>Left",))
        self.create_action("forward", self.forward, ("<primary><shift>Right",))
        self.create_action("inc_speed", self.inc_speed, ("<primary>f",))
        self.create_action("dec_speed", self.dec_speed, ("<primary>d",))
        self.create_action("jump", self.jump, ("<primary>j",))

        self.window.present()

        self.text_view = builder.get_object("text_view")
        self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        self.text_buffer = self.text_view.get_buffer()
        checker = Spelling.Checker.get_default()
        adapter = Spelling.TextBufferAdapter.new(self.text_buffer, checker)
        extra_menu = adapter.get_menu_model()
        self.text_view.set_extra_menu(extra_menu)
        self.text_view.insert_action_group('spelling', adapter)
        adapter.set_enabled(True)
        
        # Add key event controller to text view for timestamp insertion
        key_event = Gtk.EventControllerKey.new()
        key_event.connect("key-pressed", self.on_text_insert)
        self.text_view.add_controller(key_event)

        spinbutton_jump = builder.get_object("spinbutton_jump")
        spinbutton_jump.set_value(time_to_ns(self.jump_back_interval) /
                                  1000000)
        spinbutton_jump.connect("value-changed", self.on_jump_value_changed)

        self.rewind_button = builder.get_object('button_seek_back')
        self.rewind_button.set_sensitive(False)
        self.rewind_button.connect("clicked", self.rewind)
        self.forward_button = builder.get_object('button_seek_forward')
        self.forward_button.set_sensitive(False)
        self.forward_button.connect("clicked", self.forward)

        self.speedscale = builder.get_object('scale_speed')
        self.speedscale.set_sensitive(False)
        self.speedscale.connect("value-changed", self.on_scale_speed_value_changed)

        self.play_action = builder.get_object("button_play")
        self.play_action.set_sensitive(False)
        self.play_action.connect("toggled", self.play)
        self.slider = builder.get_object('scale_position')
        self.slider.set_sensitive(False)
        self.slider.connect("value-changed", self.on_scale_position_value_changed)

        self.dur_label = builder.get_object('label_duration')
        self.pos_label = builder.get_object('label_position')
        self.dur_label.set_text(self.time_str)
        self.pos_label.set_text(self.time_str)

        volumebutton = builder.get_object("volumebutton")
        volumebutton.connect("value-changed", self.on_volumebutton_value_changed)

        # Register media keys after window is created
        self.register_media_keys()

        # Open initial audio file if provided
        if self.initial_audiofile:
            GLib.idle_add(self.open_audio_file, self.initial_audiofile)

    def on_open(self, app: Adw.Application, files: list[Gio.File], n_files: int, hint: str) -> None:
        """Handle opening files from command line or file manager."""
        # Ensure window is created (activate only if no windows exist)
        if not self.get_windows():
            self.activate()
        if files:
            file = files[0]
            audiofile = file.get_path()
            GLib.idle_add(self.open_audio_file, audiofile)

    def _get_update_ui(self) -> bool:
        return self._update_id is not None

    def _set_update_ui(self, update: bool) -> None:
        if update:
            self._update_id = GLib.timeout_add(50, self.play_loop)
        else:
            # run play_loop one more time to make sure UI is up to date.
            self.play_loop()
            try:
                GLib.source_remove(self._update_id)
            except TypeError:
                # source no longer available, do nothing
                pass
            self._update_id = None

    update_ui = property(_get_update_ui, _set_update_ui)

    def register_media_keys(self) -> None:
        """Register media keys via MPRIS D-Bus interface."""
        try:
            session_bus = dbus.SessionBus()
            bus_name = dbus.service.BusName('org.mpris.MediaPlayer2.gTranscribe',
                                           bus=session_bus)
            self.mpris = MPRISInterface(bus_name, self)
            logger.debug("MPRIS media keys registered successfully")
        except Exception as e:
            logger.debug("Couldn't register MPRIS media keys: %s", e)
            self.mpris = None

    # pylint: disable=unused-argument
    def about(self, action: Gio.SimpleAction, parameter: Any | None) -> None:
        about_dialog = Adw.AboutDialog()
        about_dialog.set_application_name("gTranscribe")
        about_dialog.set_version("0.21.1")
        about_dialog.set_copyright("\u00A9 2013-2026 Philip Rinn\n"
                                   "\u00A9 2010 Frederik Elwert")
        about_dialog.set_comments(_("gTranscribe is a software focused "
                                    "on easy transcription of spoken words."))
        about_dialog.set_website("https://github.com/innir/gtranscribe")
        about_dialog.set_developer_name("Philip Rinn")
        about_dialog.set_license_type(Gtk.License(Gtk.License.GPL_3_0))
        about_dialog.present()

    # pylint: disable=unused-argument
    def show_shortcuts(self, action: Gio.SimpleAction, parameter: Any | None) -> None:
        shortcuts_ui = get_data_file(PROJECT_ROOT_DIRECTORY, 'ui', 'shortcuts.ui')
        builder = Gtk.Builder.new_from_file(shortcuts_ui)
        shortcuts_window = builder.get_object('shortcuts_window')
        shortcuts_window.set_transient_for(self.window)
        shortcuts_window.present()

    # pylint: disable=unused-argument
    def open(self, action: Gio.SimpleAction, parameter: Any | None) -> None:
        get_open_filename(self, _('Open Audio File'), _('All Audio Files'), 'audio/*', self.open_file)

    def open_file(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
        try:
            file = dialog.open_finish(result)
            if file is not None:
                audiofile = file.get_path()
                self.open_audio_file(audiofile)
        except GLib.Error as error:
            logger.error(f"Error opening file: {error.message}")

    def open_audio_file(self, audiofile: str) -> None:
        """Open an audio file from a file path."""
        if hasattr(self, 'play_action'):
            self.play_action.set_active(False)
            self.slider.set_value(0)
        self.md5 = md5_of_file(audiofile)
        # insert md5 into database so we can just update afterwards
        MetaData.store_md5(self)
        self.player.open(audiofile)

    # pylint: disable=unused-argument
    def on_file_ready(self, sig: gTranscribePlayer, audiofile: str) -> None:
        logger.debug('received signal "ready"')
        GLib.idle_add(self.update_file, audiofile)

    def on_duration_changed(self, sig: gTranscribePlayer | None) -> None:
        if sig is not None:
            logger.debug('received signal "duration_changed"')
        duration = ns_to_time(self.player.duration)
        if duration.hour:
            self.time_str = '%H:%M:%S.%f'
        else:
            self.time_str = '%M:%S.%f'
        # set duration
        dur_str = trim(duration.strftime(self.time_str))
        self.dur_label.set_text(dur_str)
        # set position
        self.set_position_label(time_to_ns(duration))

    def update_file(self, audiofile: str) -> None:
        self.position = 0
        assert self.md5 is not None
        fileinfo = MetaData(audiofile, self.md5)
        if fileinfo.position:
            logger.debug('Resuming at position %s',
                         ns_to_time(fileinfo.position))
            self.player.position = fileinfo.position
            self.position = fileinfo.position
        if fileinfo.speed:
            logger.debug('Resuming with speed %s', fileinfo.speed)
            self.speedscale.set_value(fileinfo.speed)
        # Query duration - retry if not available yet
        duration = self.player.duration
        if duration > 0:
            self.on_duration_changed(None)
        else:
            # Duration not available yet, try again in a moment
            GLib.timeout_add(100, self._update_file_duration)
        # set window title
        filename = os.path.basename(audiofile)
        self.window.set_title(f"gTranscribe \u2013 {filename}")
        self.play_action.set_sensitive(True)
        self.slider.set_sensitive(True)
        self.rewind_button.set_sensitive(True)
        self.forward_button.set_sensitive(True)
        self.speedscale.set_sensitive(True)

    def _update_file_duration(self) -> bool:
        """Retry updating duration if it wasn't available initially."""
        duration = self.player.duration
        if duration > 0:
            self.on_duration_changed(None)
            return False  # Stop retrying
        return True  # Keep retrying

    # pylint: disable=unused-argument
    def on_file_ended(self, sig: gTranscribePlayer) -> None:
        logger.debug('received signal "ended"')
        # Automatically save text if a filename is given to avoid data loss
        if self.filename is not None:
            self.save_text(self.window)
        self.player.reset()
        self.play_action.set_active(False)

    # pylint: disable=unused-argument
    def toggle_play_action(self, action: Gio.SimpleAction, parameter: Any | None = None) -> None:
        """Toggle play button when activated via shortcut"""
        if self.slider.is_sensitive():
            if self.play_action.get_active():
                self.play_action.set_active(False)
            else:
                self.play_action.set_active(True)

    def pause_action(self, action: Gio.SimpleAction, parameter: Any | None = None) -> None:
        """Pause playback"""
        self.play_action.set_active(False)

    def play(self, action: Gtk.ToggleButton) -> None:
        if action.get_active():
            logger.debug('play action triggered')
            self.play_action.set_icon_name("media-playback-pause-symbolic")
            #self.play_menu.set_label(_("Pause"))
            # It's not resuming at the correct position if we don't set the
            # position explicitly
            self.player.position = self.position
            self.player.play()
            self.window.update_ui = True
            self._set_update_ui(True)
        else:
            logger.debug('pause action triggered')
            self.play_action.set_icon_name("media-playback-start-symbolic")
            #self.play_menu.set_label(_("Play"))
            self.window.update_ui = False
            self._set_update_ui(False)
            self.player.pause()
            self.player.move_position(-time_to_ns(self.jump_back_interval))
            GLib.idle_add(self.play_loop, True)
            if self.md5 is not None:
                fileinfo = MetaData(self.player.filename, self.md5)
                fileinfo.position = self.position

    def play_loop(self, once: bool = False, update_scale: bool = True) -> bool:
        try:
            self.position = self.player.position
        except (AttributeError, TypeError) as e:
            logger.warning("query failed, can't get current position: %s", e)
            return False
        try:
            duration = self.player.duration
        except (AttributeError, TypeError) as e:
            logger.warning("query failed, can't get file duration: %s", e)
            return False
        self.set_position_label(duration, update_scale)
        if once:
            return False
        return True

    def set_position_label(self, duration: int, update_scale: bool = True) -> None:
        if duration > 0:
            frac = float(self.position) / float(duration)
            if update_scale:
                self.seeking = False
                scalepos = frac * self.slider.get_adjustment().get_upper()
                self.slider.set_value(scalepos)
            pos_str = trim(ns_to_time(self.position).strftime(self.time_str))
            self.pos_label.set_text(pos_str)
        else:
            logger.warning("Can't update position, don't know the duration")

    def dec_speed(self, action: Gio.SimpleAction, parameter: Any | None = None) -> None:
        self.speedscale.set_value(self.speedscale.get_value() - 0.1)

    def inc_speed(self, action: Gio.SimpleAction, parameter: Any | None = None) -> None:
        self.speedscale.set_value(self.speedscale.get_value() + 0.1)

    def forward(self, action: Any | None = None, parameter: Any | None = None) -> None:
        self.player.move_position(time_to_ns(self.seek_interval))
        GLib.idle_add(self.play_loop, True)

    def rewind(self, action: Any | None = None, parameter: Any | None = None) -> None:
        self.player.move_position(-time_to_ns(self.seek_interval))
        GLib.idle_add(self.play_loop, True)

    # pylint: disable=unused-argument
    def jump(self, action: Gio.SimpleAction, parameter: Any | None = None) -> None:
        # Only do this if an audio file is already loaded
        if self.md5 is not None:
            # Get the current cursor position
            position = self.text_buffer.get_iter_at_mark(
                self.text_buffer.get_insert())
            # Get the cursor position relative  to the beginning of this line
            line_offset = position.get_line_offset()
            # Get beginning of the line
            line_start = position.get_offset() - line_offset
            # Get the text at the end of the last line
            pos = self.text_buffer.get_text(
                self.text_buffer.get_iter_at_offset(line_start - 10),
                self.text_buffer.get_iter_at_offset(line_start - 1), True)
            logger.debug('Try to get the position from %s', pos)
            # Match both [HH:MM:SS.F] or [MM:SS.F] and #HH:MM:SS-F# or #MM:SS-F# formats
            # pylint: disable=anomalous-backslash-in-string
            pos_tag = re.compile(r'.*[\[#]((\d?\d:)?\d\d:\d\d([-.]\d)?)[\]#]')
            match = pos_tag.match(pos)
            if match:
                pos = match.group(1).replace("-", ".")
                if len(pos) == 10:
                    time_str = "%H:%M:%S.%f"
                else:
                    time_str = "%M:%S.%f"
                self.player.position = time_to_ns(
                    datetime.datetime.strptime(pos, time_str).time())
                GLib.idle_add(self.play_loop, True)
                self.text_buffer.place_cursor(
                    self.text_buffer.get_iter_at_offset(line_start))
                logger.debug('Set position to %s', pos)

    def on_scale_speed_value_changed(self, speed: Gtk.Scale) -> None:
        value = speed.get_value()
        if (value != self.player.rate) and (self.md5 is not None):
            self.player.rate = value
            fileinfo = MetaData(self.player.filename, self.md5)
            fileinfo.speed = value

    def on_scale_position_value_changed(self, rel_position: Gtk.Scale) -> None:
        if not self.seeking:
            # Slider changed programmatically, enable seeking for next user change
            self.seeking = True
            return
        value = rel_position.get_value()
        max_value = self.slider.get_adjustment().get_upper()
        new_position = self.player.duration * (value / max_value)
        self.player.position = new_position
        # Update only position label
        GLib.idle_add(self.play_loop, True, False)

    # pylint: disable=unused-argument
    def on_volumebutton_value_changed(self, scalebutton: Gtk.VolumeButton, value: float) -> None:
        self.player.volume = value

    def quit(self, widget: Adw.ApplicationWindow, data: Any | None = None) -> None:
        """Signal handler for closing the gTranscribeWindow."""
        self.on_destroy(widget, data=None)

    # pylint: disable=unused-argument
    def on_destroy(self, widget: Adw.ApplicationWindow, data: Any | None = None) -> None:
        """Called when the gTranscribeWindow is closed."""
        # Clean up code for saving application state should be added here.
        if self.player.filename is not None and self.md5 is not None:
            fileinfo = MetaData(self.player.filename, self.md5)
            fileinfo.position = self.position
            fileinfo.speed = self.player.rate
        if self.mpris is not None:
            self.mpris.remove_from_connection()

    def on_jump_value_changed(self, range: Gtk.SpinButton) -> None:
        self.jump_back_interval = ns_to_time(range.get_value_as_int() * 1000000)

    # pylint: disable=unused-argument
    def open_text(self, action: Gio.SimpleAction, parameter: Any | None) -> None:
        """
        Called when the user clicks the 'Open Text' menu.
        The previous contents of the GtkTextView is overwritten.
        """
        self.filename = None
        get_open_filename(self, _("Open Text File"),
                                          _('Plain Text Files'), 'text/plain', self.open_text_callback)
    
    def open_text_callback(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
        try:
            file = dialog.open_finish(result)
            if file is not None:
                self.filename = file.get_path()
        except GLib.Error as error:
            logger.error(f"Error opening file: {error.message}")

        if self.filename is not None:
            # get the file contents
            with open(file=self.filename, mode="r", encoding="utf_8") as fin:
                text = fin.read()
            # Only do this if an audio file is already loaded
            if self.md5 is not None:
                # Try to get the last position
                pos = text[-15:]
                logger.debug('Try to get the last position from %s', pos)
                # pylint: disable=anomalous-backslash-in-string
                pos_tag = re.compile(r'.*[\[#]((\d?\d:)?\d\d:\d\d([-.]\d)?)[\]#]')
                match = pos_tag.match(pos)
                if match:
                    pos = match.group(1).replace("-", ".")
                    if len(pos) == 10:
                        time_str = "%H:%M:%S.%f"
                    else:
                        time_str = "%M:%S.%f"
                    self.player.position = time_to_ns(
                        datetime.datetime.strptime(pos, time_str).time())
                    GLib.idle_add(self.play_loop, True)
                    logger.debug('Set position on load to %s', pos)
            # disable the text view while loading the buffer with the text
            self.text_view.set_sensitive(False)
            self.text_buffer.set_text(text)
            self.text_buffer.set_modified(False)
            self.text_view.set_sensitive(True)
            self.text_view.grab_focus()
            GLib.idle_add(self.text_view.scroll_mark_onscreen,
                          self.text_buffer.get_insert())

    # pylint: disable=unused-argument
    def save_text(self, action: Any, parameter: Any | None = None) -> None:
        """
        Called when the user clicks the 'Save' menu. We need to allow the
        user to choose a file to save if it's an untitled document.
        """
        if self.filename is None:
            get_save_filename(self)
        else:
            self.save_text_real()
    
    def save_dialog_callback(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
        try:
            file = dialog.save_finish(result)
            if file is not None:
                self.filename = file.get_path()
                self.save_text_real()
        except GLib.Error as error:
            logger.error(f"Error saving file: {error.message}")

    def save_text_real(self) -> None:
        if self.filename is None:
            return
        try:
            # disable text view while getting contents of buffer
            self.text_view.set_sensitive(False)
            text = self.text_buffer.get_text(self.text_buffer.get_start_iter(),
                                             self.text_buffer.get_end_iter(),
                                             True)
            self.text_view.set_sensitive(True)
            self.text_buffer.set_modified(False)
            with open(file=self.filename, mode="w", encoding="utf_8") as fout:
                fout.write(text)
            self.text_view.grab_focus()
        except OSError as e:
            # error writing file, show message to user
            logger.error("Could not save file %s: %s", self.filename, e)
            error_message(self, f"Could not save file: {self.filename}")

    # pylint: disable=unused-argument
    def on_text_insert(self, event: Gtk.EventControllerKey, keyval: int, keycode: int, state: Gdk.ModifierType) -> bool:
        keyname = Gdk.keyval_name(keyval)
        if (keyname in ('Return', 'F8')) and self.position > 0:
            pos_str = ' #' + trim(ns_to_time(
                self.position).strftime(self.time_str.replace(".", "-"))) + '#'
            self.text_buffer.insert_at_cursor(pos_str)
            # For Return key, also insert newline; for F8, just the timestamp
            if keyname == 'Return':
                self.text_buffer.insert_at_cursor('\n')
            return True  # Consume the event
        return False  # Let other keys propagate

    def create_action(self, name: str, callback: Any, shortcuts: tuple[str, ...] | None) -> None:
        action = Gio.SimpleAction.new(name, None)
        action.connect("activate", callback)
        self.add_action(action)
        if shortcuts:
            self.set_accels_for_action(f"app.{name}", shortcuts)


if __name__ == "__main__":
    # Support for command line options
    parser = argparse.ArgumentParser()
    parser.add_argument("-v", "--verbose", action="store_true",
                        help=_("Show debug messages"))
    parser.add_argument("file", nargs='?', help=_("Audio file to load"))
    args = parser.parse_args()

    logger = logging.getLogger('root')
    # Set the logging level to show debug messages
    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)
        logger.debug('logging enabled')

    audio_file = None
    if args.file and os.path.isfile(args.file):
        audio_file = args.file

    # Catch Ctrl+C and quit the program
    app = gTranscribe(audio_file)
    signal.signal(signal.SIGINT, lambda a, b: app.quit(None))
    GLib.timeout_add(500, lambda: True)

    # Run the application
    app.run(sys.argv)
