#!/usr/bin/env python3

import dbus
import dbus.mainloop.glib
import dbus.service
import getpass
import gettext
import gi
import json
import os
import re
import signal
import socket
import subprocess
import sys
import syslog

os.environ['GDK_DPI_SCALE'] = '1.5'

gi.require_version('AyatanaAppIndicator3', '0.1')
gi.require_version('Gio', '2.0')
gi.require_version('Gtk', '3.0')
gi.require_version('Notify', '0.7')
gi.require_version('Soup', '2.4')

from gi.repository import AyatanaAppIndicator3, Gdk, Gio, GLib, Gtk, Notify, Soup

gettext.bindtextdomain('puavo-exammode-setup-applet', '/usr/share/locale')
gettext.textdomain('puavo-exammode-setup-applet')
_tr = gettext.gettext

dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)

ICON_PATH = '/usr/share/icons/oxygen/base/64x64/actions/document-edit.png'

css = b"""
button {
   transition: none;
}

.callout {
  font-weight: bold;
  min-width: 8em;
}
"""
provider = Gtk.CssProvider()
provider.load_from_data(css)

Gtk.StyleContext.add_provider_for_screen(
    Gdk.Screen.get_default(),
    provider,
    Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)


def puavoconf_get(puavoconf_key):
  return subprocess.check_output([ 'puavo-conf', puavoconf_key ]).rstrip() \
                   .decode('utf-8')


class JoinChoice():
  def __init__(self, chooser):
    self.chooser = chooser
    self.label   = self.create_join_label()
    self.tab     = None


  def create_join_label(self):
     return Gtk.Label(label=self.JOIN_LABEL)


  def connect_tab(self, tab):
    self.tab = tab


  def show_confirmation(self, exam_url):
    """Show a confirmation dialog before joining an exam."""
    win = self.chooser.window

    dialog = Gtk.Dialog(
        title=_tr('Join the exam?'),
        transient_for=win,
        modal=True
    )
    dialog.set_default_size(860, -1)
    dialog.set_resizable(False)

    content = dialog.get_content_area()
    content.set_spacing(12)
    content.set_margin_top(16)
    content.set_margin_bottom(16)
    content.set_margin_start(18)
    content.set_margin_end(18)
    
    title = Gtk.Label()
    title.set_xalign(0)
    title.set_use_markup(True)
    title.set_markup('<span size="x-large" weight="bold">%s</span>'
                       % self.join_info())
    content.add(title)

    if exam_url:
      url_title = Gtk.Label()
      url_title.set_xalign(0)
      url_title.set_use_markup(True)
      url_title.set_markup('<b>%s</b>' % _tr('The exam address is'))
      content.add(url_title)

      url_label = Gtk.Label()
      url_label.set_xalign(0)
      url_label.set_selectable(True)
      url_label.set_use_markup(True)
      url_label.set_line_wrap(True)
      url_label.set_markup(
        '<span font_family="monospace" alpha="70%%">%s</span>'
          % GLib.markup_escape_text(exam_url))
      content.add(url_label)

    desc = Gtk.Label(
      label=_tr('Your current desktop session will be locked and you'
                  ' will be moved to the exam session.'))
    desc.set_xalign(0)
    desc.set_line_wrap(True)
    content.add(desc)

    notes_title = Gtk.Label()
    notes_title.set_xalign(0)
    notes_title.set_use_markup(True)
    notes_title.set_markup('<b>%s</b>' % _tr('Notice'))
    content.add(notes_title)

    notes = Gtk.Label()
    notes.set_xalign(0)
    notes.set_line_wrap(True)
    notes.set_use_markup(True)
    notes.set_markup(
      "• %s\n"
      "• %s\n"
      "• %s"
        % (_tr('Make sure you are connected to a network,'
               ' where the exam is accessible.'),
            _tr('By joining the exam you will accept that session'
                ' might be technically monitored to prevent cheating.'),
            _tr('After the exam you will exit the exam session and return to'
                ' the normal desktop session through screen unlocking.')))
    content.add(notes)

    dialog.add_button(_tr('Cancel'), Gtk.ResponseType.CANCEL)
    join_button = dialog.add_button(_tr('Join the exam'), Gtk.ResponseType.OK)
    join_button.get_style_context().add_class('suggested-action')
    dialog.set_default_response(Gtk.ResponseType.OK)

    dialog.show_all()
    response = dialog.run()
    dialog.destroy()

    self.chooser.check_joins(False)

    if response == Gtk.ResponseType.OK:
      win.hide()
      return True

    return False


class ExamTab():
  DEFAULT_JOIN_SENSITIVITY = False

  def __init__(self):
    self.join_button    = self.create_join_button()
    self.label          = self.create_tab_label()
    self.page           = self.create_page()


  def create_join_button(self):
    button = Gtk.Button(label=self.JOIN_BUTTON_LABEL,
                        sensitive=self.DEFAULT_JOIN_SENSITIVITY)
    button.set_hexpand(True)
    button.set_halign(Gtk.Align.FILL)
    button.get_style_context().add_class('suggested-action')
    button.set_tooltip_text(self.JOIN_BUTTON_TOOLTIP_TEXT)
    button.connect('clicked', self.on_join_clicked)

    return button


  def make_grid(self):
    grid = Gtk.Grid()
    grid.set_column_spacing(6)
    grid.set_row_spacing(6)
    grid.set_margin_top(6)
    grid.set_margin_bottom(6)
    grid.set_margin_start(6)
    grid.set_margin_end(6)
    return grid


  def create_tab_label(self):
     return Gtk.Label(label=self.TAB_LABEL)
     label = Gtk.Label(label=self.TAB_LABEL)
     label.set_sensitive(True)
     label.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
     return label


  def create_page(self, content):
    box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
    box.set_margin_top(6)
    box.pack_start(content, False, False, 0)
    box.pack_end(self.join_button, False, False, 0)
    return box


class AbittiTab(ExamTab):
  JOIN_BUTTON_LABEL        = _tr('Join the exam')
  JOIN_BUTTON_TOOLTIP_TEXT = _tr('Use the Alt key to override the URL check')
  TAB_LABEL                = _tr('Abitti')

  def __init__(self, abitti_url):
    self.abitti_url = abitti_url
    super().__init__()


  def on_join_clicked(self, button):
    self.abitti_url.on_join_clicked(button)


  def create_page(self):
    choice_grid = self.make_grid()
    self.abitti_url.attach_to_grid(choice_grid, 0)
    self.update_active_url_ui_element(self.abitti_url)
    return super().create_page(choice_grid)


  def update_active_url_ui_element(self, active_url):
    self.abitti_url.set_active()


class NextExamTab(ExamTab):
  DEFAULT_JOIN_SENSITIVITY = True
  JOIN_BUTTON_LABEL        = _tr('Join the exam')
  JOIN_BUTTON_TOOLTIP_TEXT = JOIN_BUTTON_LABEL
  TAB_LABEL                = _tr('Next-Exam')

  def __init__(self, next_exam):
    self.next_exam = next_exam
    super().__init__()


  def create_page(self):
    choice_grid = self.make_grid()
    self.next_exam.attach_to_grid(choice_grid, 0)
    return super().create_page(choice_grid)


  def on_join_clicked(self, button):
    self.next_exam.on_join_clicked(button)


class UrlTab(ExamTab):
  JOIN_BUTTON_LABEL        = _tr('Join the exam')
  JOIN_BUTTON_TOOLTIP_TEXT = _tr('Use the Alt key to override the URL check')
  TAB_LABEL                = _tr('URL')

  def __init__(self, clipboard_url, free_url, clipboard_url_first):
    self.clipboard_url       = clipboard_url
    self.free_url            = free_url
    self.clipboard_url_first = clipboard_url_first

    if self.clipboard_url_first:
      self.active_url = self.clipboard_url
    else:
      self.active_url = self.free_url

    super().__init__()


  def create_page(self):
    choice_grid = self.make_grid()

    if self.clipboard_url_first:
      first_url = self.clipboard_url
      second_url = self.free_url
    else:
      first_url = self.free_url
      second_url = self.clipboard_url

    first_url.attach_to_grid(choice_grid, 0)
    if second_url:
      second_url.attach_to_grid(choice_grid, 1)

    self.update_active_url_ui_element(first_url)

    return super().create_page(choice_grid)


  def update_active_url_ui_element(self, active_url):
    self.active_url = active_url

    if not (self.clipboard_url and self.free_url):
      return

    if self.active_url == self.clipboard_url:
      passive_url = self.free_url
    else:
      passive_url = self.clipboard_url

    self.active_url.set_active()
    passive_url.set_passive()


  def on_join_clicked(self, button):
    self.active_url.on_join_clicked(button)


class UrlInterface(JoinChoice):
  ENTRY_ENABLED = True

  def __init__(self, chooser):
    super().__init__(chooser)

    self.checked        = False
    self.is_active      = None
    self.joinability_id = None
    self.soup_message   = None
    self.soup_session   = Soup.Session()
    self.target_url     = None

    self.entry = Gtk.Entry(placeholder_text=self.ENTRY_PLACEHOLDER)
    if not self.ENTRY_ENABLED:
      self.entry.set_editable(False)
      self.entry.set_can_focus(False)
      self.entry.get_style_context().add_class('dim-label')
    self.entry.set_sensitive(True)
    self.entry.set_hexpand(True)
    self.entry.set_tooltip_text(self.ENTRY_TOOLTIP)
    self.entry.connect('activate',           self.on_join_activated)
    self.entry.connect('changed',            self.url_change_event)
    self.entry.connect('button-press-event', self.url_change_event)
    self.label.connect('button-press-event', self.url_change_event)


  def attach_to_grid(self, grid, row):
    grid.attach(self.label, 0, row, 1, 1)
    grid.attach(self.entry, 1, row, 1, 1)


  def join_info(self):
    return _tr('Do you want to join the exam?')


  def join_button_override(self, override_state):
    """Override the join button state based on the Alt key."""
    if not self.is_active:
      # if we are not active, we do not affect the join button state
      return

    if override_state:
      self.tab.join_button.set_sensitive(bool(self.target_url))
    else:
      self.tab.join_button.set_sensitive(self.checked)


  def on_join_activated(self, button):
    if not (self.checked and self.target_url):
      return
    self.on_join_clicked(button)


  def on_join_clicked(self, button):
    if self.show_confirmation(self.target_url):
      self.start_session()


  def start_session(self):
    self.chooser.start_url_session(self.target_url)


  def set_active(self):
    self.is_active = True
    self.entry.get_style_context().remove_class('dim-label')
    self.label.get_style_context().remove_class('dim-label')
    self.entry.get_style_context().add_class('callout')
    self.label.get_style_context().add_class('callout')


  def set_passive(self):
    self.is_active = False
    self.entry.get_style_context().remove_class('callout')
    self.label.get_style_context().remove_class('callout')
    self.entry.get_style_context().add_class('dim-label')
    self.label.get_style_context().add_class('dim-label')


  def url_change_event(self, widget, param=None):
    self.checked = False
    self.tab.join_button.set_sensitive(False)
    text = self.entry.get_text().strip()
    self.target_url = None
    self.update_target_url_from_text(text)


  def update_target_url(self, url):
    self.target_url = url
    if self.joinability_id:
      GLib.source_remove(self.joinability_id)
      self.joinability_id = None
    if self.target_url:
      self.joinability_id = GLib.timeout_add(500, self.check_joinability)
    self.tab.update_active_url_ui_element(self)


  # AbittiUrl overrides this because this is more convoluted in that case
  def update_target_url_from_text(self, text):
    self.update_target_url( self.convert_entry_to_url(text) )


  def check_joinability(self):
    self.joinability_id = None
    if not self.target_url:
      return
    self.check_url(self.target_url)


  def check_url(self, url):
    syslog.syslog(syslog.LOG_INFO, f'checking if url {url} is reachable')

    if self.soup_message:
      self.soup_session.cancel_message(self.soup_message,
                                       Soup.KnownStatusCode.CANCELLED)
      self.soup_message = None

    try:
      msg = Soup.Message.new('HEAD', url)
      user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'   \
                            + ' AppleWebKit/537.36 (KHTML, like Gecko)' \
                            + ' Chrome/134.0.0.0 Safari/537.36'
      msg.request_headers.append('User-Agent', user_agent_string)
      self.soup_message = msg
      self.soup_session.queue_message(msg, self.on_url_checked, url)
    except Exception as e:
      syslog.syslog(syslog.LOG_ERR,
                    'error in starting url check with URL %s: %s' % (url, e))


  def on_url_checked(self, session, msg, url):
    # check that the callback is for the url we are checking
    if not (msg == self.soup_message and url == self.target_url):
      return

    if msg.status_code != Soup.KnownStatusCode.OK:
      syslog.syslog(syslog.LOG_INFO,
                    'url %s failed a check: %s' % (url, msg.reason_phrase))
      return
    self.checked = True
    self.tab.join_button.set_sensitive(True)



class AbittiUrl(UrlInterface):
  ENTRY_PLACEHOLDER = _tr('Abitti server name')
  ENTRY_TOOLTIP     = _tr('Enter the Abitti server name here')
  EXAM_TYPE         = _tr('Abitti Exam')
  JOIN_LABEL        = _tr('Exam server name:')

  def __init__(self, chooser):
    super().__init__(chooser)
    self.host_resolver = Gio.Resolver.get_default()
    self.host_resolver_cancellable = None
    self.addr_resolver = Gio.Resolver.get_default()
    self.addr_resolver_cancellable = None
    self.resolver_lookup_seq = 0
    self.server_lookup_id = None


  def lookup_server_by_abitti_single_word(self, name):
    hostname = f'{name}.local'
    if self.host_resolver_cancellable:
      self.host_resolver_cancellable.cancel()
    self.host_resolver_cancellable = Gio.Cancellable()
    if self.addr_resolver_cancellable:
      self.addr_resolver_cancellable.cancel()
    self.addr_resolver_cancellable = Gio.Cancellable()

    if self.server_lookup_id:
      GLib.source_remove(self.server_lookup_id)
      self.server_lookup_id = None

    lookup_fn = lambda: self.lookup_server_fqdn_async(hostname)
    self.server_lookup_id = GLib.timeout_add(500, lookup_fn)


  def lookup_server_fqdn_async(self, hostname):
    self.resolver_lookup_seq += 1
    self.server_lookup_id = None
    self.host_resolver.lookup_by_name_async(
      hostname,
      self.host_resolver_cancellable,
      self.on_host_lookup_resolved,
      (hostname, self.resolver_lookup_seq))


  def on_host_lookup_resolved(self, resolver, result, user_data):
    hostname, resolver_lookup_seq = user_data

    # old lookup, we are not interested in this
    if resolver_lookup_seq != self.resolver_lookup_seq:
      return

    self.resolver_lookup_seq += 1

    try:
      inet_addrs = self.host_resolver.lookup_by_name_finish(result)
      if len(inet_addrs) == 0:
        syslog.syslog(syslog.LOG_WARNING,
                      f"no addresses returned when looking up '{hostname}'")
        return
      address = inet_addrs[0]
      self.addr_resolver.lookup_by_address_async(
        address,
        self.addr_resolver_cancellable,
        self.on_addr_lookup_resolved,
        (address, self.resolver_lookup_seq))

    except GLib.Error as e:
      if e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED):
        return
      if e.matches(Gio.resolver_error_quark(), Gio.ResolverError.NOT_FOUND):
        syslog.syslog(syslog.LOG_WARNING,
                      f"hostname '{hostname}' could not be found");
        return
      syslog.syslog(syslog.LOG_WARNING, f"unexpected GLib error: {e.message}")
    except Exception as e:
      syslog.syslog(syslog.LOG_WARNING, f"unexpected error: {e}")
    finally:
      self.host_resolver_cancellable = None


  def on_addr_lookup_resolved(self, resolver, result, user_data):
    address, resolver_lookup_seq = user_data

    # old lookup, we are not interested in this
    if resolver_lookup_seq != self.resolver_lookup_seq:
      return

    target_url = None

    try:
      server_hostname = self.addr_resolver.lookup_by_address_finish(result)
      if not server_hostname:
        syslog.syslog(syslog.LOG_WARNING, 'no server hostname returned')
      else:
        target_url = f'http://{server_hostname}:8010'
    except GLib.Error as e:
      if e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED):
        pass
      elif e.matches(Gio.resolver_error_quark(), Gio.ResolverError.NOT_FOUND):
        syslog.syslog(syslog.LOG_WARNING,
                      f"address '{address}' could not be found");
      else:
        syslog.syslog(syslog.LOG_WARNING, f'unexpected GLib error: {e.message}')
    except Exception as e:
      syslog.syslog(syslog.LOG_WARNING, f'unexpected error: {e}')
    finally:
      self.addr_resolver_cancellable = None

    self.update_target_url(target_url)


  def start_session(self):
    self.chooser.start_abitti2_session(self.target_url)


  # override the default UrlInterface method in AbittiUrl
  def update_target_url_from_text(self, text):
    if not text:
      self.update_target_url(None)
      return

    # check two word server identifier
    match = re.match(r'^([a-z]+)(?:\s+|-)([a-z]+)$', text)
    if match:
      # two word abitti code means this is the URL
      self.update_target_url(f'http://{ match.group(1) }-{ match.group(2) }.koe.abitti.net:8010')
      return

    # check one word server identifier
    match = re.match(r'^([a-z0-9]+)$', text)
    if match:
      # One word abitti code means we have to resolve server name through dns.
      # The callback chain should eventually call self.update_target_url().
      self.lookup_server_by_abitti_single_word( match.group(1) )
      return

    # try with the assumption that text is the full server name
    self.update_target_url(f'http://{ text }:8010')


class FreeUrl(UrlInterface):
  ENTRY_PLACEHOLDER = _tr('Input exam URL')
  ENTRY_TOOLTIP     = _tr('Enter the exam URL here')
  EXAM_TYPE         = _tr('URL')
  JOIN_LABEL        = _tr('Exam URL:')


  def convert_entry_to_url(self, text):
    # check that text contains something other than whitespace
    url = text.strip()
    if not url:
      return None
    url_with_scheme \
      = ('https://%s' % url) if not re.match(r'^https?://', url) else url
    return url_with_scheme


  # FreeUrl needs label which is actually clickable to choose
  # between two ClipboardUrl and FreeUrl.
  def create_join_label(self):
     label = Gtk.Button(label=self.JOIN_LABEL)
     label.set_relief(Gtk.ReliefStyle.NONE)
     return label


class ClipboardUrl(UrlInterface):
  ENTRY_ENABLED     = False
  ENTRY_PLACEHOLDER = _tr('Use the clipboard to get an URL here')
  ENTRY_TOOLTIP     = ENTRY_PLACEHOLDER
  EXAM_TYPE         = _tr('URL')
  JOIN_LABEL        = _tr('URL from clipboard:')

  def __init__(self, chooser):
    super().__init__(chooser)
    self.clipboard_main = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
    self.clipboard_main.connect('owner-change', self.on_clipboard_main_change)
    self.clipboard_primary = Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY)
    self.clipboard_primary.connect('owner-change',
                                   self.on_clipboard_primary_change)


  # ClipboardUrl needs label which is actually clickable to choose
  # between two ClipboardUrl and FreeUrl.
  def create_join_label(self):
     label = Gtk.Button(label=self.JOIN_LABEL)
     label.set_relief(Gtk.ReliefStyle.NONE)
     return label


  def convert_entry_to_url(self, text):
    if not text:
      return None
    stripped_text = text.strip()
    if re.match(r'^https?://', stripped_text):
       return stripped_text
    return None


  def set_url_active(self, clipboard, event, can_set_empty):
    # wait_for_text() is synchronous
    clipboard_text = clipboard.wait_for_text()
    url = self.convert_entry_to_url(clipboard_text)
    if url:
      self.entry.set_text(url)
    elif can_set_empty:
      self.entry.set_text('')
    else:
      return

    self.url_change_event(self.entry)


  def on_clipboard_main_change(self, clipboard, event):
    self.set_url_active(clipboard, event, True)


  def on_clipboard_primary_change(self, clipboard, event):
    # Use False for can_set_empty, as we want to keep the previous
    # clipboard URL in this case (main clipboard is preferred over primary
    # clipboard).
    self.set_url_active(clipboard, event, False)


class NextExamNotInstalled(Exception):
  pass


class NextExam(JoinChoice):
  EXAM_TYPE      = _tr('Next-Exam')
  JOIN_LABEL     = _tr('Use Next-Exam to join the exam.')
  NEXTEXAM_PATH  = '/opt/next-exam-puavo-student'

  def __init__(self, chooser):
    if not os.path.exists(self.NEXTEXAM_PATH):
      raise NextExamNotInstalled(f'{ self.NEXTEXAM_PATH } not found')

    super().__init__(chooser)


  @classmethod
  def create(cls, chooser):
    try:
      return NextExam(chooser)
    except NextExamNotInstalled as e:
      errmsg = 'Next Exam Student app not installed,' \
                 + ' can not enable it as a feature'
      syslog.syslog(syslog.LOG_WARNING, errmsg)
      return None


  def attach_to_grid(self, grid, row):
    grid.attach(self.label, 0, row, 1, 1)


  def join_info(self):
    return _tr('Do you want to use Next-Exam to join the exam?')


  def on_join_clicked(self, button):
    if self.show_confirmation(None):
      self.start_session()


  def start_session(self):
    self.chooser.start_nextexam_session()


class ExamChooser():
  def __init__(self, applet):
    self.applet        = applet
    self.abitti_tab    = None
    self.next_exam_tab = None
    self.url_objects   = []
    self.url_tab       = None

    self.window = Gtk.Window()
    self.window.connect('delete-event', self.on_delete_event)
    self.window.set_default_size(760, 320)
    self.window.set_resizable(False)
    self.window.set_title(_tr('Join an exam'))
    self.window.connect('key-press-event',   self.on_key_event)
    self.window.connect('key-release-event', self.on_key_event)

    main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12,
                       margin=12)

    title = self.label_title(_tr('Join an exam'))
    subtitle = self.label_subtitle(_tr('Choose a way to join an exam.'))
    main_box.pack_start(title,    False, False, 0)
    main_box.pack_start(subtitle, False, False, 0)

    notebook = Gtk.Notebook()

    setup_features_conf = puavoconf_get('puavo.exammode.setup.features')
    setup_features_no_duplicates_list \
      = list( dict.fromkeys( setup_features_conf.split() ) )

    abitti_url = None
    clipboard_url = None
    free_url = None
    next_exam = None

    joinchoice_list = []
    for feature in setup_features_no_duplicates_list:
      if feature == 'abitti2-code':
        joinchoice = abitti_url = AbittiUrl(self)
      elif feature == 'clipboard_url':
        joinchoice = clipboard_url = ClipboardUrl(self)
      elif feature == 'free_url':
        joinchoice = free_url = FreeUrl(self)
      elif feature == 'next-exam':
        joinchoice = next_exam = NextExam.create(self) # may return None
      else:
        syslog.syslog(syslog.LOG_WARNING,
                      'unknown feature in puavo.exammode.setup.features: %s' \
                        % feature)
        continue

      if joinchoice:
        joinchoice_list.append(joinchoice)
        if isinstance(joinchoice, UrlInterface):
          self.url_objects.append(joinchoice)

    if not joinchoice_list:
        syslog.syslog(syslog.LOG_ERR,
                      'no features enabled, not creating a chooser window')
        return

    if next_exam and len(joinchoice_list) == 1:
      # if Next-Exam is the only option, we can skip the chooser window
      self.start_nextexam_session()
      return

    tab_list = []
    for joinchoice in joinchoice_list:
      if joinchoice == abitti_url:
        tab = self.abitti_tab = AbittiTab(abitti_url)
      elif joinchoice == clipboard_url:
        if not self.url_tab:
          self.url_tab = UrlTab(clipboard_url, free_url, True)
        tab = self.url_tab
      elif joinchoice == free_url:
        if not self.url_tab:
          self.url_tab = UrlTab(clipboard_url, free_url, False)
        tab = self.url_tab
      elif joinchoice == next_exam:
        tab = self.next_exam_tab = NextExamTab(next_exam)

      joinchoice.connect_tab(tab)

      if not tab in tab_list:
        tab_list.append(tab)

    for tab in tab_list:
      notebook.append_page(tab.page, tab.label)

    main_box.pack_start(notebook, True, True, 0)
    self.window.add(main_box)


  def label_title(self, text):
    w = Gtk.Label()
    safe = GLib.markup_escape_text(text)
    w.set_markup(f'<span size="x-large" weight="bold">{safe}</span>')
    w.set_xalign(0)
    return w


  def label_subtitle(self, text):
    w = Gtk.Label(label=text)
    w.set_xalign(0)
    return w


  def check_joins(self, alt_pressed):
    for url_interface in self.url_objects:
      url_interface.join_button_override(alt_pressed)


  def on_key_event(self, widget, event):
    if not event.keyval in [ Gdk.KEY_Alt_L, Gdk.KEY_Alt_R ]:
      return
    alt_pressed = (event.type == Gdk.EventType.KEY_PRESS)
    self.check_joins(alt_pressed)


  def on_delete_event(self, widget, event):
    self.window.hide()
    return True  # Prevents the window from being destroyed


  def start_abitti2_session(self, url):
    self.applet.start_abitti2_session(url)


  def start_nextexam_session(self):
    self.applet.start_nextexam_session()


  def start_url_session(self, url):
    self.applet.start_url_session(url)


  def show(self):
    if not self.window:
      syslog.syslog(syslog.LOG_ERR,
                    'ExamChooser window is not initialized, cannot show it')
      return
    self.window.show_all()
    self.window.present()


class ExamSetup(dbus.service.Object):
  def __init__(self, bus, applet):
    object_path = '/org/puavo/ExamSetup'
    super().__init__(bus, object_path)
    self.applet = applet

  @dbus.service.method('org.puavo.ExamSetup', in_signature='', out_signature='')
  def StartFree(self):
    self.applet.start_free_session()


  @dbus.service.method('org.puavo.ExamSetup', in_signature='', out_signature='')
  def StartUI(self):
    self.applet.show_chooser()


class PuavoExamModeSetupApplet:
  def __init__(self):
    Notify.init('puavo-exammode-setup-applet')

    self.user_service_dbus_name = self.get_user_dbus_service()

    self.sys_dbus_iface = self.get_sys_dbus_iface()
    self.sys_dbus_iface.connect_to_signal('ExamsAvailable',
                                          self.exams_available)

    self.indicator \
      = AyatanaAppIndicator3.Indicator.new('puavo-exammode-setup-applet',
          ICON_PATH, AyatanaAppIndicator3.IndicatorCategory.SYSTEM_SERVICES)
    self.indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)

    self.chooser = ExamChooser(self)

    self.available_exams = []
    self.update_menu()

    self.register_to_exam_service()


  def get_user_dbus_service(self):
    bus = dbus.SessionBus()
    dbus_name = dbus.service.BusName('org.puavo.ExamSetup', bus=bus)
    ExamSetup(bus, self)
    return dbus_name


  def get_sys_dbus_iface(self):
    bus = dbus.SystemBus()
    dbusobj = bus.get_object('org.puavo.Exam', '/exammode')
    return dbus.Interface(dbusobj, dbus_interface='org.puavo.Exam.exammode')


  def register_to_exam_service(self):
    try:
      username = getpass.getuser()
      krb5ccname_env = ''
      if 'KRB5CCNAME' in os.environ:
        krb5ccname_env = os.environ['KRB5CCNAME']
      if not 'LANG' in os.environ:
        raise Exception('LANG environment variable is not set')
      lang_locale = os.environ['LANG']
    except Exception as e:
      syslog.syslog(syslog.LOG_ERR,
                    'error in looking up user information: %s' % e)
      sys.exit(1)

    self.sys_dbus_iface.Register(username, lang_locale, krb5ccname_env,
                                 reply_handler=lambda: None,
                                 error_handler=self.register_dbus_error_handler)


  def register_dbus_error_handler(self, dbusexception):
    raise Exception('unexpected dbus error in register, that is bad: %s' \
                       % dbusexception)


  def start_abitti2_session(self, url):
    self.sys_dbus_iface.StartAbitti2Session(url,
      reply_handler=lambda: None,
      error_handler=self.start_session_dbus_error_handler)


  def start_free_session(self):
    self.sys_dbus_iface.StartFreeSession(
      reply_handler=lambda: None,
      error_handler=self.start_session_dbus_error_handler)


  def start_nextexam_session(self):
    self.sys_dbus_iface.StartNextExamSession(
      reply_handler=lambda: None,
      error_handler=self.start_session_dbus_error_handler)


  def start_session_by_exam_id(self, exam_id):
    self.sys_dbus_iface.StartSession(exam_id,
      reply_handler=lambda: None,
      error_handler=self.start_session_dbus_error_handler)


  def start_url_session(self, url):
    self.sys_dbus_iface.StartSessionWithUrl(url,
      reply_handler=lambda: None,
      error_handler=self.start_session_dbus_error_handler)


  def start_session_dbus_error_handler(self, dbusexception):
    raise Exception('unexpected dbus error in start session, that is bad: %s' \
                       % dbusexception)


  def update_menu(self):
    self.menu = Gtk.Menu()

    if len(self.available_exams) > 0:
      header = Gtk.MenuItem(label=_tr('Available exams:'))
      header.set_sensitive(False)
      header.show()
      self.menu.add(header)

      separator = Gtk.SeparatorMenuItem()
      separator.show()
      self.menu.add(separator)

      self.menuitems = []
      for exam in self.available_exams:
        exam_id = exam['id']
        exam_name = exam['name']
        exam_choice = Gtk.MenuItem(label=exam_name)
        exam_choice.set_sensitive(True)
        exam_choice.connect('activate',
          lambda w, exam_id=exam_id: self.choose_exam(exam_id, w))
        exam_choice.show()
        self.menu.add(exam_choice)

      self.indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
      self.indicator.set_menu(self.menu)

      self.menu.show_all()
    else:
      self.indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.PASSIVE)


  def show_chooser(self):
    self.chooser.show()


  def choose_exam(self, exam_id, widget):
    self.start_session_by_exam_id(exam_id)


  def exams_available(self, examinfo_json):
    try:
      examinfo = json.loads(examinfo_json)
      if not 'version' in examinfo:
        raise Exception('examinfo is missing version')
      if type(examinfo['version']) != str:
        raise Exception('examinfo version is not a string')
      version = examinfo['version'].split('.')
      if len(version) != 2:
        raise Exception('examinfo version is not x.y')
      major_version = int(version[0])
      if major_version > 1:
       raise Exception('unsupported exams info version (%d | supported %d)' \
                          % (major_version, 1))

      if type(examinfo['exams']) != list:
        raise Exception('no exams list')

      for e in examinfo['exams']:
        if not 'id' in e:
          raise Exception('exam is missing an id')
        if type(e['id']) != int:
          raise Exception('exam id is not an integer')
        if not 'name' in e:
          raise Exception('exam is missing a name')
        if type(e['name']) != str:
          raise Exception('exam name is not a string')
        if not 'params' in e:
          raise Exception('exam is missing exam parameters')
        if type(e['params']) != dict:
          raise Exception('exam parameters is not a dict')

      self.available_exams = examinfo['exams']
      self.update_menu()

    except Exception as e:
      syslog.syslog(syslog.LOG_ERR,
                    'error in checking exams info: %s' % e)


  def main(self):
    Gtk.main()


exitstatus = 0

syslog.openlog('puavo-exammode-setup-applet')

applet = PuavoExamModeSetupApplet()

try:
  signal.signal(signal.SIGINT, signal.SIG_DFL)
  applet.main()
except Exception as e:
  syslog.syslog(syslog.LOG_ERR, 'unexpected error: %s' % e)
  exitstatus = 1

syslog.closelog()

sys.exit(exitstatus)
