# mainwindow.py
#
# Copyright 2020-2025 Fabio Comuni, et al.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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 <http://www.gnu.org/licenses/>.
from typing import Optional

import json
from time import time
from gettext import gettext as _
from enum import IntEnum

from gi.repository import GLib
from gi.repository import Gio
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from gi.repository import Notify

from . import local
from . import models
from .settings import Settings
from .openwindow import OpenWindow
from .config import APP_NAME, APP_ID  # type:ignore # config is generated at compile time
from .networkconnectionmonitor import NetworkConnectionMonitor
from .widgets import LoaderPage
from .widgets import Navbar
from .widgets import LoadingOverlay
from .widgets.navbar import NavbarListItem
from .pages import PageInfo, PageList, PageEvents, PageDetails
from .utils import clean_markup


@Gtk.Template(resource_path="/net/kirgroup/confy/mainwindow.ui")
class MainWindow(Adw.ApplicationWindow):
    __gtype_name__ = "ConfyMainWindow"

    @GObject.Signal
    def conf_updated(self):
        pass

    @GObject.Property(type=bool, default=False)
    def has_recent(self):
        return self.recent_model.get_n_items() > 0

    overlay: Adw.ToastOverlay = Gtk.Template.Child()  # type: ignore
    # stack to switch between 'start', 'loader' and 'main' views
    loader_stack: Gtk.Stack = Gtk.Template.Child()  # type: ignore
    # the loader widget inside 'loader' view
    loader: LoaderPage = Gtk.Template.Child()  # type: ignore

    # main split view
    main_split_view: Adw.OverlaySplitView = Gtk.Template.Child()  # type: ignore

    # sidebar navbar
    navbar: Navbar = Gtk.Template.Child()  # type: ignore

    # main navigation view
    main_stack: Adw.NavigationView = Gtk.Template.Child()  # type: ignore

    # recent confs model and menu, in 'start' page
    recent_model: Gtk.NoSelection = Gtk.Template.Child()  # type: ignore
    recent_menu: Gio.MenuItem = Gtk.Template.Child()  # type: ignore

    # Monitor network connectivity
    nm = NetworkConnectionMonitor()

    # current opened conference
    conf: Optional[models.Conference] = None

    # dict of sent notifications.
    # Keeps Notification ojects around to be able to get activation events
    notifications: dict[str, Notify.Notification] = {}

    # we use the app timer "tick" event to automatically update cached data
    # but: we don't want to un update while an update is already running
    an_update_is_running = False

    def __init__(self, application: Gtk.Application, **kwargs):
        super().__init__(application=application, **kwargs)

        size = Settings.instance().get_size()
        self.set_default_size(*size)
        self.props.maximized = Settings.instance().get_maximized()

        # Check if there are notifications to send
        application.connect('tick', self.check_event_notification)

        application.connect('tick', self.auto_update_conf)

        # handle conf data updating, eg. reloading from remote
        self.connect('conf-updated', self._on_conf_updated)

        self._add_action("open-conference", None, None, ["<Primary>o",], self.open_conference)
        self._add_action("search", None, GLib.Variant.new_boolean(False), ["<Primary>f",], None, self._on_search_action).set_enabled(False)
        self._add_action("copy", None, None, ["<Primary>c",], lambda *_: self.copy_details_to_clipboard()).set_enabled(False)
        # open recent action, used by recent menu
        self._add_action("open-recent", "i", None, None, self._on_recent_action)

        # TODO: serve?
        self.nm.connect("notify", self._on_networkconnectionmonitor_notify)

        model = Gio.ListStore.new(models.RecentConf)
        self.recent_model.set_model(model)

        self.recentmanager = Gtk.RecentManager.get_default()
        self.recentmanager.connect("changed", self._on_recent_changed)
        self._on_recent_changed()

    def _add_action(self, name, parameter_type, state, accels, activate_cbk, state_cbk = None):
        """Simplify adding an action to the window"""
        if parameter_type is not None:
            parameter_type = GLib.VariantType.new(parameter_type)
        action = Gio.SimpleAction(
            name=name,
            parameter_type=parameter_type,
            state=state
        )

        if activate_cbk:
            action.connect("activate", activate_cbk)
        if state_cbk:
            action.connect("notify::state", state_cbk)
        self.add_action(action)
        if accels is not None:
            self.get_application().set_accels_for_action(F"win.{name}", accels)

        return action

    def _on_networkconnectionmonitor_notify(self, *_):
        # TODO: what should be here?
        ...

    def _on_recent_changed(self, *args):
        # empty recent confs model and menu...
        model = self.recent_model.get_model()
        menu = self.recent_menu
        menu.remove_all()
        while model.get_n_items() > 0:
            model.remove(0)

        # ... then rebuild model and menu
        n = 0
        for item in self.recentmanager.get_items():
            if item.has_application(APP_NAME):
                print(f"#{n} {item.get_uri()} {item.get_applications()}")
                recentconf = models.RecentConf(item)
                model.append(recentconf)
                # add menu item, trigger 'open-recent' action
                menu.append(recentconf.props.title, f"win.open-recent({n})")
                n = n + 1
        self.notify("has-recent")

    @Gtk.Template.Callback()
    def _on_recent_activated(self, listview, index):
        """Called by recent listview 'activated' signal"""
        recentconf = self.recent_model.get_item(index)
        conf = json.loads(recentconf.data)
        conf = models.Conference()._init(conf)
        # don't load conf if it is already loaded
        if self.conf is not None and self.conf.url == conf.url:
            print(f"Same url {self.conf.url} == {conf.url}. Skip")
            return
        self.load_conf(conf)

    def _on_recent_action(self, action, args):
        """
        Called by open-recent action activation

        first arg from action is int32 index in recent model
        """
        index = args.get_int32()
        self._on_recent_activated(None, index)

    def _add_recent(self, conf: models.Conference):
        """
        Add conference to recent.
        """

        # remove current conf if is already in recents
        # this way will be readded on top
        # TODO: is there a better way to do this?
        if self.recentmanager.has_item(conf.url):
            self.recentmanager.remove_item(conf.url)

        recentdata = Gtk.RecentData()
        recentdata.mime_type = "x-application/confy-event-uri"
        recentdata.app_exec = "confy"
        recentdata.is_private = True
        recentdata.app_name = APP_NAME
        recentdata.display_name = conf.title
        # misuse recentdata description to hold conf object, yay!
        recentdata.description = json.dumps(conf.to_json())

        self.recentmanager.add_full(conf.url, recentdata)

    def _send_desktop_notification(self, title: str, body: str, nid: str):
        """
        Send a notification and register it to react to user action
        """
        n = Notify.Notification.new(title, body, APP_ID)
        n.set_timeout(Notify.EXPIRES_NEVER)
        n.add_action("view", "View", self._on_notification_action, nid)
        n.show()
        self.notifications[nid] = n

    def _on_conf_updated(self, *args):
        """
        Callback when conference data is modified (eg. is reloaded)
        """
        self.an_update_is_running = False
        self.lookup_action("search").set_enabled(True)
        self.navbar.days.props.visible = models.Day.count() > 0
        self.navbar.tracks.props.visible = models.Track.count() > 0
        self.navbar.rooms.props.visible = models.Room.count() > 0

    def _on_fetcher_done(self, *_):
        """
        Callback when conference has been open from remote url
        """
        self._add_recent(self.conf)
        self.emit("conf_updated")
        self.show_main()

    def _on_fetcher_error(self, fetcher, error):
        """
        Callback when there was an error fetching conference from remote url
        """
        self.show_error(error)

    def _on_notification_action(self, notification, action, data):
        """
        User clicked on "View" in notification
        """
        print(notification, action, data)
        if action == "view" and data.startswith("nextup-"):
            self.present_with_time(time())
            eventid = data.split("-", maxsplit=1)[1]
            event = models.Event.by_id(eventid)
            page = PageDetails(obj=event, tagfmt="notif-{0.id}")
            self.main_stack.replace([page])

    @Gtk.Template.Callback()
    def _on_page_changed(self, navbar: Navbar, navitem: NavbarListItem):
        self.navigate_to(navitem)

    @Gtk.Template.Callback()
    def _on_close_requested(self, *args):
        size = self.get_default_size()
        Settings.instance().set_size(size)
        Settings.instance().set_maximized(self.props.maximized)

    @Gtk.Template.Callback()
    def _on_stack_replaced(self, stack):
        self.lookup_action("copy").set_enabled(False)
        page = stack.get_visible_page()
        if hasattr(page, "ready"):
            page.ready()

    @Gtk.Template.Callback()
    def _on_stack_page_pushed(self, stack):
        if isinstance(stack.get_visible_page(), PageDetails):
            self.lookup_action("copy").set_enabled(True)

    @Gtk.Template.Callback()
    def _on_stack_page_popped(self, stack, page):
        self.lookup_action("copy").set_enabled(False)
        print("pop", page.props.tag)
        if page.props.tag == "search":
            print("set search state False")
            self.lookup_action("search").set_state(GLib.Variant.new_boolean(False))

    def open_conference(self, *_):
        """
        Show open window to open a conference
        """
        app = self.get_application()
        win = OpenWindow(modal=True, transient_for=self, application=app)
        win.show()

    def load_conf(self, conf: models.Conference):
        """
        Load conference

        Setup/reset window to display `conf`
        """
        self.conf = conf
        # we reset navigation because 'conf-updated' is emited, but
        # we don't really have a clear way to get the updated conf in pages
        # as we cannot attach a conf to the signal, as is not a GObject.
        # So existing pages will update values but with old conf object.
        # Reset navigation will force to create a new PageInfo with the new
        # conf object
        self.navigate_to_none()

        self.show_loader()
        self.an_update_is_running = True
        try:
            res = local.openconf(conf, is_online=self.nm.get_isconnected())
        except Exception as e:
            self.show_error(e)
            return
        else:
            if res.fetcher is not None:
                # 'local.openconf' result object has a `Fetcher` if there is no local cache
                # or the local cache is expired, and data must be downloaded
                # we listen to fetcher events
                res.fetcher.connect("done", self._on_fetcher_done)
                if res.is_new:
                    # if the cache is new (first time openeing the conf, or after a migration)
                    # we _must_ download the data, and report on errors to users
                    res.fetcher.connect("error", self._on_fetcher_error)
                if not res.is_new and res.is_outdated:
                    # if the cace is not new, but it only outdated
                    # we can ignore errors go on with we already have in cache
                    res.fetcher.connect("error", self._on_fetcher_done)

                # pass the fetcher to the loader to show progress
                self.loader.set_fetcher(res.fetcher)
            else:
                # there was a valid local cache, signal change in conf,
                # and show main page
                self._on_fetcher_done()

        self.notifications = {}
        self.check_event_notification()

    def auto_update_conf(self, *args):
        """
        Update cache automatically when it expires (if we are online)
        """
        if self.conf is None:
            # no conf loaded, nothing to do
            return

        if self.an_update_is_running:
            # an update is running, nothing to do
            return

        def _on_f_done(*args):
            self.emit("conf_updated")
            toast.dismiss()

        is_online = self.nm.get_isconnected();
        if is_online and local.check_need_update():
            self.an_update_is_running = True
            f = local.updatedb(self.conf.url)
            f.connect("done", _on_f_done)
            toast = LoadingOverlay(f, _("Updating schedule..."))
            self.overlay.add_toast(toast)

    def check_event_notification(self, *args):
        """
        Notifiy starred talk staring in next 5 minutes
        """
        if self.conf is None:
            # no conf loaded, nothing to do
            return

        nextup = list(models.Event.nextup(5))
        for e in nextup:
            description = _("at {}").format(e.start_in_tz().strftime("%H:%M"))
            if e.room:
                description = _("at {} in {}").format(
                    e.start_in_tz().strftime("%H:%M"),
                    e.room
                )

            title = clean_markup(e.title)
            self._send_desktop_notification(
                _("Next up: {}").format(title),
                description,
                f"nextup-{e.id}")
            e.set_notified(True)

    def show_error(self, error=None):
        msg = Adw.MessageDialog(
            transient_for=self,
            modal=True,
            heading=_("Error loading event"),
            body=str(error) if error else "",
        )
        msg.add_response("ok", _("Ok"))
        msg.connect("response", self.open_conference)
        msg.present()

    def show_start(self):
        """Show 'start' page"""
        self.loader_stack.set_visible_child_name("start")

    def show_loader(self):
        """Show 'loader' page"""
        self.loader_stack.set_visible_child_name("loader")

    def show_main(self):
        """Show 'main' page"""
        self.navigate_to(self.navbar.info)
        self.loader_stack.set_visible_child_name("main")

    def navigate_to_none(self):
        """Remove everything from main stack"""
        self.main_stack.replace([Adw.NavigationPage()])

    def navigate_to(self, info: NavbarListItem):
        name = info.get_buildable_id()
        print("Navigate to", name)

        # we already into this navigation path?
        top = self.main_stack.find_page(name)
        if top is not None:
            self.main_stack.replace_with_tags([name])
            if self.main_split_view.get_collapsed():
                self.main_split_view.set_show_sidebar(False)
            return
        else:
        # no, start anew
            child: Adw.NavigationPage
            match name:
                case "info":
                    child = PageInfo(self, self.conf, title=info.label, tag=name)
                case "starred":
                    child = PageEvents(None, model=name, tag=name)
                case _:
                    child = PageList(self, self.conf, model=name, title=info.label, tag=name)
            self.main_stack.replace([child])

        # set navbar selection
        self.navbar.select_row(info)

        if self.main_split_view.get_collapsed():
            self.main_split_view.set_show_sidebar(False)

    def copy_details_to_clipboard(self):
        page = self.main_stack.get_visible_page()
        if isinstance(page, PageDetails):
            page.copy_to_clipboard()
            self.notification_show(_("Event details copied to the clipboard"))

    def notification_show(self, text):
        t = Adw.Toast()
        t.set_title(text)
        self.overlay.add_toast(t)

    def _on_search_action(self, action, param):
        print("search state is", action.get_state())
        page = self.main_stack.find_page("search")
        if action.get_state():
            # if state is true, push "search" page to stack
            if not page:
                page = PageEvents(None, model="search", tag="search")
            self.main_stack.push(page)
        else:
            # if state is false, and search page is in stack,
            # pop to the page before in the stack
            if page:
                prevpage = self.main_stack.get_previous_page(page)
                # Note: 'prevpage' should never be None, because search page should
                # never be first in stack, and because if we are here _is_ in the stack
                # but better safe than sorry
                if prevpage:
                    self.main_stack.pop_to_page(prevpage)

        if self.main_split_view.get_collapsed():
            self.main_split_view.set_show_sidebar(False)

