#!/usr/bin/python3
"""I ate PDFs — a small GTK4/libadwaita utility to merge, compress, split,
and reorder PDF files."""

import os
import shutil
import subprocess
import sys
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import gi

gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk

try:
    from pypdf import PdfReader, PdfWriter
    from pdf2image import convert_from_path
except ImportError as e:
    print(f"Error: Missing required Python library '{e.name}'.")
    print("Please install the dependencies using: pip install pypdf pdf2image")
    sys.exit(1)

APP_ID = "com.github.juliengrdn.iatepdfs"
VERSION = "1.1"

# Ghostscript -dPDFSETTINGS presets with their user-facing labels.
COMPRESSION_PRESETS = [
    ("screen", "Screen (72 dpi)"),
    ("ebook", "eBook (150 dpi)"),
    ("printer", "Printer (300 dpi)"),
    ("prepress", "Prepress (300 dpi)"),
]
DEFAULT_PRESET_INDEX = 1  # ebook

FILE_THUMB_SIZE = (38, 52)
PAGE_THUMB_SIZE = (90, 126)

SELECT_FILE_HINT = "Select a file to rearrange or delete its pages"
REORDER_HINT = "Drag pages to reorder them; mark pages with the trash button to exclude them"

# Thumbnails are rendered by poppler in worker threads; keep the number of
# concurrent renders bounded so large documents don't fork dozens of
# pdftoppm processes at once.
PREVIEW_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="preview")


def is_pdf_path(path):
    return bool(path) and path.lower().endswith(".pdf")


def iter_children(widget):
    child = widget.get_first_child()
    while child is not None:
        yield child
        child = child.get_next_sibling()


class PdfPreview(Gtk.Stack):
    """A spinner that becomes a page thumbnail (or an error icon)."""

    def __init__(self, width, height, **kwargs):
        super().__init__(width_request=width, height_request=height, **kwargs)
        self._height = height
        self._picture = Gtk.Picture()
        self.add_named(Gtk.Spinner(spinning=True, halign=Gtk.Align.CENTER,
                                   valign=Gtk.Align.CENTER), "loading")
        self.add_named(self._picture, "thumbnail")
        self.add_named(Gtk.Image(icon_name="image-missing-symbolic", pixel_size=24),
                       "error")

    def render(self, pdf_path, page_number):
        """Render one page to a texture. Runs on a worker thread."""
        texture = None
        try:
            with tempfile.TemporaryDirectory() as temp_path:
                # Constrain the height only, so pages keep their aspect ratio.
                images = convert_from_path(pdf_path, first_page=page_number,
                                           last_page=page_number,
                                           output_folder=temp_path, fmt='png',
                                           size=(None, self._height))
                if images:
                    texture = Gdk.Texture.new_from_filename(images[0].filename)
        except Exception as exc:
            print(f"Could not render page {page_number} of {pdf_path}: {exc}")
        GLib.idle_add(self._show_result, texture)

    def _show_result(self, texture):
        if texture is not None:
            self._picture.set_paintable(texture)
            self.set_visible_child_name("thumbnail")
        else:
            self.set_visible_child_name("error")
        return GLib.SOURCE_REMOVE


class DraggableMixin:
    """Drag-and-drop reordering support for list/flow box children."""

    def setup_dnd(self, widget):
        source = Gtk.DragSource.new()
        source.set_actions(Gdk.DragAction.MOVE)
        source.connect("prepare", self._on_drag_prepare)
        source.connect("drag-begin", self._on_drag_begin)
        source.connect("drag-end", self._on_drag_end)
        widget.add_controller(source)

        target = Gtk.DropTarget.new(type=GObject.TYPE_OBJECT,
                                    actions=Gdk.DragAction.MOVE)
        target.connect("drop", self._on_drop)
        widget.add_controller(target)

    def _on_drag_prepare(self, source, x, y):
        widget = source.get_widget()
        source.set_icon(Gtk.WidgetPaintable(widget=widget), x, y)
        value = GObject.Value(GObject.TYPE_OBJECT, widget)
        return Gdk.ContentProvider.new_for_value(value)

    def _on_drag_begin(self, source, drag):
        source.get_widget().set_opacity(0.5)

    def _on_drag_end(self, source, drag, delete_data):
        source.get_widget().set_opacity(1.0)

    def _on_drop(self, target, value, x, y):
        raise NotImplementedError


class PdfFileRow(Adw.ActionRow, DraggableMixin):
    """A selectable, drag-reorderable row for one PDF in the file list."""

    def __init__(self, file_path, app_window):
        # use_markup=False: file names must never be parsed as Pango markup.
        super().__init__(title=os.path.basename(file_path),
                         subtitle=str(Path(file_path).parent),
                         use_markup=False, title_lines=1, subtitle_lines=1,
                         tooltip_text=file_path)
        self.file_path = file_path
        self.app_window = app_window

        self.add_prefix(Gtk.Image(icon_name="list-drag-handle-symbolic"))
        self.preview = PdfPreview(*FILE_THUMB_SIZE)
        self.add_prefix(self.preview)

        remove_button = Gtk.Button(icon_name="edit-delete-symbolic",
                                   valign=Gtk.Align.CENTER,
                                   tooltip_text="Remove from list",
                                   css_classes=["flat"])
        remove_button.connect("clicked", self._on_remove_clicked)
        self.add_suffix(remove_button)

        self.setup_dnd(self)
        PREVIEW_POOL.submit(self.preview.render, file_path, 1)

    def _on_remove_clicked(self, button):
        list_box = self.get_parent()
        if list_box is not None:
            list_box.remove(self)
        self.app_window.update_ui_state()

    def _on_drop(self, target, value, x, y):
        if not isinstance(value, PdfFileRow):
            return False
        if value is self:
            return True

        list_box = self.get_parent()
        if not isinstance(list_box, Gtk.ListBox) or value.get_parent() is not list_box:
            return False

        # Removing the selected row would clear the selection (and with it the
        # page panel), so suppress selection handling while it moves. The
        # re-selection must stay inside the suppressed section too, or its
        # unselect/select signals would clear and reload the page panel,
        # discarding any page edits.
        was_selected = list_box.get_selected_row() is value
        self.app_window.suspend_selection = True
        target_index = self.get_index()
        list_box.remove(value)
        list_box.insert(value, target_index)
        if was_selected:
            # The removed row keeps a stale "selected" flag that makes
            # select_row() a no-op; unselect first to reset it.
            list_box.unselect_row(value)
            list_box.select_row(value)
        self.app_window.suspend_selection = False
        return True


class PdfPageWidget(Gtk.FlowBoxChild, DraggableMixin):
    """A drag-reorderable card for a single PDF page, with a delete toggle."""

    def __init__(self, page_index):
        super().__init__()
        self.original_page_index = page_index
        self.is_deleted = False

        overlay = Gtk.Overlay()
        self.set_child(overlay)

        self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
                                   spacing=6, css_classes=["card"])
        overlay.set_child(self.content_box)

        self.preview = PdfPreview(*PAGE_THUMB_SIZE, margin_top=6,
                                  margin_start=6, margin_end=6)
        self.content_box.append(self.preview)
        self.content_box.append(Gtk.Label(label=f"Page {page_index + 1}",
                                          css_classes=["caption", "dim-label"],
                                          margin_bottom=6, margin_start=6,
                                          margin_end=6))

        # "osd" keeps the button visible on top of both light and dark pages.
        delete_button = Gtk.ToggleButton(icon_name="user-trash-symbolic",
                                         valign=Gtk.Align.START,
                                         halign=Gtk.Align.END,
                                         margin_top=4, margin_end=4,
                                         css_classes=["osd", "circular"],
                                         tooltip_text="Mark page for deletion")
        delete_button.connect("toggled", self._on_delete_toggled)
        overlay.add_overlay(delete_button)

        self.setup_dnd(self)

    def _on_delete_toggled(self, button):
        self.is_deleted = button.get_active()
        self.content_box.set_opacity(0.4 if self.is_deleted else 1.0)

    def _on_drop(self, target, value, x, y):
        if not isinstance(value, PdfPageWidget):
            return False
        if value is self:
            return True

        flow_box = self.get_parent()
        if not isinstance(flow_box, Gtk.FlowBox) or value.get_parent() is not flow_box:
            return False

        target_index = self.get_index()
        flow_box.remove(value)
        flow_box.insert(value, target_index)
        return True


class PdfToolWindow(Adw.ApplicationWindow):
    """The main application window."""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.set_title("I ate PDFs")
        self.set_default_size(920, 640)

        self.is_processing = False
        self.selected_pdf = None
        self.reorder_source_path = None
        self.suspend_selection = False
        self._preview_futures = []

        self._create_actions()
        self._create_ui()

        drop_target = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY)
        drop_target.connect("drop", self._on_window_drop)
        self.add_controller(drop_target)

        self.connect("close-request", self._on_close_request)

    def _create_actions(self):
        for name, callback in (("open", self._on_open_action),
                               ("clear-all", self._on_clear_action),
                               ("about", self._on_about_action)):
            action = Gio.SimpleAction.new(name, None)
            action.connect("activate", callback)
            self.add_action(action)
        self.clear_action = self.lookup_action("clear-all")
        self.clear_action.set_enabled(False)

    def _create_ui(self):
        header_bar = Adw.HeaderBar()

        open_button = Gtk.Button(action_name="win.open",
                                 tooltip_text="Open PDF files",
                                 child=Adw.ButtonContent(
                                     icon_name="document-open-symbolic",
                                     label="Open"))
        header_bar.pack_start(open_button)

        clear_button = Gtk.Button(icon_name="edit-clear-all-symbolic",
                                  action_name="win.clear-all",
                                  tooltip_text="Clear the file list")
        header_bar.pack_start(clear_button)

        primary_menu = Gio.Menu()
        primary_menu.append("About I ate PDFs", "win.about")
        menu_button = Gtk.MenuButton(menu_model=primary_menu,
                                     icon_name="open-menu-symbolic",
                                     tooltip_text="Main Menu", primary=True)
        header_bar.pack_end(menu_button)

        self.progress_spinner = Gtk.Spinner(spinning=True, visible=False,
                                            tooltip_text="Working…")
        header_bar.pack_end(self.progress_spinner)

        # Left panel: the file list, in merge order.
        files_group = Adw.PreferencesGroup(
            title="Files", description="Drag rows to set the merge order")
        self.merge_list_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE,
                                          valign=Gtk.Align.START,
                                          css_classes=["boxed-list"])
        self.merge_list_box.connect("row-selected", self._on_file_selected)
        files_group.add(Gtk.ScrolledWindow(child=self.merge_list_box, vexpand=True,
                                           hscrollbar_policy=Gtk.PolicyType.NEVER))

        self.merge_button = Gtk.Button(label="Merge All PDFs",
                                       halign=Gtk.Align.CENTER, sensitive=False,
                                       css_classes=["suggested-action", "pill"])
        self.merge_button.connect("clicked", self._on_merge_clicked)

        left_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12,
                             width_request=300, hexpand=False)
        left_panel.append(files_group)
        left_panel.append(self.merge_button)

        # Right panel: operations on the selected file.
        operations_group = Adw.PreferencesGroup(title="Operations")

        compress_row = Adw.ActionRow(title="Compress",
                                     subtitle="Reduce the file size using Ghostscript",
                                     activatable=False)
        quality_model = Gtk.StringList.new([label for _key, label in COMPRESSION_PRESETS])
        self.quality_dropdown = Gtk.DropDown(model=quality_model,
                                             selected=DEFAULT_PRESET_INDEX,
                                             valign=Gtk.Align.CENTER)
        self.compress_button = Gtk.Button(label="Compress…",
                                          valign=Gtk.Align.CENTER, sensitive=False)
        self.compress_button.connect("clicked", self._on_compress_clicked)
        compress_row.add_suffix(self.quality_dropdown)
        compress_row.add_suffix(self.compress_button)
        operations_group.add(compress_row)

        split_row = Adw.ActionRow(title="Split",
                                  subtitle="Extract every page as a separate PDF",
                                  activatable=False)
        self.split_button = Gtk.Button(label="Split…", valign=Gtk.Align.CENTER,
                                       sensitive=False)
        self.split_button.connect("clicked", self._on_split_clicked)
        split_row.add_suffix(self.split_button)
        operations_group.add(split_row)

        # Right panel: the page grid of the selected file.
        self.save_pages_button = Gtk.Button(label="Save As…",
                                            valign=Gtk.Align.CENTER, sensitive=False,
                                            css_classes=["suggested-action"])
        self.save_pages_button.connect("clicked", self._on_save_pages_clicked)

        self.pages_group = Adw.PreferencesGroup(title="Pages",
                                                description=SELECT_FILE_HINT,
                                                header_suffix=self.save_pages_button)
        self.reorder_flow_box = Gtk.FlowBox(valign=Gtk.Align.START,
                                            selection_mode=Gtk.SelectionMode.NONE,
                                            max_children_per_line=100,
                                            column_spacing=12, row_spacing=12,
                                            margin_top=6)
        self.pages_group.add(Gtk.ScrolledWindow(child=self.reorder_flow_box,
                                                vexpand=True,
                                                hscrollbar_policy=Gtk.PolicyType.NEVER))

        right_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24,
                              hexpand=True)
        right_panel.append(operations_group)
        right_panel.append(self.pages_group)

        content_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=18,
                              margin_top=12, margin_bottom=12,
                              margin_start=12, margin_end=12)
        content_box.append(left_panel)
        content_box.append(right_panel)

        placeholder = Adw.StatusPage(
            icon_name="x-office-document-symbolic",
            title="Drop PDFs Here",
            description="Merge, compress, split, and rearrange PDF files.\n"
                        "Drag files anywhere in this window or open them with the "
                        "button below.",
            child=Gtk.Button(label="Open Files…", halign=Gtk.Align.CENTER,
                             action_name="win.open",
                             css_classes=["suggested-action", "pill"]))

        self.main_stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
        self.main_stack.add_named(placeholder, "placeholder")
        self.main_stack.add_named(content_box, "content")

        self.toast_overlay = Adw.ToastOverlay(child=self.main_stack)
        toolbar_view = Adw.ToolbarView(content=self.toast_overlay)
        toolbar_view.add_top_bar(header_bar)
        self.set_content(toolbar_view)

    # --- File handling ---

    def _on_window_drop(self, drop_target, value, x, y):
        if self.is_processing:
            self._toast("A task is already in progress.")
            return False

        pdf_paths = [f.get_path() for f in value.get_files()
                     if is_pdf_path(f.get_path())]
        if not pdf_paths:
            self._toast("Only PDF files can be opened.")
            return False

        self._handle_files(pdf_paths)
        return True

    def _handle_files(self, pdf_paths):
        existing = {row.file_path for row in iter_children(self.merge_list_box)}
        new_paths = [path for path in pdf_paths if path not in existing]
        if len(new_paths) < len(pdf_paths):
            self._toast("Skipped files that are already in the list.")

        for path in new_paths:
            self.merge_list_box.append(PdfFileRow(path, self))

        if self.merge_list_box.get_first_child() is not None:
            self.main_stack.set_visible_child_name("content")
            if self.merge_list_box.get_selected_row() is None:
                self.merge_list_box.select_row(self.merge_list_box.get_row_at_index(0))

        self.update_ui_state()

    def _on_file_selected(self, listbox, row):
        if self.suspend_selection:
            return
        if row is None:
            self.selected_pdf = None
            self._clear_reorder_view()
        else:
            self.selected_pdf = row.file_path
            if row.file_path != self.reorder_source_path:
                self._load_pdf_for_reordering(row.file_path)
        self.update_ui_state()

    def _load_pdf_for_reordering(self, file_path):
        self._clear_reorder_view()
        try:
            reader = PdfReader(file_path)
            if reader.is_encrypted:
                self._toast("Cannot load encrypted PDFs for page editing.")
                return
            page_count = len(reader.pages)
        except Exception as e:
            self._toast(f"Error reading PDF: {e}")
            return

        self.reorder_source_path = file_path
        self.pages_group.set_description(REORDER_HINT)
        for i in range(page_count):
            page_widget = PdfPageWidget(i)
            self.reorder_flow_box.append(page_widget)
            self._preview_futures.append(
                PREVIEW_POOL.submit(page_widget.preview.render, file_path, i + 1))

    def _clear_reorder_view(self):
        for future in self._preview_futures:
            future.cancel()
        self._preview_futures = []

        while (child := self.reorder_flow_box.get_first_child()) is not None:
            self.reorder_flow_box.remove(child)

        self.reorder_source_path = None
        self.pages_group.set_description(SELECT_FILE_HINT)

    def _clear_file_list(self):
        self.suspend_selection = True
        while (row := self.merge_list_box.get_first_child()) is not None:
            self.merge_list_box.remove(row)
        self.suspend_selection = False

        self.selected_pdf = None
        self._clear_reorder_view()
        self.update_ui_state()

    def update_ui_state(self):
        file_count = sum(1 for _row in iter_children(self.merge_list_box))
        if file_count == 0:
            self.selected_pdf = None
            self._clear_reorder_view()
            self.main_stack.set_visible_child_name("placeholder")

        busy = self.is_processing
        self.merge_button.set_sensitive(file_count >= 2 and not busy)
        self.compress_button.set_sensitive(self.selected_pdf is not None and not busy)
        self.split_button.set_sensitive(self.selected_pdf is not None and not busy)
        self.save_pages_button.set_sensitive(self.reorder_source_path is not None
                                             and not busy)
        self.clear_action.set_enabled(file_count > 0 and not busy)
        self.progress_spinner.set_visible(busy)

    # --- Action and button handlers ---

    def _on_open_action(self, action, param):
        dialog = Gtk.FileDialog(title="Open PDF Files")
        self._add_pdf_filter(dialog)
        dialog.open_multiple(self, None, self._on_open_finished)

    def _on_open_finished(self, dialog, result):
        try:
            files = dialog.open_multiple_finish(result)
        except GLib.Error:
            return
        paths = [files.get_item(i).get_path() for i in range(files.get_n_items())]
        paths = [path for path in paths if path]
        if paths:
            self._handle_files(paths)

    def _on_clear_action(self, action, param):
        self._clear_file_list()

    def _on_about_action(self, action, param):
        about = Adw.AboutDialog(application_name="I ate PDFs",
                                application_icon="iatepdfs",
                                version=VERSION,
                                developer_name="Julien Grondin",
                                license_type=Gtk.License.MIT_X11,
                                comments="A simple utility for PDF manipulation.",
                                website="https://github.com/juliengrdn/iatepdfs",
                                copyright="© 2025 Julien Grondin")
        about.present(self)

    def _on_merge_clicked(self, button):
        pdf_paths = [row.file_path for row in iter_children(self.merge_list_box)]
        if len(pdf_paths) < 2:
            return
        self._show_save_dialog("merged.pdf",
                               lambda path: self._run_merge_task(path, pdf_paths),
                               Path(pdf_paths[0]).parent)

    def _on_compress_clicked(self, button):
        if not self.selected_pdf:
            return
        source_path = Path(self.selected_pdf)
        self._show_save_dialog(f"{source_path.stem}_compressed.pdf",
                               self._run_compress_task, source_path.parent)

    def _on_split_clicked(self, button):
        if not self.selected_pdf:
            return
        self._show_folder_dialog(self._run_split_task, Path(self.selected_pdf).parent)

    def _on_save_pages_clicked(self, button):
        if not self.reorder_source_path:
            return
        page_indices = [child.original_page_index
                        for child in iter_children(self.reorder_flow_box)
                        if not child.is_deleted]
        if not page_indices:
            self._toast("Every page is marked for deletion — nothing to save.")
            return
        source_path = Path(self.reorder_source_path)
        self._show_save_dialog(f"{source_path.stem}_reordered.pdf",
                               lambda path: self._run_reorder_task(path, page_indices),
                               source_path.parent)

    # --- File dialogs ---

    def _add_pdf_filter(self, dialog):
        pdf_filter = Gtk.FileFilter()
        pdf_filter.set_name("PDF files")
        pdf_filter.add_mime_type("application/pdf")
        pdf_filter.add_suffix("pdf")
        filters = Gio.ListStore.new(Gtk.FileFilter)
        filters.append(pdf_filter)
        dialog.set_filters(filters)
        dialog.set_default_filter(pdf_filter)

    def _show_save_dialog(self, default_name, callback_on_accept, initial_dir=None):
        dialog = Gtk.FileDialog(title="Save As…", initial_name=default_name)
        self._add_pdf_filter(dialog)
        if initial_dir is not None:
            dialog.set_initial_folder(Gio.File.new_for_path(str(initial_dir)))

        def on_finished(dlg, result):
            try:
                gfile = dlg.save_finish(result)
            except GLib.Error:
                return
            path = gfile.get_path() if gfile is not None else None
            if not path:
                return
            if not path.lower().endswith(".pdf"):
                path += ".pdf"
            callback_on_accept(path)

        dialog.save(self, None, on_finished)

    def _show_folder_dialog(self, callback_on_accept, initial_dir=None):
        dialog = Gtk.FileDialog(title="Select Output Folder")
        if initial_dir is not None:
            dialog.set_initial_folder(Gio.File.new_for_path(str(initial_dir)))

        def on_finished(dlg, result):
            try:
                gfile = dlg.select_folder_finish(result)
            except GLib.Error:
                return
            if gfile is not None and gfile.get_path():
                callback_on_accept(gfile.get_path())

        dialog.select_folder(self, None, on_finished)

    # --- Background task execution ---

    def _output_path_is_safe(self, output_path, input_paths):
        """Ghostscript and pypdf read their inputs lazily, so writing over an
        input file would destroy it mid-read."""
        output = os.path.realpath(output_path)
        if any(os.path.realpath(path) == output for path in input_paths):
            self._toast("Choose an output file different from the input file.")
            return False
        return True

    def _start_task(self, message, work):
        """Run `work` (a no-argument callable returning (success, message))
        on a worker thread and report the result back on the main loop."""
        self._set_processing_state(True, message)

        def task():
            success, result_message = work()
            GLib.idle_add(self._on_task_finished, success, result_message)

        threading.Thread(target=task, daemon=True).start()

    def _run_compress_task(self, output_path):
        if not self.selected_pdf:
            return
        input_path = self.selected_pdf
        if not self._output_path_is_safe(output_path, [input_path]):
            return
        quality = COMPRESSION_PRESETS[self.quality_dropdown.get_selected()][0]
        self._start_task("Compressing PDF…",
                         lambda: self._compress_pdf(input_path, output_path, quality))

    def _run_split_task(self, output_dir):
        if not self.selected_pdf:
            return
        input_path = self.selected_pdf
        self._start_task("Splitting PDF…",
                         lambda: self._split_pdf(input_path, output_dir))

    def _run_merge_task(self, output_path, pdf_paths):
        if not self._output_path_is_safe(output_path, pdf_paths):
            return
        self._set_processing_state(True, "Merging PDFs…")

        def task():
            success, message = self._merge_pdfs(pdf_paths, output_path)
            GLib.idle_add(self._on_merge_finished, success, message)

        threading.Thread(target=task, daemon=True).start()

    def _run_reorder_task(self, output_path, page_indices):
        if not self.reorder_source_path:
            return
        input_path = self.reorder_source_path
        if not self._output_path_is_safe(output_path, [input_path]):
            return
        self._start_task("Saving reordered PDF…",
                         lambda: self._reorder_pdf_pages(input_path, output_path,
                                                         page_indices))

    def _set_processing_state(self, is_processing, message=None):
        self.is_processing = is_processing
        self.update_ui_state()
        if message:
            self._toast(message)

    def _on_task_finished(self, success, message):
        self._set_processing_state(False)
        self._toast(message)
        return GLib.SOURCE_REMOVE

    def _on_merge_finished(self, success, message):
        self._on_task_finished(success, message)
        if success:
            self._clear_file_list()
        return GLib.SOURCE_REMOVE

    def _toast(self, message):
        self.toast_overlay.add_toast(Adw.Toast(title=message, use_markup=False))

    # --- PDF operations (run on worker threads) ---

    def _compress_pdf(self, input_path, output_path, quality):
        try:
            command = ["gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
                       f"-dPDFSETTINGS=/{quality}", "-dNOPAUSE", "-dQUIET",
                       "-dBATCH", f"-sOutputFile={output_path}", input_path]
            subprocess.run(command, check=True, capture_output=True, text=True)
            return True, "Compression successful."
        except FileNotFoundError:
            return False, "Ghostscript (gs) is not installed or not in your PATH."
        except subprocess.CalledProcessError as e:
            return False, f"Ghostscript failed: {e.stderr}"

    def _split_pdf(self, input_path, output_dir):
        try:
            reader = PdfReader(input_path)
            base_name = Path(input_path).stem
            os.makedirs(output_dir, exist_ok=True)

            for i, page in enumerate(reader.pages):
                writer = PdfWriter()
                writer.add_page(page)
                output_filename = os.path.join(output_dir,
                                               f"{base_name}_page_{i + 1}.pdf")
                with open(output_filename, "wb") as f:
                    writer.write(f)

            return True, f"Successfully split into {len(reader.pages)} pages."
        except Exception as e:
            return False, f"Failed to split PDF: {e}"

    def _merge_pdfs(self, pdf_paths, output_path):
        try:
            merger = PdfWriter()
            for path in pdf_paths:
                merger.append(path)
            merger.write(output_path)
            merger.close()
            return True, f"Successfully merged {len(pdf_paths)} files."
        except Exception as e:
            return False, f"Failed to merge PDFs: {e}"

    def _reorder_pdf_pages(self, input_path, output_path, page_indices):
        try:
            reader = PdfReader(input_path)
            writer = PdfWriter()
            for index in page_indices:
                writer.add_page(reader.pages[index])
            with open(output_path, "wb") as f:
                writer.write(f)
            return True, "Successfully reordered pages."
        except Exception as e:
            return False, f"Failed to reorder PDF: {e}"

    def _on_close_request(self, window):
        if self.is_processing:
            self._toast("Cannot close while a task is in progress.")
            return True
        PREVIEW_POOL.shutdown(wait=False, cancel_futures=True)
        return False


class PdfToolApp(Adw.Application):
    """Main application class."""

    def __init__(self, **kwargs):
        super().__init__(application_id=APP_ID, **kwargs)

    def do_startup(self):
        Adw.Application.do_startup(self)

        quit_action = Gio.SimpleAction.new("quit", None)
        quit_action.connect("activate", self._on_quit_action)
        self.add_action(quit_action)

        self.set_accels_for_action("app.quit", ["<Primary>q"])
        self.set_accels_for_action("win.open", ["<Primary>o"])
        self.set_accels_for_action("window.close", ["<Primary>w"])

    def _on_quit_action(self, action, param):
        # Close the window instead of quitting outright so the
        # "task in progress" close guard still applies.
        win = self.props.active_window
        if win is not None:
            win.close()
        else:
            self.quit()

    def do_activate(self):
        win = self.props.active_window or PdfToolWindow(application=self)
        win.present()

        if not shutil.which("gs"):
            dialog = Adw.AlertDialog(
                heading="Ghostscript Not Found",
                body="PDF compression requires Ghostscript (“gs”). Install it to "
                     "enable the Compress action — everything else works without it.")
            dialog.add_response("ok", "_OK")
            dialog.present(win)


if __name__ == "__main__":
    sys.exit(PdfToolApp().run(sys.argv))
