# encoding=UTF-8

# Copyright © 2008-2019 Jakub Wilk <jwilk@jwilk.net>
# Copyright © 2009 Mateusz Turcza <mturcza@mimuw.edu.pl>
#
# This file is part of djvusmooth.
#
# djvusmooth is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 2 as published
# by the Free Software Foundation.
#
# djvusmooth 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.

import sys
import itertools
import functools
import locale
import os.path
import threading
from Queue import Queue, Empty as QueueEmpty

import djvusmooth.dependencies

import wx
import wx.lib.ogl
import wx.lib.newevent
import wx.lib.scrolledpanel

import djvu.decode
import djvu.const

from djvusmooth.djvused import StreamEditor
from djvusmooth.gui.page import PageWidget, PercentZoom, OneToOneZoom, StretchZoom, FitWidthZoom, FitPageZoom
from djvusmooth.gui.page import RENDER_NONRASTER_TEXT, RENDER_NONRASTER_MAPAREA
from djvusmooth.gui.metadata import MetadataDialog
from djvusmooth.gui.flatten_text import FlattenTextDialog
from djvusmooth.gui.text_browser import TextBrowser
from djvusmooth.gui.outline_browser import OutlineBrowser
from djvusmooth.gui.maparea_browser import MapAreaBrowser
from djvusmooth.gui.history import FileHistory
from djvusmooth.gui import dialogs
from djvusmooth.text import mangle as text_mangle
import djvusmooth.models.metadata
import djvusmooth.models.annotations
import djvusmooth.models.text
from djvusmooth import models
from djvusmooth import external_editor
from djvusmooth import config

from djvusmooth import __version__, __author__

from djvusmooth.i18n import _

APPLICATION_NAME = 'djvusmooth'
LICENSE = 'GPL-2'

MENU_ICON_SIZE = (16, 16)

WxDjVuMessage, wx.EVT_DJVU_MESSAGE = wx.lib.newevent.NewEvent()

system_encoding = locale.getpreferredencoding()

class OpenDialog(wx.FileDialog):

    __wildcard = _(
        'DjVu files (*.djvu, *.djv)|*.djvu;*.djv|'
        'All files|*'
    )

    def __init__(self, parent):
        wx.FileDialog.__init__(self, parent,
            style=wx.FD_OPEN,
            wildcard=self.__wildcard,
            message=_('Open a DjVu document')
        )

class TextModel(models.text.Text):

    def __init__(self, document):
        models.text.Text.__init__(self)
        self._document = document

    def reset_document(self, document):
        self._document = document

    def acquire_data(self, n):
        text = self._document.pages[n].text
        text.wait()
        return text.sexpr

class OutlineModel(models.outline.Outline):

    def __init__(self, document):
        self._document = document
        models.outline.Outline.__init__(self)

    def reset_document(self, document):
        self._document = document

    def acquire_data(self):
        outline = self._document.outline
        outline.wait()
        return outline.sexpr

class PageProxy(object):
    def __init__(self, page, text_model, annotations_model):
        self._page = page
        self._text = text_model
        self._annotations = annotations_model

    @property
    def page_job(self):
        return self._page.decode(wait=False)

    @property
    def text(self):
        return self._text

    @property
    def annotations(self):
        return self._annotations

    def register_text_callback(self, callback):
        self._text.register_callback(callback)

    def register_annotations_callback(self, callback):
        self._annotations.register_callback(callback)

class DocumentProxy(object):

    def __init__(self, document, outline):
        self._document = document
        self._outline = outline

    @property
    def outline(self):
        return self._outline

    def register_outline_callback(self, callback):
        self._outline.register_callback(callback)

class AnnotationsModel(models.annotations.Annotations):

    def __init__(self, document_path):
        models.annotations.Annotations.__init__(self)
        self.__djvused = StreamEditor(document_path)

    def reset_document(self, document):
        pass  # Nothing to do

    def acquire_data(self, n):
        djvused = self.__djvused
        if n == models.SHARED_ANNOTATIONS_PAGENO:
            djvused.select_shared_annotations()
        else:
            djvused.select(n + 1)
        djvused.print_annotations()
        s = '(%s)' % djvused.commit()  # FIXME: optimize
        try:
            return djvu.sexpr.Expression.from_string(s)
        except djvu.sexpr.ExpressionSyntaxError:
            raise  # FIXME

class MetadataModel(models.metadata.Metadata):

    def __init__(self, document):
        models.metadata.Metadata.__init__(self)
        self._document = document

    def reset_document(self, document):
        self._document = document

    def acquire_data(self, n):
        document_annotations = self._document.annotations
        document_annotations.wait()
        document_metadata = document_annotations.metadata
        if n == models.SHARED_ANNOTATIONS_PAGENO:
            result = document_metadata
        else:
            page_annotations = self._document.pages[n].annotations
            page_annotations.wait()
            page_metadata = page_annotations.metadata
            result = {}
            for k, v in page_metadata.iteritems():
                if k not in document_metadata:
                    pass
                elif v != document_metadata[k]:
                    pass
                else:
                    continue
                result[k] = v
        return result

class PageTextCallback(models.text.PageTextCallback):

    def __init__(self, owner):
        self._owner = owner

    def notify_node_change(self, node):
        self._owner.dirty = True

    def notify_node_children_change(self, node):
        self._owner.dirty = True

    def notify_node_select(self, node):
        type = str(node.type)
        text = _('[Text layer]') + ' ' + _(type)
        if node.is_leaf():
            text += ': %s' % node.text
        self._owner.SetStatusText(text)

    def notify_node_deselect(self, node):
        self._owner.SetStatusText('')

    def notify_tree_change(self, node):
        self._owner.dirty = True

class OutlineCallback(models.outline.OutlineCallback):

    def __init__(self, owner):
        self._owner = owner

    def notify_tree_change(self, node):
        self._owner.dirty = True

    def notify_node_change(self, node):
        self._owner.dirty = True

    def notify_node_children_change(self, node):
        self._owner.dirty = True

    def notify_node_select(self, node):
        try:
            self._owner.SetStatusText(_('Link: %s') % node.uri)
        except AttributeError:
            pass

class PageAnnotationsCallback(models.annotations.PageAnnotationsCallback):

    def __init__(self, owner):
        self._owner = owner

    def notify_node_change(self, node):
        self._owner.dirty = True
    def notify_node_add(self, node):
        self._owner.dirty = True
    def notify_node_delete(self, node):
        self._owner.dirty = True
    def notify_node_replace(self, node, other_node):
        self._owner.dirty = True

    def notify_node_select(self, node):
        try:
            self._owner.SetStatusText(_('Link: %s') % node.uri)
        except AttributeError:
            pass

    def notify_node_deselect(self, node):
        self._owner.SetStatusText('')

class ScrolledPanel(wx.lib.scrolledpanel.ScrolledPanel):

    def OnChildFocus(self, event):
        # We *don't* want to scroll to the child window which just got the focus.
        # So just skip the event:
        event.Skip()

def skip_if_being_deleted(method):
    @functools.wraps(method)
    def method_wrapper(self, *args, **kwargs):
        if self.IsBeingDeleted():
            # This method could be called via an event while the window is
            # being deleted. Do *not* fiddle with menu in such a case, as it
            # would provoke segmentation fault.
            return
        return method(self, *args, **kwargs)
    return method_wrapper

class MainWindow(wx.Frame):

    @apply
    def default_xywh():
        def get(self):
            return tuple(
                self._config.read_int('main_window_%s' % key, value)
                for key, value
                in (('x', -1), ('y', -1), ('width', 640), ('height', 480))
            )
        def set(self, xywh):
            (x, y, w, h) = xywh
            for key, value in dict(x=x, y=y, width=w, height=h).iteritems():
                self._config['main_window_%s' % key] = value
        return property(get, set)

    @apply
    def default_splitter_sash():
        def get(self):
            return self._config.read_int('main_window_splitter_sash', 160)
        def set(self, value):
            self._config['main_window_splitter_sash'] = value
        return property(get, set)

    @apply
    def default_sidebar_shown():
        def get(self):
            return self._config.read_bool('main_window_sidebar_shown', True)
        def set(self, value):
            self._config['main_window_sidebar_shown'] = value
        return property(get, set)

    @apply
    def default_editor_path():
        def get(self):
            return self._config.read('external_editor', '') or None
        def set(self, value):
            self._config['external_editor'] = value or ''
        return property(get, set)

    @apply
    def default_open_dir():
        def get(self):
            return self._config.read('open_dir', '')
        def set(self, value):
            self._config['open_dir'] = value or ''
        return property(get, set)

    def save_defaults(self):
        self._config.flush()

    def setup_external_editor(self):
        editor_path = self.default_editor_path
        if editor_path is None:
            self.external_editor = external_editor.RunMailcapEditor()
        else:
            self.external_editor = external_editor.CustomEditor(*editor_path.split())

    def __init__(self):
        self._config = wx.GetApp().config
        # The ._config attribute should not be accessed directly, but only
        # via .default_* properties.
        # Use .save_defaults() to save the config file.
        x, y, w, h = self.default_xywh
        wx.Frame.__init__(self, None, pos=(x, y), size=(w, h))
        self.setup_external_editor()
        self.Bind(wx.EVT_DJVU_MESSAGE, self.handle_message)
        self.context = Context(self)
        self._page_text_callback = PageTextCallback(self)
        self._page_annotations_callback = PageAnnotationsCallback(self)
        self._outline_callback = OutlineCallback(self)
        self.status_bar = self.CreateStatusBar(2, style=wx.ST_SIZEGRIP)
        self.splitter = wx.SplitterWindow(self, style=wx.SP_LIVE_UPDATE)
        self.splitter.Bind(wx.EVT_SPLITTER_SASH_POS_CHANGED, self.on_splitter_sash_changed)
        self.sidebar = wx.Notebook(self.splitter, wx.ID_ANY)
        self.text_browser = TextBrowser(self.sidebar)
        self.outline_browser = OutlineBrowser(self.sidebar)
        self.maparea_browser = MapAreaBrowser(self.sidebar)
        self.sidebar.AddPage(self.outline_browser, _('Outline'))
        self.sidebar.AddPage(self.maparea_browser, _('Hyperlinks'))
        self.sidebar.AddPage(self.text_browser, _('Text'))
        self.sidebar.Bind(
            wx.EVT_NOTEBOOK_PAGE_CHANGED,
            self._on_sidebar_page_changed(
                self.on_display_no_nonraster,
                self.on_display_maparea,
                self.on_display_text)
        )
        self.scrolled_panel = ScrolledPanel(self.splitter)
        self.splitter.SetSashGravity(0.1)
        self.do_show_sidebar()
        if not self.default_sidebar_shown:
            self.do_hide_sidebar()
        sizer = wx.BoxSizer(wx.VERTICAL)
        self.scrolled_panel.SetSizer(sizer)
        self.scrolled_panel.SetupScrolling()
        self.page_widget = PageWidget(self.scrolled_panel)
        self.page_widget.Bind(wx.EVT_CHAR, self.on_char)
        self.scrolled_panel.Bind(wx.EVT_SIZE, self.page_widget.on_parent_resize)
        sizer.Add(self.page_widget, 0, wx.ALL, 0)
        self.editable_menu_items = []
        self.saveable_menu_items = []
        self.file_history = FileHistory(self._config)
        self.create_menus()
        self.dirty = False
        self.do_open(None)
        self.Bind(wx.EVT_CLOSE, self.on_exit)

    def create_menus(self):
        menu_bar = wx.MenuBar()
        menu_bar.Append(self._create_file_menu(), _('&File'))
        menu_bar.Append(self._create_edit_menu(), _('&Edit'))
        menu_bar.Append(self._create_view_menu(), _('&View'))
        menu_bar.Append(self._create_go_menu(), _('&Go'))
        menu_bar.Append(self._create_settings_menu(), _('&Settings'))
        menu_bar.Append(self._create_help_menu(), _('&Help'))
        self.SetMenuBar(menu_bar)

    def _create_menu_item(self, menu, caption, help, method, style=wx.ITEM_NORMAL, icon=None, id=wx.ID_ANY):
        item = wx.MenuItem(menu, id, caption, help, style)
        if icon is not None:
            bitmap = wx.ArtProvider_GetBitmap(icon, wx.ART_MENU, MENU_ICON_SIZE)
            item.SetBitmap(bitmap)
        self.Bind(wx.EVT_MENU, method, item)
        menu.AppendItem(item)
        return item

    def _create_file_menu(self):
        menu = wx.Menu()
        menu_item = functools.partial(self._create_menu_item, menu)
        menu_item(_('&Open') + '\tCtrl+O', _('Open a DjVu document'), self.on_open, icon=wx.ART_FILE_OPEN)
        recent_menu = wx.Menu()
        recent_menu_item = menu.AppendMenu(wx.ID_ANY, _('Open &recent'), recent_menu)
        self.file_history.set_menu(self, recent_menu_item, self.do_open)
        save_menu_item = menu_item(_('&Save') + '\tCtrl+S', _('Save the document'), self.on_save, icon=wx.ART_FILE_SAVE)
        close_menu_item = menu_item(_('&Close') + '\tCtrl+W', _('Close the document'), self.on_close, id=wx.ID_CLOSE)
        self.editable_menu_items += close_menu_item,
        self.saveable_menu_items += save_menu_item,
        menu.AppendSeparator()
        menu_item(_('&Quit') + '\tCtrl+Q', _('Quit the application'), self.on_exit, icon=wx.ART_QUIT)
        return menu

    def _create_edit_menu(self):
        menu = wx.Menu()
        menu_item = functools.partial(self._create_menu_item, menu)
        menu_item(_('&Metadata') + '\tCtrl+M', _('Edit the document or page metadata'), self.on_edit_metadata)
        submenu = wx.Menu()
        submenu_item = functools.partial(self._create_menu_item, submenu)
        submenu_item(_('&External editor') + '\tCtrl+T', _('Edit page text in an external editor'), self.on_external_edit_text)
        submenu_item(_('&Flatten'), _('Remove details from page text'), self.on_flatten_text)
        menu.AppendMenu(wx.ID_ANY, _('&Text'), submenu)
        submenu = wx.Menu()
        submenu_item = functools.partial(self._create_menu_item, submenu)
        submenu_item(_('&Bookmark this page') + '\tCtrl+B', _('Add the current to document outline'), self.on_bookmark_current_page)
        submenu_item(_('&External editor'), _('Edit document outline in an external editor'), self.on_external_edit_outline)
        submenu_item(_('&Remove all'), _('Remove whole document outline'), self.on_remove_outline)
        menu.AppendMenu(wx.ID_ANY, _('&Outline'), submenu)
        return menu

    def _create_view_menu(self):
        menu = wx.Menu()
        menu_item = functools.partial(self._create_menu_item, menu)
        submenu = wx.Menu()
        submenu_item = functools.partial(self._create_menu_item, submenu)
        for caption, help, method, id in [
            (_('Zoom &in') + '\tCtrl++', _('Increase the magnification'), self.on_zoom_in, wx.ID_ZOOM_IN),
            (_('Zoom &out') + '\tCtrl+-', _('Decrease the magnification'), self.on_zoom_out, wx.ID_ZOOM_OUT),
        ]:
            submenu_item(caption, help, method, id=id)
        submenu.AppendSeparator()
        for caption, help, zoom, id in [
            (_('Fit &width'), _('Set magnification to fit page width'), FitWidthZoom(), None),
            (_('Fit &page'), _('Set magnification to fit page'), FitPageZoom(), wx.ID_ZOOM_FIT),
            (_('&Stretch'), _('Stretch the image to the window size'), StretchZoom(), None),
            (_('One &to one'), _('Set full resolution magnification.'), OneToOneZoom(), wx.ID_ZOOM_100),
        ]:
            id = id or wx.ID_ANY
            submenu_item(caption, help, self.on_zoom(zoom), style=wx.ITEM_RADIO, id=id)
        submenu.AppendSeparator()
        self.zoom_menu_items = {}
        for percent in 300, 200, 150, 100, 75, 50, 25:
            item = submenu_item(
                '%d%%' % percent,
                _('Magnify %d%%') % percent,
                self.on_zoom(PercentZoom(percent)),
                style=wx.ITEM_RADIO
            )
            if percent == 100:
                item.Check()
            self.zoom_menu_items[percent] = item
        menu.AppendMenu(wx.ID_ANY, _('&Zoom'), submenu)
        submenu = wx.Menu()
        submenu_item = functools.partial(self._create_menu_item, submenu)
        for caption, help, method in [
            (_('&Color') + '\tAlt+C', _('Display everything'), self.on_display_everything),
            (_('&Stencil'), _('Display only the document bitonal stencil'), self.on_display_stencil),
            (_('&Foreground'), _('Display only the foreground layer'), self.on_display_foreground),
            (_('&Background'), _('Display only the background layer'), self.on_display_background),
            (_('&None') + '\tAlt+N', _('Neither display the foreground layer nor the background layer'), self.on_display_none)
        ]:
            submenu_item(caption, help, method, style=wx.ITEM_RADIO)
        menu.AppendMenu(wx.ID_ANY, _('&Image'), submenu)
        submenu = wx.Menu()
        submenu_item = functools.partial(self._create_menu_item, submenu)
        _tmp_items = []
        for caption, help, method in [
            (_('&None'), _('Don\'t display non-raster data'), self.on_display_no_nonraster),
            (_('&Hyperlinks') + '\tAlt+H', _('Display overprinted annotations'), self.on_display_maparea),
            (_('&Text') + '\tAlt+T', _('Display the text layer'), self.on_display_text),
        ]:
            _tmp_items += [submenu_item(caption, help, method, style=wx.ITEM_RADIO)]
        self.menu_item_display_no_nonraster, self.menu_item_display_maparea, self.menu_item_display_text = _tmp_items
        del _tmp_items
        self.menu_item_display_no_nonraster.Check()
        menu.AppendMenu(wx.ID_ANY, _('&Non-raster data'), submenu)
        menu_item(_('&Refresh') + '\tCtrl+L', _('Refresh the window'), self.on_refresh)
        return menu

    def _create_go_menu(self):
        menu = wx.Menu()
        menu_item = functools.partial(self._create_menu_item, menu)
        for caption, help, method, icon in [
            (_('&First page') + '\tCtrl-Home', _('Jump to first document page'), self.on_first_page, None),
            (_('&Previous page') + '\tPgUp', _('Jump to previous document page'), self.on_previous_page, wx.ART_GO_UP),
            (_('&Next page') + '\tPgDn', _('Jump to next document page'), self.on_next_page, wx.ART_GO_DOWN),
            (_('&Last page') + '\tCtrl-End', _('Jump to last document page'), self.on_last_page, None),
            (_(u'&Go to page…') + '\tCtrl-G', _(u'Jump to page…'), self.on_goto_page, None)
        ]:
            menu_item(caption, help, method, icon=icon)
        return menu

    def _create_settings_menu(self):
        menu = wx.Menu()
        menu_item = functools.partial(self._create_menu_item, menu)
        sidebar_menu_item = menu_item(_('Show &sidebar') + '\tF9', _('Show/hide the sidebar'), self.on_show_sidebar, style=wx.ITEM_CHECK)
        if self.default_sidebar_shown:
            sidebar_menu_item.Check()
        menu_item(_(u'External editor…'), _('Setup an external editor'), self.on_setup_external_editor)
        return menu

    def _create_help_menu(self):
        menu = wx.Menu()
        menu_item = functools.partial(self._create_menu_item, menu)
        menu_item(_('&About') + '\tF1', _('More information about this program'), self.on_about, id=wx.ID_ABOUT)
        return menu

    def on_setup_external_editor(self, event):
        dialog = wx.TextEntryDialog(self,
            caption=_('Setup an external editor'),
            message=_('Enter path to your favourite text editor.')
        )
        try:
            dialog.SetValue(self.default_editor_path or '')
            if dialog.ShowModal() == wx.ID_OK:
                self.default_editor_path = dialog.GetValue()
                self.setup_external_editor()
        finally:
            dialog.Destroy()

    def on_splitter_sash_changed(self, event):
        self.default_splitter_sash = event.GetSashPosition()

    def _on_sidebar_page_changed(self, *methods):
        def event_handler(event):
            methods[event.GetSelection()](event)
        return event_handler

    def on_char(self, event):
        key_code = event.GetKeyCode()
        if key_code == ord('-'):
            self.on_zoom_out(event)
        elif key_code == ord('+'):
            self.on_zoom_in(event)
        else:
            event.Skip()

    def enable_edit(self, enable=True):
        for i in 1, 3:
            self.GetMenuBar().EnableTop(i, enable)
        for menu_item in self.editable_menu_items:
            menu_item.Enable(enable)

    def enable_save(self, enable=True):
        for menu_item in self.saveable_menu_items:
            menu_item.Enable(enable)

    @apply
    def dirty():
        def get(self):
            return self._dirty
        def set(self, value):
            self._dirty = value
            self.enable_save(value)
        return property(get, set)

    def error_box(self, message, caption=_('Error')):
        wx.MessageBox(message=message, caption=caption, style=(wx.OK | wx.ICON_ERROR), parent=self)

    def on_exit(self, event):
        if self.do_open(None):
            x, y = self.GetPosition()
            w, h = self.GetSize()
            self.default_xywh = x, y, w, h
            self.save_defaults()
            self.Destroy()

    def on_open(self, event):
        dialog = OpenDialog(self)
        dialog.SetDirectory(self.default_open_dir)
        try:
            if dialog.ShowModal() == wx.ID_OK:
                self.do_open(dialog.GetPath())
        finally:
            dialog.Destroy()

    def on_close(self, event):
        self.do_open(None)

    def on_save(self, event):
        self.do_save()

    def on_save_failed(self, exception):
        self.error_box(_('Saving document failed:\n%s') % exception)

    def do_save(self):
        if not self.dirty:
            return True
        queue = Queue()
        sed = StreamEditor(self.path, autosave=True)
        for model in self.models:
            model.export(sed)
        def job():
            try:
                sed.commit()
                self.document = self.context.new_document(djvu.decode.FileURI(self.path))
                for model in self.models:
                    model.reset_document(self.document)
            except Exception as exception:
                pass
            else:
                exception = None
            queue.put(exception)
        thread = threading.Thread(target=job)
        thread.start()
        dialog = None
        try:
            try:
                exception = queue.get(block=True, timeout=0.1)
                if exception is not None:
                    self.on_save_failed(exception)
                    return False
            except QueueEmpty:
                dialog = dialogs.ProgressDialog(
                    title=_('Saving document'),
                    message=_(u'Saving the document, please wait…'),
                    parent=self,
                    style=(wx.PD_APP_MODAL | wx.PD_ELAPSED_TIME),
                )
            while dialog is not None:
                try:
                    exception = queue.get(block=True, timeout=0.1)
                    if exception is not None:
                        self.on_save_failed(exception)
                        return False
                    break
                except QueueEmpty:
                    dialog.Pulse()
        finally:
            thread.join()
            if dialog is not None:
                dialog.Destroy()
        self.dirty = False
        return True

    def on_show_sidebar(self, event):
        if event.IsChecked():
            self.do_show_sidebar()
        else:
            self.do_hide_sidebar()

    def do_show_sidebar(self):
        self.splitter.SplitVertically(self.sidebar, self.scrolled_panel, self.default_splitter_sash)
        self.default_sidebar_shown = True

    def do_hide_sidebar(self):
        self.splitter.Unsplit(self.sidebar)
        self.default_sidebar_shown = False

    def on_display_everything(self, event):
        self.page_widget.render_mode = djvu.decode.RENDER_COLOR

    def on_display_foreground(self, event):
        self.page_widget.render_mode = djvu.decode.RENDER_FOREGROUND

    def on_display_background(self, event):
        self.page_widget.render_mode = djvu.decode.RENDER_BACKGROUND

    def on_display_stencil(self, event):
        self.page_widget.render_mode = djvu.decode.RENDER_BLACK

    def on_display_none(self, event):
        self.page_widget.render_mode = None

    @skip_if_being_deleted
    def on_display_text(self, event):
        self.page_widget.render_nonraster = RENDER_NONRASTER_TEXT
        self.menu_item_display_text.Check()

    @skip_if_being_deleted
    def on_display_maparea(self, event):
        self.page_widget.render_nonraster = RENDER_NONRASTER_MAPAREA
        self.menu_item_display_maparea.Check()

    @skip_if_being_deleted
    def on_display_no_nonraster(self, event):
        self.page_widget.render_nonraster = None
        self.menu_item_display_no_nonraster.Check()

    def on_refresh(self, event):
        self.Refresh()

    def get_page_uri(self, page_no=None):
        if page_no is None:
            page_no = self.page_no
        try:
            id = self.document.pages[page_no].file.id
        except djvu.decode.NotAvailable:
            id = str(page_no)
        return '#' + id

    @apply
    def page_no():
        def get(self):
            return self._page_no
        def set(self, n):
            if self.document is None:
                self._page_no = 0
                self.status_bar.SetStatusText('', 1)
                return
            if n < 0 or n >= len(self.document.pages):
                return
            self._page_no = n
            self.status_bar.SetStatusText(_('Page %(pageno)d of %(npages)d') % {'pageno': (n + 1), 'npages': len(self.document.pages)}, 1)
            self.update_page_widget(new_page=True)
        return property(get, set)

    def on_first_page(self, event):
        self.page_no = 0

    def on_last_page(self, event):
        self.page_no = len(self.document.pages) - 1

    def on_next_page(self, event):
        self.page_no += 1

    def on_previous_page(self, event):
        self.page_no -= 1

    def on_goto_page(self, event):
        dialog = dialogs.NumberEntryDialog(
            parent=self,
            message=(_('Go to page') + ':'),
            prompt='',
            caption=_('Go to page'),
            value=self.page_no + 1,
            min=1,
            max=len(self.document.pages)
        )
        try:
            rc = dialog.ShowModal()
            if rc == wx.ID_OK:
                self.page_no = dialog.GetValue() - 1
        finally:
            dialog.Destroy()

    def on_edit_metadata(self, event):
        document_metadata_model = self.metadata_model[models.SHARED_ANNOTATIONS_PAGENO].clone()
        document_metadata_model.title = _('Document metadata')
        page_metadata_model = self.metadata_model[self.page_no].clone()
        page_metadata_model.title = _('Page %d metadata') % (self.page_no + 1)
        dialog = MetadataDialog(self, models=(document_metadata_model, page_metadata_model), known_keys=djvu.const.METADATA_KEYS)
        try:
            if dialog.ShowModal() == wx.ID_OK:
                self.metadata_model[models.SHARED_ANNOTATIONS_PAGENO] = document_metadata_model
                self.metadata_model[self.page_no] = page_metadata_model
                self.dirty = True
        finally:
            dialog.Destroy()

    def on_flatten_text(self, event):
        dialog = FlattenTextDialog(self)
        zone = None
        try:
            if dialog.ShowModal() == wx.ID_OK:
                scope_all = dialog.get_scope()
                zone = dialog.get_zone()
        finally:
            dialog.Destroy()
        if zone is None:
            return
        if scope_all:
            page_nos = xrange(len(self.document.pages))
        else:
            page_nos = (self.page_no,)
        for page_no in page_nos:
            self.text_model[page_no].strip(zone)

    def on_bookmark_current_page(self, event):
        uri = self.get_page_uri()
        node = models.outline.InnerNode(djvu.sexpr.Expression((_('(no title)'), uri)), self.outline_model)
        self.outline_model.root.add_child(node)
        node.notify_select()

    def on_remove_outline(self, event):
        self.outline_model.remove()

    def on_external_edit_outline(self, event):
        model = self.outline_model
        def job(disabler):
            new_repr = None
            try:
                with external_editor.temporary_file('outline.txt') as tmp_file:
                    model.export_as_plaintext(tmp_file)
                    tmp_file.flush()
                    self.external_editor(tmp_file.name)
                    tmp_file.seek(0)
                    new_repr = map(str.expandtabs, itertools.imap(str.rstrip, tmp_file))
            except Exception as exception:
                pass
            else:
                exception = None
            wx.CallAfter(self.after_external_edit_outline, new_repr, disabler, exception)
            disabler = exception = None
        disabler = wx.WindowDisabler()
        thread = threading.Thread(target=job, args=(disabler,))
        thread.start()

    def on_external_edit_failed(self, exception):
        self.error_box(
            _('External edit failed:\n%s') %
            str(exception).decode(system_encoding, 'replace')
        )

    def after_external_edit_outline(self, new_repr, disabler, exception):
        if exception is not None:
            self.on_external_edit_failed(exception)
            return
        # TODO: how to check if actually something changed?
        self.outline_model.import_plaintext(new_repr)

    def on_external_edit_text(self, event):
        sexpr = self.text_model[self.page_no].raw_value
        if not sexpr:
            self.error_box(_('No text layer to edit.'))
            return
        def job(disabler):
            new_sexpr = None
            try:
                with external_editor.temporary_file('text.txt') as tmp_file:
                    text_mangle.export(sexpr, tmp_file)
                    tmp_file.flush()
                    self.external_editor(tmp_file.name)
                    tmp_file.seek(0)
                    try:
                        new_sexpr = text_mangle.import_(sexpr, tmp_file)
                    except text_mangle.NothingChanged:
                        pass
            except Exception as exception:
                pass
            else:
                exception = None
            wx.CallAfter(self.after_external_edit_text, new_sexpr, disabler, exception)
            disabler = exception = None
        disabler = wx.WindowDisabler()
        thread = threading.Thread(target=job, args=(disabler,))
        thread.start()

    def after_external_edit_text(self, sexpr, disabler, exception):
        if exception is not None:
            try:
                raise exception
            except text_mangle.CharacterZoneFound:
                self.error_box(_('Cannot edit text with character zones.'))
                return
            except text_mangle.LengthChanged:
                self.error_box(_('Number of lines changed.'))
                return
            except Exception:
                self.on_external_edit_failed(exception)
                return
        if sexpr is None:
            # nothing changed
            return
        self.text_model[self.page_no].raw_value = sexpr

    def do_percent_zoom(self, percent):
        self.page_widget.zoom = PercentZoom(percent)
        self.zoom_menu_items[percent].Check()

    def on_zoom_out(self, event):
        try:
            percent = self.page_widget.zoom.percent
        except ValueError:
            return  # FIXME
        candidates = [k for k in self.zoom_menu_items.iterkeys() if k < percent]
        if not candidates:
            return
        self.do_percent_zoom(max(candidates))

    def on_zoom_in(self, event):
        try:
            percent = self.page_widget.zoom.percent
        except ValueError:
            return  # FIXME
        candidates = [k for k in self.zoom_menu_items.iterkeys() if k > percent]
        if not candidates:
            return
        self.do_percent_zoom(min(candidates))

    def on_zoom(self, zoom):
        def event_handler(event):
            self.page_widget.zoom = zoom
        return event_handler

    def do_open(self, path):
        if isinstance(path, unicode):
            path = path.encode(system_encoding)
        if self.dirty:
            dialog = wx.MessageDialog(self, _('Do you want to save your changes?'), '', wx.YES_NO | wx.YES_DEFAULT | wx.CANCEL | wx.ICON_QUESTION)
            try:
                rc = dialog.ShowModal()
                if rc == wx.ID_YES:
                    if not self.do_save():
                        return False
                    assert not self.dirty
                elif rc == wx.ID_NO:
                    pass
                elif rc == wx.ID_CANCEL:
                    return False
            finally:
                dialog.Destroy()
        self.path = path
        self.document = None
        self.page_no = 0
        def clear_models():
            self.metadata_model = self.text_model = self.outline_model = self.annotations_model = None
            self.models = ()
            self.enable_edit(False)
        if path is None:
            clear_models()
        else:
            self.file_history.add(path)
            self.default_open_dir = os.path.dirname(path)
            try:
                self.document = self.context.new_document(djvu.decode.FileURI(path))
                self.metadata_model = MetadataModel(self.document)
                self.text_model = TextModel(self.document)
                self.outline_model = OutlineModel(self.document)
                self.annotations_model = AnnotationsModel(path)
                self.models = self.metadata_model, self.text_model, self.outline_model, self.annotations_model
                self.enable_edit(True)
            except djvu.decode.JobFailed:
                clear_models()
                self.document = None
                # Do *not* display error message here. It will be displayed by `handle_message()`.
        self.page_no = 0  # again, to set status bar text
        self.update_title()
        self.update_page_widget(new_document=True, new_page=True)
        self.dirty = False
        return True

    def update_page_widget(self, new_document=False, new_page=False):
        if self.document is None:
            self.page_widget.Hide()
            self.page = self.page_job = self.page_proxy = self.document_proxy = None
        elif self.page_job is None or new_page:
            self.page_widget.Show()
            self.page = self.document.pages[self.page_no]
            self.page_job = self.page.decode(wait=False)
            self.page_proxy = PageProxy(
                page=self.page,
                text_model=self.text_model[self.page_no],
                annotations_model=self.annotations_model[self.page_no]
            )
            self.page_proxy.register_text_callback(self._page_text_callback)
            self.page_proxy.register_annotations_callback(self._page_annotations_callback)
            if new_document:
                self.document_proxy = DocumentProxy(document=self.document, outline=self.outline_model)
                self.document_proxy.register_outline_callback(self._outline_callback)
        self.page_widget.page = self.page_proxy
        self.text_browser.page = self.page_proxy
        self.maparea_browser.page = self.page_proxy
        if new_document:
            self.outline_browser.document = self.document_proxy

    def update_title(self):
        if self.path is None:
            title = APPLICATION_NAME
        else:
            base_path = os.path.basename(self.path)
            if isinstance(base_path, str):
                base_path = base_path.decode(system_encoding, 'replace')
            title = u'%s — %s' % (APPLICATION_NAME, base_path)
        self.SetTitle(title)

    def on_about(self, event):
        message = (
            '%(app)s %(version)s\n' +
            _('Author') + ': %(author)s\n' +
            _('License') + ': %(license)s'
        )
        message = message % dict(
            app=APPLICATION_NAME,
            version=__version__,
            author=__author__,
            license=LICENSE
        )
        wx.MessageBox(message=message, caption=_(u'About…'))

    def handle_message(self, event):
        message = event.message
        if isinstance(message, djvu.decode.ErrorMessage):
            self.error_box(message=str(message))
        elif message.document is not self.document:
            # Bogus, non-error message are ignored.
            pass
        self.update_title()
        if isinstance(message, (djvu.decode.RedisplayMessage, djvu.decode.RelayoutMessage)):
            if self.page_job is message.page_job:
                self.update_page_widget()

class Context(djvu.decode.Context):

    def __new__(cls, window):
        return djvu.decode.Context.__new__(cls)

    def __init__(self, window):
        djvu.decode.Context.__init__(self)
        self.window = window

    def handle_message(self, message):
        wx.PostEvent(self.window, WxDjVuMessage(message=message))

class Application(wx.App):

    @apply
    def config():
        def get(self):
            return self._config
        return property(get)

    def except_hook(self, *args):
        sys.__excepthook__(*args)
        wx.CallAfter(self.except_hook_after, *args)

    def except_hook_after(self, type_, value, traceback):
        from traceback import format_exception
        message = _('An unhandled exception occurred. Ideally, this should not happen. Please report the bug to the author.\n\n')
        message += ''.join(format_exception(type_, value, traceback))
        caption = _('Unhandled exception: %s' % type_.__name__)
        wx.MessageBox(message=message, caption=caption, style=(wx.OK | wx.ICON_ERROR))

    def OnInit(self):
        wx.lib.ogl.OGLInitialize()
        self.SetAppName(APPLICATION_NAME)
        if os.name == 'posix':
            legacy_path = os.path.expanduser('~/.DjVuSmooth')
        else:
            legacy_path = None
        self._config = config.Config('djvusmooth', legacy_path)
        sys.excepthook = self.except_hook
        return True

    def start(self, argv):
        window = MainWindow()
        window.Show(True)
        if argv:
            path = argv[0]
            path = os.path.abspath(path)
            window.do_open(path)
        return self.MainLoop()

# vim:ts=4 sts=4 sw=4 et
