# Copyright (C) 2020 pdfarranger contributors
#
# pdfarranger 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, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

from gi.repository import Gtk, Gdk, GObject
import gettext
import cairo
import locale

from math import pi

from .core import Sides, Dims, PDFRenderer

_ = gettext.gettext


def scale(model, selection, factor):
    """Set the scale factor of a selection of pages."""
    changed = False
    try:
        width, height = factor
    except TypeError:
        width, height = None, None
    for path in selection:
        it = model.get_iter(path)
        page = model.get_value(it, 0)
        page_size = page.size.cropped(page.crop)
        if width is None:
            f = factor
        else:
            # TODO: allow to change aspect ratio
            f = min(*Dims(width, height) / page_size)
        # Page size must be in [72, 14400] (PDF standard requirement)
        f = max(f, *(Dims(72, 72) / page_size))
        f = min(f, *(Dims(14400, 14400) / page_size))
        if page.scale != f:
            changed = True
        page.resample = page.resample * f / page.scale
        for lp in page.layerpages:
            lp.scale = lp.scale * f / page.scale
        page.scale = f
        model.set_value(it, 0, page)
    return changed


class _LinkedSpinButton(Gtk.SpinButton):
    """ A spin button which can be bound to an other button """

    def __init__(self, minval, maxval, step, page=None):
        Gtk.SpinButton.__init__(self)
        self.set_digits(20)
        self.set_width_chars(9)
        self.connect("output", self.__output)
        self.set_range(minval, maxval)
        self.set_increments(step, step if page is None else page)
        self.changing_from_brother = False

    def __output(self, _user_data):
        """ output signal handler to remove unneeded 0 """
        s = locale.format_string("%.8g", self.get_adjustment().get_value())
        self.get_buffer().set_text(s, len(s))
        return True


class _RadioStackSwitcher(Gtk.Box):
    """ Same as GtkStackSwitcher but with radio button (i.e different semantic) """

    def __init__(self, margin=10):
        super().__init__()
        self.props.orientation = Gtk.Orientation.VERTICAL
        self.set_spacing(margin)
        self.props.margin = margin
        self.radiogroup = []
        self.stack = Gtk.Stack()
        self.button_box = Gtk.Box()
        self.button_box.set_spacing(margin)
        self.add(self.button_box)
        self.add(self.stack)
        self.selected_child = None
        self.selected_name = None

    def add_named(self, child, name, title):
        radio = Gtk.RadioButton.new_with_label(None, title)
        if len(self.radiogroup) > 0:
            radio.join_group(self.radiogroup[-1])
        self.radiogroup.append(radio)
        radio.set_hexpand(True)
        radio.set_halign(Gtk.Align.CENTER)
        radio.connect("toggled", self.__radio_handler, name)
        self.button_box.add(radio)
        self.stack.add_named(child, name)
        if self.selected_child is None:
            self.selected_child = child
            self.selected_name = name

    def __radio_handler(self, button, name):
        if button.props.active:
            self.stack.set_visible_child_name(name)
            self.selected_name = name
            self.selected_child = self.stack.get_child_by_name(name)


class _RelativeScalingWidget(Gtk.Box):
    """ A form to specify the relative scaling factor """

    def __init__(self, current_scale, margin=10):
        super().__init__(valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
        self.props.spacing = margin
        self.add(Gtk.Label(label=_("Scale factor")))
        # Largest page size is 200 inch and smallest is 1 inch
        # so we can set a min and max
        self.entry = _LinkedSpinButton(100 / 200.0, 100 * 200.0, 1, 10)
        self.entry.set_activates_default(True)
        self.add(self.entry)
        self.add(Gtk.Label(label=_("%")))
        self.entry.set_value(current_scale * 100)

    def get_value(self):
        return self.entry.get_value() / 100


class PaperSizeWidget(Gtk.Grid):
    def __init__(self, size, margin=16):
        super().__init__(margin=margin, row_spacing=8, column_spacing=8, row_homogeneous=True)

        self.attach(Gtk.Label(label=_("Width"), halign=Gtk.Align.START), 1, 1, 1, 1)
        self.width_entry = _LinkedSpinButton(25.4, 5080, 1, 0)
        self.w_entry_id = self.width_entry.connect('value-changed', self.width_changed)
        self.attach(self.width_entry, 2, 1, 1, 1)
        self.attach(Gtk.Label(label=_("mm"), halign=Gtk.Align.START), 4, 1, 1, 1)

        self.attach(Gtk.Label(label=_("Height"), halign=Gtk.Align.START), 1, 2, 1, 1)
        self.height_entry = _LinkedSpinButton(25.4, 5080, 1, 10)
        self.h_entry_id = self.height_entry.connect('value-changed', self.height_changed)
        self.attach(self.height_entry, 2, 2, 1, 1)
        self.attach(Gtk.Label(label=_("mm"), halign=Gtk.Align.START), 4, 2, 1, 1)

        self.attach(Gtk.Label(_("Paper size"), halign=Gtk.Align.START), 1, 3, 1, 1)
        self.combo = Gtk.ComboBoxText(margin=0)
        self.combo_changed_id = self.combo.connect('changed', self.paper_size_changed)
        self.papers = [Gtk.PaperSize.new_custom('Custom', _("Custom"), 0, 0, Gtk.Unit.MM)]
        paper_list = ['iso_a3', 'iso_a4', 'iso_a5', 'na_letter', 'na_legal', 'na_ledger']
        self.papers += [Gtk.PaperSize.new(p) for p in paper_list]
        for p in self.papers:
            p.size = [round(p.get_width(Gtk.Unit.MM), 5), round(p.get_height(Gtk.Unit.MM), 5)]
            self.combo.append(None, p.get_display_name())
        self.attach(self.combo, 2, 3, 1, 1)

        self.attach(Gtk.Label(_("Orientation"), halign=Gtk.Align.START), 1, 4, 1, 1)
        self.port = Gtk.RadioButton(label=_("Portrait"), group=None)
        self.land = Gtk.RadioButton(label=_("Landscape"), group=self.port)
        self.port.connect('clicked', self.orientation_clicked)
        box1 = Gtk.Box()
        box1.pack_start(self.port, True, True, 0)
        box1.pack_start(self.land, True, True, 0)
        self.attach(box1, 2, 4, 1, 1)

        box2 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        box2.pack_start(Gtk.Label("┐"), True, True, 0)
        self.ratio_cb = Gtk.CheckButton(margin_top=1)
        box2.pack_start(self.ratio_cb, True, True, 0)
        box2.pack_start(Gtk.Label("┘"), True, True, 0)
        self.attach(box2, 3, 1, 1, 2)

        if size is None:
            size = [210, 297]  # A4 by default
            self.ratio_cb.set_sensitive(False)
        else:
            self.ratio_cb.set_active(True)
            self.ratio_cb.connect('clicked', self.width_changed)

        self.ratios = [size[0] / size[1], size[1] / size[0]]
        self.set_entry_size(size)
        self.select_paper_and_orientation()
        self.update_entry_limits()
        self.ratio_cb.connect('clicked', self.update_entry_limits)

    def update_entry_limits(self, _widget=None):
        r = self.ratios if self.ratio_cb.get_active() else [1, 1]
        wrange = max(25.4, 25.4 * r[0]), min(5080, 5080 * r[0])
        hrange = max(25.4, 25.4 * r[1]), min(5080, 5080 * r[1])
        self.width_entry.set_range(*wrange)
        self.height_entry.set_range(*hrange)

    def width_changed(self, _widget):
        if self.ratio_cb.get_active():
            width = self.width_entry.get_value()
            self.set_entry_size((width, width / self.ratios[0]))
        self.select_paper_and_orientation()

    def height_changed(self, _widget):
        if self.ratio_cb.get_active():
            height = self.height_entry.get_value()
            self.set_entry_size((height / self.ratios[1], height))
        self.select_paper_and_orientation()

    def orientation_clicked(self, _widget):
        size = self.get_value(Gtk.Unit.MM)
        self.width_entry.set_range(25.4, 5080)
        self.height_entry.set_range(25.4, 5080)
        self.set_entry_size(sorted(size, reverse=self.land.get_active()))
        self.ratios.sort(reverse=self.land.get_active())
        self.update_entry_limits()

    def paper_size_changed(self, combo):
        paper = self.papers[combo.get_active()]
        size = paper.get_width(Gtk.Unit.MM), paper.get_height(Gtk.Unit.MM)
        self.set_entry_size(sorted(size, reverse=self.land.get_active()))
        if round(size[0] / size[1], 5) not in [round(r, 5) for r in self.ratios]:
            self.ratio_cb.set_active(False)
            self.update_entry_limits()

    def select_paper_and_orientation(self):
        size = self.get_value(Gtk.Unit.MM)
        self.papers[0].set_size(*size, Gtk.Unit.MM)
        size = [round(s, 5) for s in size]
        for num, paper in reversed(list(enumerate(self.papers))):
            if paper.size in [size, size[::-1]]:
                break
        self.combo.set_active(num)
        self.port.set_active(size[0] < size[1])
        self.land.set_active(size[0] > size[1])

    def set_entry_size(self, size):
        """Set entry size in mm"""
        with GObject.signal_handler_block(self.width_entry, self.w_entry_id):
            self.width_entry.set_value(size[0])
        with GObject.signal_handler_block(self.height_entry, self.h_entry_id):
            self.height_entry.set_value(size[1])

    def get_value(self, unit=Gtk.Unit.POINTS):
        """Get entry size in points or mm"""
        size = [self.width_entry.get_value(), self.height_entry.get_value()]
        if unit == Gtk.Unit.POINTS:
            size = [s * 72 / 25.4 for s in size]
        return size


class _CropHideWidget(Gtk.Frame):
    sides = ('L', 'R', 'T', 'B')
    side_names = {'L': _('Left'), 'R': _('Right'), 'T': _('Top'), 'B': _('Bottom')}
    opposite_sides = {'L': 'R', 'R': 'L', 'T': 'B', 'B': 'T'}

    def __init__(self, val, margin=12):
        super().__init__(shadow_type=Gtk.ShadowType.NONE)
        grid = Gtk.Grid(halign=Gtk.Align.CENTER)
        grid.set_column_spacing(margin)
        grid.set_row_spacing(margin)
        grid.props.margin = margin
        self.add(grid)
        label = Gtk.Label(
            label=_(
                'Cropping/hiding does not remove any content '
                'from the PDF file, it only hides it.'
            )
        )
        label.props.margin = margin
        label.set_line_wrap(True)
        label.set_max_width_chars(38)
        grid.attach(label, 0, 0, 3, 1)
        self.spin_changed_callback = None
        self.spin_list = []
        units = 2 * [_('% of width')] + 2 * [_('% of height')]

        for row, side in enumerate(_CropHideWidget.sides):
            label = Gtk.Label(label=_CropHideWidget.side_names[side])
            label.set_alignment(0.0, 0.5)
            grid.attach(label, 0, row + 1, 1, 1)

            adj = Gtk.Adjustment(
                value=100.0 * val.pop(0),
                lower=0.0,
                upper=90.0,
                step_increment=1.0,
                page_increment=5.0,
                page_size=0.0,
            )
            spin = Gtk.SpinButton(adjustment=adj, climb_rate=0, digits=1)
            spin.set_activates_default(True)
            spin.connect('value-changed', self.__set_value, self, side)
            self.spin_list.append(spin)
            grid.attach(spin, 1, row + 1, 1, 1)

            label = Gtk.Label(label=units.pop(0))
            label.set_alignment(0.0, 0.5)
            grid.attach(label, 2, row + 1, 1, 1)

    @staticmethod
    def __set_value(spinbutton, self, side):
        opp_side = self.opposite_sides[side]
        adj = self.spin_list[self.sides.index(opp_side)].get_adjustment()
        limit = 90.0 - spinbutton.get_value()
        adj.set_upper(limit)
        opp_spinner = self.spin_list[self.sides.index(opp_side)]
        opp_spinner.set_value(min(opp_spinner.get_value(), limit))
        if callable(self.spin_changed_callback):
            self.spin_changed_callback()

    def set_spinb_changed_callback(self, callback):
        self.spin_changed_callback = callback

    def set_val(self, val):
        for i, spin in enumerate(self.spin_list):
            spin.set_value(val[i] * 100)

    def get_val(self):
        return Sides(*(spin.get_value() / 100.0 for spin in self.spin_list))


class BaseDialog(Gtk.Dialog):
    def __init__(self, title, parent, prepend_buttons=None):
        buttons = () if prepend_buttons is None else prepend_buttons
        buttons += (
            _("_Cancel"), Gtk.ResponseType.CANCEL,
            _("_OK"), Gtk.ResponseType.OK,
        )
        super().__init__(
            title=title,
            parent=parent,
            flags=Gtk.DialogFlags.MODAL,
            buttons=buttons,
        )
        self.set_default_response(Gtk.ResponseType.OK)


class ScaleDialog(BaseDialog):
    """ A dialog box to define page size or scale factor """

    def __init__(self, model, selection, window):
        super().__init__(title=_("Page size"), parent=window)
        self.set_resizable(False)
        page = model.get_value(model.get_iter(selection[-1]), 0)
        paper_widget = PaperSizeWidget(page.size_in_mm(), margin=1)
        paper_widget.attach(Gtk.Label(_("Fit mode"), halign=Gtk.Align.START), 1, 5, 1, 1)
        self.combo = Gtk.ComboBoxText()
        self.combo.append('SCALE', _("Scale"))
        self.combo.append('SCALE-ADD-MARG', _("Scale & Add margins"))
        self.combo.append('CROP-ADD-MARG', _("Crop & Add margins"))
        self.combo.set_active(0)
        paper_widget.attach(self.combo, 2, 5, 1, 1)
        rel_widget = _RelativeScalingWidget(page.scale)
        self.scale_stack = _RadioStackSwitcher(margin=15)
        self.scale_stack.add_named(paper_widget, "Fit", _("Fit to paper"))
        self.scale_stack.add_named(rel_widget, "Relative", _("Relative"))
        pagesizeframe = Gtk.Frame(shadow_type=Gtk.ShadowType.NONE)
        pagesizeframe.add(self.scale_stack)
        self.vbox.pack_start(pagesizeframe, True, True, 0)
        self.show_all()
        self.selection = selection

    def run_get(self):
        """ Open the dialog and return the size value """
        result = self.run()
        val = None
        if result == Gtk.ResponseType.OK:
            s = self.scale_stack
            mode = 'SCALE' if s.selected_name == 'Relative' else self.combo.get_active_id()
            val = s.selected_child.get_value(), mode
        self.destroy()
        return val


def white_borders(model, selection, pdfqueue):
    crop = []
    for path in selection:
        it = model.get_iter(path)
        p = model.get_value(it, 0)
        pdfdoc = pdfqueue[p.nfile - 1]

        page = pdfdoc.document.get_page(p.npage - 1)
        # Always render pages at 72 dpi whatever the zoom or scale of the page
        w, h = page.get_size()
        orig_crop = p.crop.rotated(-p.rotate_times(p.angle))

        first_col = int(w * orig_crop.left)
        last_col = min(int(w), int(w * (1 - orig_crop.right) + 1))
        first_row = int(h * orig_crop.top)
        last_row = min(int(h), int(h * (1 - orig_crop.bottom) + 1))
        w = int(w)
        h = int(h)
        thumbnail = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h)
        cr = cairo.Context(thumbnail)
        with pdfdoc.render_lock:
            page.render(cr)
        data = thumbnail.get_data().cast("i")
        whitecol = memoryview(b"\0" * (last_row - first_row) * 4).cast("i")
        whiterow = memoryview(b"\0" * (last_col - first_col) * 4).cast("i")
        crop_this_page = [0.0, 0.0, 0.0, 0.0]
        # Left
        for col in range(first_col, last_col):
            if data[col::w][first_row:last_row] != whitecol:
                crop_this_page[0] = (col - 1) / w
                break

        # Right
        for col in range(last_col - 1, first_col - 1, -1):
            if data[col::w][first_row:last_row] != whitecol:
                crop_this_page[1] = (w - col - 1) / w
                break

        # Top
        for row in range(first_row, last_row):
            if data[row * w : (row + 1) * w][first_col:last_col] != whiterow:
                crop_this_page[2] = (row - 1) / h
                break

        # Bottom
        for row in range(last_row - 1, first_row - 1, -1):
            if data[row * w : (row + 1) * w][first_col:last_col] != whiterow:
                crop_this_page[3] = (h - row - 1) / h
                break

        crop.append(Sides(*crop_this_page).rotated(p.rotate_times(p.angle)))
    return crop


class BlankPageDialog(BaseDialog):
    def __init__(self, size, window):
        super().__init__(title=_("Insert Blank Page"), parent=window)
        self.set_resizable(False)
        self.paper_widget = PaperSizeWidget(size)
        self.vbox.pack_start(self.paper_widget, True, True, 0)
        self.show_all()

    def run_get(self):
        result = self.run()
        r = None
        if result == Gtk.ResponseType.OK:
            r = self.paper_widget.get_value()
        self.destroy()
        return r


class MergePagesDialog(BaseDialog):

    def __init__(self, window, size, equal):
        super().__init__(title=_("Merge Pages"), parent=window)
        self.size = size
        self.set_resizable(False)
        max_margin = int(((14400 - max(*size)) / 2) * 25.4 / 72)
        self.marg = Gtk.SpinButton.new_with_range(0, max_margin, 1)
        self.marg.set_activates_default(True)
        self.marg.connect('value-changed', self.on_sb_value_changed)
        self.cols = Gtk.SpinButton.new_with_range(1, 2, 1)
        self.cols.set_activates_default(True)
        self.cols.connect('value-changed', self.on_sb_value_changed)
        self.rows = Gtk.SpinButton.new_with_range(1, 1, 1)
        self.rows.set_activates_default(True)
        self.rows.connect('value-changed', self.on_sb_value_changed)

        marg_lbl1 = Gtk.Label(_("Margin"), halign=Gtk.Align.START)
        marg_lbl2 = Gtk.Label(_("mm"), halign=Gtk.Align.START)
        cols_lbl1 = Gtk.Label(_("Columns"), halign=Gtk.Align.START)
        rows_lbl1 = Gtk.Label(_("Rows"), halign=Gtk.Align.START)
        grid1 = Gtk.Grid(column_spacing=12, row_spacing=12, margin=12, halign=Gtk.Align.CENTER)
        grid1.attach(marg_lbl1, 0, 1, 1, 1)
        grid1.attach(self.marg, 1, 1, 1, 1)
        grid1.attach(marg_lbl2, 2, 1, 1, 1)
        grid1.attach(cols_lbl1, 0, 2, 1, 1)
        grid1.attach(self.cols, 1, 2, 1, 1)
        grid1.attach(rows_lbl1, 0, 3, 1, 1)
        grid1.attach(self.rows, 1, 3, 1, 1)
        self.vbox.pack_start(grid1, False, False, 8)

        self.hor = Gtk.RadioButton(label=_("Horizontal"), group=None)
        vrt = Gtk.RadioButton(label=_("Vertical"), group=self.hor)
        self.l_r = Gtk.RadioButton(label=_("Left to Right"), group=None)
        r_l = Gtk.RadioButton(label=_("Right to Left"), group=self.l_r)
        self.t_b = Gtk.RadioButton(label=_("Top to Bottom"), group=None)
        b_t = Gtk.RadioButton(label=_("Bottom to Top"), group=self.t_b)
        grid2 = Gtk.Grid(column_spacing=6, row_spacing=12, margin=12, halign=Gtk.Align.CENTER)
        grid2.attach(self.hor, 0, 1, 1, 1)
        grid2.attach(vrt, 1, 1, 1, 1)
        grid2.attach(self.l_r, 0, 2, 1, 1)
        grid2.attach(r_l, 1, 2, 1, 1)
        grid2.attach(self.t_b, 0, 3, 1, 1)
        grid2.attach(b_t, 1, 3, 1, 1)
        frame1 = Gtk.Frame(label=_("Page Order"), margin=8)
        frame1.add(grid2)
        self.vbox.pack_start(frame1, False, False, 0)

        t = "" if equal else _("Non-uniform page size - using max size")
        warn_lbl = Gtk.Label(t, margin=8, wrap=True, width_chars=36, max_width_chars=36)
        self.vbox.pack_start(warn_lbl, False, False, 0)
        self.size_lbl = Gtk.Label(halign=Gtk.Align.CENTER, margin_bottom=16)
        self.vbox.pack_start(self.size_lbl, False, False, 0)
        self.show_all()

    def size_with_margin(self):
        width = self.size[0] + 2 * self.marg.get_value() * 72 / 25.4
        height = self.size[1] + 2 * self.marg.get_value() * 72 / 25.4
        return width, height

    def on_sb_value_changed(self, _button):
        width, height = self.size_with_margin()
        self.cols.set_range(1, 14400 // width)
        self.rows.set_range(1, 14400 // height)
        cols = int(self.cols.get_value())
        rows = int(self.rows.get_value())
        width = str(round(cols * width * 25.4 / 72, 1))
        height = str(round(rows * height * 25.4 / 72, 1))
        t =  _("Merged page size:") + " " + width + _("mm") + " \u00D7 " + height + _("mm")
        self.size_lbl.set_label(t)

    def run_get(self):
        self.cols.set_value(2)
        result = self.run()
        if result != Gtk.ResponseType.OK:
            self.destroy()
            return None
        cols = int(self.cols.get_value())
        rows = int(self.rows.get_value())
        range_cols = range(cols) if self.l_r.get_active() else range(cols)[::-1]
        range_rows = range(rows) if self.t_b.get_active() else range(rows)[::-1]
        if self.hor.get_active():
            order = [(row, col) for row in range_rows for col in range_cols]
        else:
            order = [(row, col) for col in range_cols for row in range_rows]
        self.destroy()
        return cols, rows, order, self.size_with_margin()


class _OffsetWidget(Gtk.Frame):
    def __init__(self, offset, dpage, lpage):
        super().__init__(shadow_type=Gtk.ShadowType.NONE)
        self.spinb_x = Gtk.SpinButton.new_with_range(0, 100, 1)
        self.spinb_x.set_activates_default(True)
        self.spinb_x.set_digits(1)
        self.spinb_x.connect('value-changed', self.spinb_val_changed)
        self.spinb_y = Gtk.SpinButton.new_with_range(0, 100, 1)
        self.spinb_y.set_activates_default(True)
        self.spinb_y.set_digits(1)
        self.spinb_y.connect('value-changed', self.spinb_val_changed)
        self.spinb_changed_callback = None

        lbl1_x = Gtk.Label(_("Horizontal offset"), halign=Gtk.Align.START)
        lbl2_x = Gtk.Label(_("%"), halign=Gtk.Align.START)
        lbl1_y = Gtk.Label(_("Vertical offset"), halign=Gtk.Align.START)
        lbl2_y = Gtk.Label(_("%"), halign=Gtk.Align.START)
        grid = Gtk.Grid(column_spacing=12, row_spacing=12, margin=12, halign=Gtk.Align.CENTER)
        grid.attach(lbl1_x, 0, 1, 1, 1)
        grid.attach(self.spinb_x, 1, 1, 1, 1)
        grid.attach(lbl2_x, 2, 1, 1, 1)
        grid.attach(lbl1_y, 0, 2, 1, 1)
        grid.attach(self.spinb_y, 1, 2, 1, 1)
        grid.attach(lbl2_y, 2, 2, 1, 1)
        self.add(grid)

        self.spinb_x.set_value(offset[0] * 100)
        self.spinb_y.set_value(offset[1] * 100)
        self.set_scale(dpage, lpage)

    def spinb_val_changed(self, spinbutton):
        if callable(self.spinb_changed_callback):
            self.spinb_changed_callback()

    def set_spinb_changed_callback(self, callback):
        self.spinb_changed_callback = callback

    def set_val(self, values):
        self.spinb_x.set_value(values.left * self.scale.left)
        self.spinb_y.set_value(values.top * self.scale.top)

    def get_val(self):
        """Get left, right, top, bottom offset from dest page edges."""
        xval = self.spinb_x.get_value()
        yval = self.spinb_y.get_value()
        return Sides(xval, 100 - xval, yval, 100 - yval) / self.scale

    def get_diff_offset(self):
        """Get the fraction of page size differenace at top-left."""
        return self.spinb_x.get_value() / 100, self.spinb_y.get_value() / 100

    def set_scale(self, dpage, lpage):
        """Set scale between 'destination page edge offset' and 'page size diff offset'."""
        dw, dh = dpage.size_in_pixel()
        lw, lh = lpage.size_in_pixel()
        scalex = 100 * dw / (dw - lw) if dw - lw != 0 else 1e10
        scaley = 100 * dh / (dh - lh) if dh - lh != 0 else 1e10
        self.scale = Sides(scalex, scalex, scaley, scaley)


class DrawingAreaWidget(Gtk.Box):
    """A widget which draws a page. It has tools for editing a rectangle (crop/hide/offset)."""

    def __init__(self, page, pdfqueue, spinbutton_widget=None, draw_on_page_func=None):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        page = page.duplicate()
        page.thumbnail = page.thumbnail if page.crop == Sides() else None
        page.resample = -1
        self.damodel = Gtk.ListStore(GObject.TYPE_PYOBJECT)
        self.damodel.append([page])
        self.pdfqueue = pdfqueue
        self.spinbutton_widget = spinbutton_widget
        self.padding = 25  # Around thumbnail
        self.click_pos = 0, 0
        self.click_val = [0] * 4
        self.x_po_rel_thmb = 0
        self.y_po_rel_thmb = 0
        self.x_po_rel_sw = 0
        self.y_po_rel_sw = 0
        self.cursor_name = 'default'
        self.rendering_thread = None
        self.render_id = None
        self.surface = None
        self.adjust_rect = [0] * 4
        self.allow_adjust_rect_resize = True
        self.handle_move_limits = True
        self.draw_on_page = draw_on_page_func
        self.da = Gtk.DrawingArea()
        self.da.set_events(self.da.get_events()
                              | Gdk.EventMask.BUTTON_PRESS_MASK
                              | Gdk.EventMask.BUTTON_RELEASE_MASK
                              | Gdk.EventMask.POINTER_MOTION_MASK)
        self.da.connect('draw', self.on_draw)
        self.da.connect('button-press-event', self.button_press_event)
        self.da.connect('button-release-event', self.button_release_event)
        self.da.connect('motion-notify-event', self.motion_notify_event)
        self.da.connect('size_allocate', self.size_allocate)

        self.sw = Gtk.ScrolledWindow()
        self.sw.set_size_request(self.padding + 100, self.padding + 100)
        self.sw.add(self.da)
        self.sw.connect('size_allocate', self.draw_page)
        self.sw.connect('scroll_event', self.sw_scroll_event)
        self.sw.connect('leave_notify_event', self.sw_leave_notify_event)
        self.pack_start(self.sw, True, True, 0)

        if self.spinbutton_widget is not None:
            self.spinbutton_widget.set_spinb_changed_callback(self.draw_page)
            self.pack_start(self.spinbutton_widget, False, False, 0)
            cb = Gtk.CheckButton(label=_("Show values"), margin=12, halign=Gtk.Align.CENTER)
            cb.connect('toggled', self.cb_show_val_toggled)
            cb.connect('realize', self.cb_realize)
            self.pack_start(cb, False, False, 0)

    def cb_realize(self, _cb):
        self.spinbutton_widget.hide()

    def cb_show_val_toggled(self, cb):
        self.spinbutton_widget.set_visible(cb.get_active())

    def store_pointer_location(self, sw, event):
        """Store pointer location relative to thumbnail and scrolled window."""
        ha = sw.get_hadjustment()
        thmb_x = event.x - self.padding + ha.get_value()
        self.x_po_rel_thmb = thmb_x / (ha.get_upper() - self.padding * 2)
        self.x_po_rel_sw = event.x / ha.get_page_size()

        va = sw.get_vadjustment()
        thmb_y = event.y - self.padding + va.get_value()
        self.y_po_rel_thmb = thmb_y / (va.get_upper() - self.padding * 2)
        self.y_po_rel_sw = event.y / va.get_page_size()

    def sw_scroll_event(self, sw, event):
        if not event.state & Gdk.ModifierType.CONTROL_MASK:
            return Gdk.EVENT_PROPAGATE
        w = max(p.width_in_pixel() for [p] in self.damodel)
        h = max(p.height_in_pixel() for [p] in self.damodel)
        maxfactor = (80000000 / (w * h)) ** .5  # Limit zoom at about 304Mb

        if event.direction == Gdk.ScrollDirection.SMOOTH:
            factor = round(min(1 - event.get_scroll_deltas()[2] * 0.3, maxfactor), 2)
        elif event.direction == Gdk.ScrollDirection.UP:
            factor = round(min(1.3, maxfactor), 2)
        elif event.direction == Gdk.ScrollDirection.DOWN:
            factor = 0.7
        else:
            return Gdk.EVENT_PROPAGATE
        self.store_pointer_location(sw, event)
        dpage = self.damodel[0][0]
        self.da.set_size_request((dpage.width_in_pixel() + self.padding * 2) * factor,
                                 (dpage.height_in_pixel() + self.padding * 2) * factor)
        return Gdk.EVENT_STOP

    def size_allocate(self, _da, da_rect):
        self.set_adjustment_values()
        self.set_zoom(da_rect)
        self.init_surface()
        self.silent_render()

    def set_adjustment_values(self):
        """Update adjustment values so it does zoom in at cursor."""
        ha = self.sw.get_hadjustment()
        thmb_x = (ha.get_upper() - self.padding * 2) * self.x_po_rel_thmb
        sw_x = ha.get_page_size() * self.x_po_rel_sw
        ha.set_value(self.padding + thmb_x - sw_x)

        va = self.sw.get_vadjustment()
        thmb_y = (va.get_upper() - self.padding * 2) * self.y_po_rel_thmb
        sw_y = va.get_page_size() * self.y_po_rel_sw
        va.set_value(self.padding + thmb_y - sw_y)

    def set_zoom(self, da_rect):
        dpage = self.damodel[0][0]
        thmb_max_w = da_rect.width - self.padding * 2
        thmb_max_h = da_rect.height - self.padding * 2
        zoom_x = thmb_max_w / dpage.width_in_points()
        zoom_y = thmb_max_h / dpage.height_in_points()
        for [page] in self.damodel:
            page.zoom = min(zoom_x, zoom_y)

    def silent_render(self):
        if self.render_id:
            GObject.source_remove(self.render_id)
        self.render_id = GObject.timeout_add(149, self.render)

    def quit_rendering(self):
        if self.rendering_thread is None:
            return False
        self.rendering_thread.quit = True
        self.rendering_thread.join(timeout=0.01)
        return self.rendering_thread.is_alive()

    def render(self):
        self.render_id = None
        alive = self.quit_rendering()
        if alive:
            self.silent_render()
            return
        self.rendering_thread = PDFRenderer(self.damodel, self.pdfqueue, [0, 1] , 1)
        self.rendering_thread.connect('update_thumbnail', self.update_thumbnail)
        self.rendering_thread.start()

    def update_thumbnail(self, _obj, ref, thumbnail, _zoom, _scale, _is_preview):
        if thumbnail is None:
            return
        path = ref.get_path()
        page = self.damodel[path][0]
        page.thumbnail = thumbnail
        self.draw_page()

    def button_press_event(self, _darea, event):
        self.click_pos = event.x, event.y
        if event.button == 2:
            self.set_cursor('move')
        elif event.button == 1 and self.spinbutton_widget is not None:
            self.click_val = self.spinbutton_widget.get_val()

    def button_release_event(self, _darea, event):
        sc = self.get_suggested_cursor(event)
        self.set_cursor(sc)

    def get_suggested_cursor(self, event):
        """Get appropriate cursor when moving mouse over adjust rect (crop/hide/offset)."""
        margin = 5
        r = self.adjust_rect
        if self.allow_adjust_rect_resize:
            w = r[0] - margin < event.x < r[0] + margin
            e = r[0] + r[2] - margin < event.x < r[0] + r[2] + margin
            n = r[1] - margin < event.y < r[1] + margin
            s = r[1] + r[3] - margin < event.y < r[1] + r[3] + margin
        else:
            w = e = n = s = False
        x_area = r[0] + margin < event.x < r[0] + r[2] - margin
        y_area = r[1] + margin < event.y < r[1] + r[3] - margin

        if n and w:
            cursor_name = 'nw-resize'
        elif s and w:
            cursor_name = 'sw-resize'
        elif s and e:
            cursor_name = 'se-resize'
        elif n and e:
            cursor_name = 'ne-resize'
        elif w and y_area:
            cursor_name = 'w-resize'
        elif e and y_area:
            cursor_name = 'e-resize'
        elif n and x_area:
            cursor_name = 'n-resize'
        elif s and x_area:
            cursor_name = 's-resize'
        elif x_area and y_area:
            cursor_name = 'move'
        else:
            cursor_name = 'default'
        return cursor_name

    def set_cursor(self, cursor_name):
        if cursor_name != self.cursor_name:
            self.cursor_name = cursor_name
            cursor = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), cursor_name)
            self.get_window().set_cursor(cursor)

    def motion_notify_event(self, _darea, event):
        if event.state & Gdk.ModifierType.BUTTON2_MASK:
            self.pan_view(event)
            self.set_cursor('move')
        elif event.state & Gdk.ModifierType.BUTTON1_MASK:
            self.adjust_val(event)
        else:
            sc = self.get_suggested_cursor(event)
            self.set_cursor(sc)

    def pan_view(self, event):
        ha = self.sw.get_hadjustment()
        va = self.sw.get_vadjustment()
        ha.set_value(ha.get_value() + self.click_pos[0] - event.x)
        va.set_value(va.get_value() + self.click_pos[1] - event.y)

    def adjust_val(self, event):
        if self.spinbutton_widget is None:
            return
        left, right, top, bottom = self.spinbutton_widget.get_val()
        page = self.damodel[0][0]
        if self.cursor_name in ['w-resize', 'nw-resize', 'sw-resize', 'move']:
            left = self.click_val[0] + ((event.x - self.click_pos[0]) / page.width_in_pixel())
        if self.cursor_name in ['e-resize', 'ne-resize', 'se-resize', 'move']:
            right = self.click_val[1] - ((event.x - self.click_pos[0]) / page.width_in_pixel())
        if self.cursor_name in ['n-resize', 'nw-resize', 'ne-resize', 'move']:
            top = self.click_val[2] + ((event.y - self.click_pos[1]) / page.height_in_pixel())
        if self.cursor_name in ['s-resize', 'sw-resize', 'se-resize', 'move']:
            bottom = self.click_val[3] - ((event.y - self.click_pos[1]) / page.height_in_pixel())
        v = Sides(left, right, top, bottom)
        if self.cursor_name in ['move'] and self.handle_move_limits:
            v += Sides(*(min(0, v[i]) for i in [1, 0, 3, 2]))
        self.spinbutton_widget.set_spinb_changed_callback(None)
        self.spinbutton_widget.set_val(v)
        self.spinbutton_widget.set_spinb_changed_callback(self.draw_page)
        self.draw_page()

    def sw_leave_notify_event(self, _sw, event):
        if event.state & Gdk.ModifierType.BUTTON1_MASK:
            return
        self.set_cursor('default')

    def init_surface(self):
        aw = self.da.get_allocated_width()
        ah = self.da.get_allocated_height()
        self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, aw, ah)

    def on_draw(self, _darea, cr):
        if self.surface is not None:
            cr.set_source_surface(self.surface, 0, 0)
            cr.paint()

    def draw_page(self, _widget=None, _rect=None):
        """Draw the 'destination' thumbnail page."""
        if len(self.damodel) == 0 or self.surface is None:
            return
        dpage = self.damodel[0][0]
        if dpage.thumbnail is None:
            return
        cr = cairo.Context(self.surface)
        aw = self.da.get_allocated_width()
        ah = self.da.get_allocated_height()

        # Destination page rectangle
        dw = dpage.width_in_pixel()
        dh = dpage.height_in_pixel()
        dx = int(.5 + (aw - dw) / 2)
        dy = int(.5 + (ah - dh) / 2)

        # Page border
        cr.set_source_rgb(0, 0, 0)
        cr.rectangle(dx - 2, dy - 2, dw + 4, dh + 4)
        cr.fill_preserve()
        cr.clip()

        # Fill white paper
        cr.set_source_rgb(1, 1, 1)
        cr.rectangle(dx, dy, dw, dh)
        cr.fill()

        # Add the thumbnail
        (dw0, dh0) = (dh, dw) if dpage.angle in [90, 270] else (dw, dh)
        cr.translate(dx, dy)
        if dpage.angle > 0:
            cr.translate(dw / 2, dh / 2)
            cr.rotate(dpage.angle * pi / 180)
            cr.translate(-dw0 / 2, -dh0 / 2)
        tw, th = dpage.thumbnail.get_width(), dpage.thumbnail.get_height()
        tw, th = (th, tw) if dpage.angle in [90, 270] else (tw, th)
        cr.scale(dw / tw, dh / th)
        cr.set_source_surface(dpage.thumbnail)
        cr.get_source().set_filter(cairo.FILTER_FAST)
        cr.paint()

        cr.identity_matrix()
        cr.set_line_width(1)

        if dpage.hide != Sides():
            # Draw the hide rectangle. For the Hide dialog this will be the crop rectangle
            dwfull = dpage.scale * dpage.size.width * dpage.zoom
            dhfull = dpage.scale * dpage.size.height * dpage.zoom
            rx = round(dx + (dpage.hide.left - dpage.crop.left) * dwfull) - .5
            ry = round(dy + (dpage.hide.top - dpage.crop.top) * dhfull) - .5
            rw = round(dwfull * (1 - dpage.hide.left - dpage.hide.right)) + 1
            rh = round(dhfull * (1 - dpage.hide.top - dpage.hide.bottom)) + 1
            hide_rect = [rx, ry, rw, rh]

            cr.set_source_rgba(1, 1, 1, .7)
            cr.rectangle(*hide_rect)
            cr.stroke()
            cr.set_source_rgba(0, 0, 0, .3)
            cr.set_dash([8, 8])
            cr.rectangle(*hide_rect)
            cr.stroke()

            # Darken the area outside of rectangle
            cr.set_source_rgba(0, 0, 0, .3)
            cr.rectangle(dx, dy, dw, dh)
            cr.rectangle(*hide_rect)
            cr.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
            cr.fill()

        if callable(self.draw_on_page):
            self.adjust_rect = self.draw_on_page(cr, dx, dy, dw, dh, self.damodel)
            # Draw the adjust rectangle
            cr.identity_matrix()
            cr.set_source_rgb(1, 1, 1)
            cr.set_dash([])
            cr.rectangle(*self.adjust_rect)
            cr.stroke()
            cr.set_source_rgb(0, 0, 0)
            cr.set_dash([4.0, 4.0])
            cr.rectangle(*self.adjust_rect)
            cr.stroke()

        # Invalidate region
        ha = self.sw.get_hadjustment()
        va = self.sw.get_vadjustment()
        r = ha.get_value(), va.get_value(), ha.get_page_size(), va.get_page_size()
        self.da.queue_draw_area(*r)


class CropHideDialog():
    def __init__(self, window, selection, model, pdfqueue, is_unsaved, mode, update_val_func):
        title = _("Crop Margins") if mode == 'CROP' else _("Hide Margins")
        init_values = [getattr(model[row][0], mode.lower()) for row in selection]
        self.updated_values = init_values
        self.spinbutton_widget = _CropHideWidget(list(init_values[-1]), margin=8)
        page = model[selection[-1]][0]
        dawidget = DrawingAreaWidget(page, pdfqueue, self.spinbutton_widget, self.draw_on_page)
        page = dawidget.damodel[0][0]
        page.hide = page.crop if mode == 'HIDE' else page.hide
        page.crop = Sides()

        prepend_butt = (_("_Revert"), Gtk.ResponseType.REJECT, _("_Apply"), Gtk.ResponseType.APPLY)
        d = BaseDialog(title, window, prepend_butt)
        d.connect('response', self.on_response, selection, init_values, is_unsaved, update_val_func)
        d.vbox.pack_start(dawidget, True, True, 0)
        d.set_size_request(350, 500)
        d.show_all()

    def draw_on_page(self, cr, dx, dy, dw, dh, _damodel):
        """Draw on the thumbnail page."""
        v = self.spinbutton_widget.get_val()
        rx = round(dx + v.left * dw) - .5
        ry = round(dy + v.top * dh) - .5
        rw = round(dw * (1 - v.left - v.right)) + 1
        rh = round(dh * (1 - v.top - v.bottom)) + 1

        # Darken area outside of crop or hide rectangle
        cr.set_source_rgba(0, 0, 0, .3)
        cr.set_dash([])
        cr.rectangle(dx, dy, dw, dh)
        cr.rectangle(rx, ry, rw, rh)
        cr.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
        cr.fill()

        return [rx, ry, rw, rh]

    def on_response(self, dialog, response, selection, init_values, is_unsaved, update_val_func):
        if response == Gtk.ResponseType.REJECT:
            self.spinbutton_widget.set_val(init_values[-1])
            self.updated_values = init_values
            update_val_func(self.updated_values, selection, is_unsaved)
            return
        if response in [Gtk.ResponseType.OK, Gtk.ResponseType.APPLY]:
            new_val = self.spinbutton_widget.get_val()
            if any([new_val != val for val in self.updated_values]):
                self.updated_values = [new_val] * len(selection)
                update_val_func(self.updated_values, selection, True)
        if response == Gtk.ResponseType.APPLY:
            return
        dialog.destroy()


class PastePageLayerDialog():
    def __init__(self, window, dpage, lpage_stack, model, pdfqueue, mode, layer_pos):
        title = _("Overlay") if mode == 'OVERLAY' else _("Underlay")
        lpage = lpage_stack[0].duplicate()
        lpage.layerpages = [lp.duplicate() for lp in lpage_stack[1:]]
        lpage.zoom = dpage.zoom
        lpage.resample = -1
        lpage.thumbnail = None
        lpage.hide = Sides()
        self.spinbutton_widget = _OffsetWidget(layer_pos, dpage, lpage)
        dawidget = DrawingAreaWidget(dpage, pdfqueue, self.spinbutton_widget, self.draw_on_page)
        dawidget.allow_adjust_rect_resize = False
        dawidget.handle_move_limits = False
        dawidget.damodel.append([lpage])

        self.dialog = BaseDialog(title, window)
        self.dialog.vbox.pack_start(dawidget, True, True, 0)
        self.dialog.set_size_request(350, 500)
        self.dialog.show_all()

    def draw_on_page(self, cr, dx, dy, dw, dh, damodel):
        """Draw on the thumbnail page."""
        dpage, lpage = [page for [page] in damodel]
        if lpage.thumbnail is None:
            return [0] * 4
        self.spinbutton_widget.set_scale(dpage, lpage)

        # Layer page rectangle
        offset = self.spinbutton_widget.get_val()
        lx = round(dx + dw * offset.left)
        ly = round(dy + dh * offset.top)
        lw = round(lpage.zoom * lpage.width_in_points())
        lh = round(lpage.zoom * lpage.height_in_points())

        # Add the overlay/underlay
        (pw0, ph0) = (lh, lw) if lpage.angle in [90, 270] else (lw, lh)
        cr.translate(lx, ly)
        if lpage.angle > 0:
            cr.translate(lw / 2, lh / 2)
            cr.rotate(lpage.angle * pi / 180)
            cr.translate(-pw0 / 2, -ph0 / 2)
        ltw, lth = lpage.thumbnail.get_width(), lpage.thumbnail.get_height()
        ltw, lth = (lth, ltw) if lpage.angle in [90, 270] else (ltw, lth)
        cr.scale(lw / ltw, lh / lth)
        cr.set_source_surface(lpage.thumbnail)
        cr.get_source().set_filter(cairo.FILTER_FAST)
        cr.paint()

        return [lx - .5, ly - .5, lw + 1, lh + 1]

    def get_offset(self):
        """Get layer page x and y offset from top-left edge of the destination page.

        The offset is the fraction of space positioned at left and top of the pasted layer,
        where space is the difference in width and height between the layer and the page.
        """
        result = self.dialog.run()
        r = None
        if result == Gtk.ResponseType.OK:
            r = self.spinbutton_widget.get_diff_offset()
        self.dialog.destroy()
        return r

class RangeSelectDialog(BaseDialog):
    """ A dialog box to select a range of pages. """

    def __init__(self, window):
        super().__init__(title=_("Range Select"), parent=window)
        margin = 12
        range_frame = Gtk.Frame()
        self.set_resizable(False)
        grid = Gtk.Grid()
        grid.set_column_spacing(margin)
        grid.set_row_spacing(margin)
        grid.props.margin = margin
        range_frame.add(grid)
        label = Gtk.Label(label=_("Select range of pages: "))
        label.set_alignment(0.0, 0.5)
        grid.attach(label, 0, 0, 1, 1)
        self.range_entry_widget = Gtk.Entry()
        grid.attach(self.range_entry_widget, 1, 0, 1, 1)
        label = Gtk.Label(
            label=_(
                    'Use a comma to separate page numbers, '
                    'a dash to select a range of pages. \n'
                    'e.g. : "1,3,5-7,9"'
                )
        )
        label.props.margin = margin
        label.set_line_wrap(True)
        label.set_max_width_chars(38)
        grid.attach(label, 0, 1, 2, 1)
        self.vbox.pack_start(range_frame, False, False, 0)
        self.show_all()
        # Connect "changed" signal to function for checking user input
        self.range_entry_widget.connect('changed', self.on_changed)
        self.range_entry_widget.set_activates_default(True)

    def run_get(self):
        """ Open the dialog and return the selected range"""
        result = self.run()
        selected_range = None
        if result == Gtk.ResponseType.OK:
            selected_range = self.range_entry_widget.get_text()
        self.destroy()
        return selected_range

    def on_changed(self, *args):
        # Check each user entry to be one of the valid ones '0123456789,- '
        text = self.range_entry_widget.get_text()
        text = text.replace('--', '-').replace(',,', ',').replace('  ', ' ')
        self.range_entry_widget.set_text(''.join([char for char in text if char in '0123456789,- ']))
