#!/usr/bin/env python3
"""
PDF-Decrypt - branded Tkinter user interface (Windows).

Removes AES-256 (and RC4) password protection from PDF files.
Drop one or more encrypted PDFs onto PDF-Decrypt.exe, enter the password,
and a decrypted copy is saved next to each original.

The interface is byte-for-byte identical in look and feel to PDF-Encrypt:
same teal #008080 header, same gold #f2b705 rule, same Montserrat, same
centered-window layout that reads as one surface transforming in place.

Started by the C# launcher (PDF-Decrypt.exe) as:
    runtime\\pythonw.exe app\\ui.py FILE [FILE ...]

Design notes:
  * Canvas-drawn buttons (BrandButton) and a hand-drawn progress bar give
    consistent branding on Windows without depending on system colors.
  * Every window (dialogs, progress, summary) is centered on the screen so
    the flow reads as a single surface that transforms in place.
  * The palette is fixed (teal/gold/white); we don't follow dark mode.
  * Nothing is written to disk other than the decrypted PDFs themselves.
"""

import math
import os
import queue
import sys
import threading


def _point_windows_runtime_at_bundled_tcl():
    """Point TCL_LIBRARY/TK_LIBRARY at the bundled tree before tkinter
    is imported, so the app works regardless of the user's environment."""
    if os.name != "nt":
        return
    runtime = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    runtime = os.path.join(runtime, "runtime")
    tcl_root = os.path.join(runtime, "tcl")
    if not os.path.isdir(tcl_root):
        return
    for name, prefix in (("TCL_LIBRARY", "tcl8."), ("TK_LIBRARY", "tk8.")):
        try:
            for entry in sorted(os.listdir(tcl_root)):
                if entry.startswith(prefix) and os.path.isdir(
                        os.path.join(tcl_root, entry)):
                    os.environ[name] = os.path.join(tcl_root, entry)
                    break
        except OSError:
            pass


_point_windows_runtime_at_bundled_tcl()

import tkinter as tk            # noqa: E402
import tkinter.font as tkfont   # noqa: E402
from tkinter import ttk          # noqa: F401,E402

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

import decrypt   # noqa: E402

# ---------------------------------------------------------------------------
# Brand
# ---------------------------------------------------------------------------
TEAL      = "#008080"
TEAL_DARK = "#006666"
TEAL_DEEP = "#004d4d"
TEAL_HOVER= "#0a8f8f"
TEAL_LIGHT= "#e6f2f2"
GOLD      = "#f2b705"
GOLD_DARK = "#d9a304"
GOLD_HOVER= "#ffab40"
GOLD_PRESS= "#d9a404"
WHITE     = "#ffffff"
BG        = "#f7f7f5"
INK       = "#1f2937"
MUTED     = "#6b7280"
LINE      = "#dfe4e6"
FIELD_BORDER = "#cfd4d3"
DANGER    = "#b3261e"

FONT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts")

FONT_STACK      = ["Montserrat", "Helvetica Neue", "Helvetica", "Segoe UI", "Arial"]
FONT_STACK_MED  = ["Montserrat Medium",   "Montserrat", "Helvetica Neue",
                   "Helvetica", "Segoe UI", "Arial"]
FONT_STACK_SEMI = ["Montserrat SemiBold", "Montserrat Medium", "Montserrat",
                   "Helvetica Neue", "Helvetica", "Segoe UI", "Arial"]

DIALOG_W  = 480
PROGRESS_W= 480
PAD       = 24
BTN_GAP   = 10
BTN_PAD   = 34
BTN_MIN   = 96
BTN_H     = 36
BTN_R     = 9
HEADER_H  = 56
RULE_H    = 3
BAR_H     = 14

_fonts = {}


def register_bundled_fonts():
    """Make Montserrat available to Tk without installing it system-wide."""
    if not os.path.isdir(FONT_DIR):
        return
    faces = [os.path.join(FONT_DIR, f) for f in sorted(os.listdir(FONT_DIR))
             if f.lower().endswith(".ttf")]
    if not faces:
        return
    if os.name == "nt":
        try:
            import ctypes
            FR_PRIVATE = 0x10
            for path in faces:
                ctypes.windll.gdi32.AddFontResourceExW(path, FR_PRIVATE, 0)
        except Exception:
            pass
    elif sys.platform == "darwin":
        try:
            import ctypes, ctypes.util
            ct = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreText"))
            cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
            cf.CFURLCreateFromFileSystemRepresentation.restype  = ctypes.c_void_p
            cf.CFURLCreateFromFileSystemRepresentation.argtypes = [
                ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_bool]
            ct.CTFontManagerRegisterFontsForURL.restype  = ctypes.c_bool
            ct.CTFontManagerRegisterFontsForURL.argtypes = [
                ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
            for path in faces:
                raw = path.encode("utf-8")
                url = cf.CFURLCreateFromFileSystemRepresentation(
                    None, raw, len(raw), False)
                if url:
                    ct.CTFontManagerRegisterFontsForURL(url, 1, None)
        except Exception:
            pass
    else:
        try:
            import shutil, subprocess
            dest = os.path.expanduser("~/.fonts")
            os.makedirs(dest, exist_ok=True)
            for path in faces:
                target = os.path.join(dest, os.path.basename(path))
                if not os.path.exists(target):
                    shutil.copy2(path, target)
            subprocess.run(["fc-cache", "-f"], capture_output=True, timeout=30)
        except Exception:
            pass


def _pick(root, stack):
    available = set(tkfont.families(root))
    for name in stack:
        if name in available:
            return name
    return "TkDefaultFont"


def fonts(root):
    if not _fonts:
        body = _pick(root, FONT_STACK)
        med  = _pick(root, FONT_STACK_MED)
        semi = _pick(root, FONT_STACK_SEMI)
        _fonts["title"]    = (semi, 15)
        _fonts["body"]     = (body, 13)
        _fonts["bodybold"] = (med,  13)
        _fonts["small"]    = (body, 11)
        _fonts["button"]   = (med,  13)
    return _fonts


def button_width(root, label):
    f = tkfont.Font(root=root, font=fonts(root)["button"])
    return max(BTN_MIN, f.measure(label) + BTN_PAD)


# ---------------------------------------------------------------------------
# Language
# ---------------------------------------------------------------------------
def system_is_german():
    if os.name == "nt":
        try:
            import ctypes
            lang_id = ctypes.windll.kernel32.GetUserDefaultUILanguage()
            return (lang_id & 0x3FF) == 0x07
        except Exception:
            pass
    if sys.platform == "darwin":
        try:
            import subprocess
            out = subprocess.run(["defaults", "read", "-g", "AppleLocale"],
                                 capture_output=True, timeout=5)
            return out.stdout.decode("utf-8", "replace").strip().startswith("de")
        except Exception:
            pass
    for var in ("LANG", "LC_ALL", "LANGUAGE"):
        if os.environ.get(var, "").lower().startswith("de"):
            return True
    return False


DE = system_is_german()


def t(en, de):
    return de if DE else en


# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------
def _screen(win):
    return win.winfo_screenwidth(), win.winfo_screenheight()


def center_window(win, w=None, h=None):
    win.update_idletasks()
    sw, sh = _screen(win)
    if w is None:
        w = win.winfo_reqwidth()
    if h is None:
        h = win.winfo_reqheight()
    x = max(int((sw - w) / 2), 0)
    y = max(int((sh - h) / 2), 0)
    win.geometry("{0}x{1}+{2}+{3}".format(w, h, x, y))


def apply_window_icon(root):
    if os.name != "nt":
        return
    ico = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                       "icon.ico")
    if not os.path.isfile(ico):
        return
    try:
        root.iconbitmap(default=ico)
    except Exception:
        pass


def round_rect(canvas, x1, y1, x2, y2, r, **kw):
    r = max(0.0, min(float(r), (x2 - x1) / 2.0, (y2 - y1) / 2.0))
    if r <= 0:
        return canvas.create_polygon([x1, y1, x2, y1, x2, y2, x1, y2], **kw)
    steps = 8
    pts = []
    for cx, cy, start in ((x2-r, y1+r, -90.0),
                          (x2-r, y2-r,   0.0),
                          (x1+r, y2-r,  90.0),
                          (x1+r, y1+r, 180.0)):
        for i in range(steps + 1):
            a = math.radians(start + 90.0 * i / steps)
            pts.extend((cx + r * math.cos(a), cy + r * math.sin(a)))
    return canvas.create_polygon(pts, **kw)


def raise_window(win):
    win.lift()
    win.attributes("-topmost", True)
    win.after(200, lambda: win.attributes("-topmost", False))
    try:
        win.focus_force()
    except Exception:
        pass


# ---------------------------------------------------------------------------
# Branded widgets
# ---------------------------------------------------------------------------
BUTTON_KINDS = {
    "primary": dict(fill=TEAL, fill_hover=TEAL_DARK, fill_press=TEAL_DEEP,
                    fg=WHITE, fg_hover=GOLD,
                    edge=TEAL, edge_hover=TEAL_DARK),
    "accent":  dict(fill=GOLD, fill_hover=GOLD_HOVER, fill_press=GOLD_PRESS,
                    fg=TEAL_DEEP, fg_hover=TEAL_DEEP,
                    edge=GOLD, edge_hover=TEAL),
    "quiet":   dict(fill=WHITE, fill_hover="#f1f7f7", fill_press=TEAL_LIGHT,
                    fg=INK, fg_hover=TEAL_DARK,
                    edge=FIELD_BORDER, edge_hover=TEAL),
}


class BrandButton(tk.Canvas):
    def __init__(self, master, text, command, kind="primary", width=None):
        self.spec = BUTTON_KINDS.get(kind, BUTTON_KINDS["quiet"])
        if width is None:
            width = button_width(master, text)
        try:
            ground = master.cget("bg")
        except Exception:
            ground = WHITE
        super().__init__(master, width=width+1, height=BTN_H+1, bg=ground,
                         highlightthickness=0, bd=0, takefocus=0, cursor="hand2")
        self.command  = command
        self._btn_w   = width
        self._pressed = False
        f = fonts(master)
        self._shape = round_rect(self, 0.5, 0.5, width-0.5, BTN_H-0.5, BTN_R,
                                 fill=self.spec["fill"], outline=self.spec["edge"], width=1)
        self._label = self.create_text(width/2.0, BTN_H/2.0, text=text,
                                       font=f["button"], fill=self.spec["fg"])
        self.bind("<Enter>",          self._enter)
        self.bind("<Leave>",          self._leave)
        self.bind("<ButtonPress-1>",  self._press)
        self.bind("<ButtonRelease-1>",self._click)

    def _paint(self, fill, edge, fg):
        self.itemconfigure(self._shape, fill=fill, outline=edge)
        self.itemconfigure(self._label, fill=fg)

    def _enter(self, _e=None):
        self._paint(self.spec["fill_hover"], self.spec["edge_hover"], self.spec["fg_hover"])

    def _leave(self, _e=None):
        self._pressed = False
        self._paint(self.spec["fill"], self.spec["edge"], self.spec["fg"])

    def _press(self, _e=None):
        self._pressed = True
        self._paint(self.spec["fill_press"], self.spec["edge_hover"], self.spec["fg_hover"])

    def _inside(self, e):
        return 0 <= e.x <= self._btn_w and 0 <= e.y <= BTN_H

    def _click(self, e=None):
        was = self._pressed
        self._pressed = False
        if e is not None and not self._inside(e):
            self._leave()
            return
        self._enter()
        if was or e is None:
            self.command()


class BrandDialog(tk.Toplevel):
    """Modal, centered, branded dialog.

    Three-band layout: teal header with gold rule, white body, footer strip
    with buttons. buttons = list of (label, value, kind).
    """

    def __init__(self, master, title, message, buttons,
                 secure=False, tone="normal", scroll=False, error="",
                 default=None):
        super().__init__(master)
        self.result = None
        self.entry  = None
        self.withdraw()
        self.title("PDF-Decrypt")
        self.configure(bg=BG)
        self.resizable(False, False)
        try:
            if master is not None and master.winfo_viewable():
                self.transient(master)
        except Exception:
            pass
        f = fonts(self)

        btn_widths = [button_width(self, label) for label, _v, _k in buttons]
        row_w = sum(btn_widths) + BTN_GAP * (len(buttons) - 1) + 2 * PAD + 24
        self.dlg_w = max(DIALOG_W, row_w)
        wrap_w = self.dlg_w - 2 * PAD - 6

        # Header
        header = tk.Frame(self, bg=TEAL, height=HEADER_H)
        header.pack(fill="x")
        header.pack_propagate(False)
        tk.Label(header, text=title, bg=TEAL, fg=WHITE, font=f["title"],
                 anchor="w").pack(side="left", padx=PAD)
        tk.Frame(self, bg=DANGER if tone == "error" else GOLD,
                 height=RULE_H).pack(fill="x")

        # Footer (packed first so a tall body cannot push it off-screen)
        footer = tk.Frame(self, bg=BG)
        footer.pack(side="bottom", fill="x")
        tk.Frame(footer, bg=LINE, height=1).pack(fill="x")
        row = tk.Frame(footer, bg=BG)
        row.pack(fill="x", padx=PAD, pady=16)

        body = tk.Frame(self, bg=WHITE, padx=PAD, pady=20)
        body.pack(side="top", fill="both", expand=True)

        msg_fg = INK
        if scroll:
            box = tk.Frame(body, bg=WHITE)
            box.pack(fill="both", expand=True)
            txt = tk.Text(box, wrap="word", height=4, width=44, bd=0,
                          bg=WHITE, fg=msg_fg, font=f["body"],
                          highlightthickness=0, padx=0, pady=0,
                          spacing1=2, spacing3=4)
            txt.insert("1.0", message)
            txt.configure(state="disabled")
            txt.pack(side="left", fill="both", expand=True)
            sb = tk.Scrollbar(box, command=txt.yview)
            txt.configure(yscrollcommand=sb.set)
            metric = tkfont.Font(root=self, font=f["body"])
            shown = 0
            for para in message.split("\n"):
                width = metric.measure(para)
                shown += max(1, int(math.ceil(float(width) / max(wrap_w, 1))))
            txt.configure(height=max(3, min(shown, 12)))
            if shown > 12:
                sb.pack(side="right", fill="y")
        else:
            tk.Label(body, text=message, bg=WHITE, fg=msg_fg, font=f["body"],
                     justify="left", anchor="w", wraplength=wrap_w
                     ).pack(fill="x")

        if error:
            tk.Label(body, text=error, bg=WHITE, fg=DANGER, font=f["small"],
                     justify="left", anchor="w", wraplength=wrap_w
                     ).pack(fill="x", pady=(10, 0))

        if secure:
            self._field = tk.Frame(body, bg=FIELD_BORDER, padx=1, pady=1)
            self._field.pack(fill="x", pady=(18, 2))
            inner = tk.Frame(self._field, bg=WHITE)
            inner.pack(fill="x")
            self.entry = tk.Entry(inner, show="\u2022", font=f["body"], bd=0,
                                  bg=WHITE, fg=INK, relief="flat",
                                  insertbackground=TEAL, highlightthickness=0)
            self.entry.pack(side="left", fill="x", expand=True, padx=(12, 0), ipady=8)
            self.entry.bind("<FocusIn>",
                            lambda _e: self._field.configure(bg=TEAL))
            self.entry.bind("<FocusOut>",
                            lambda _e: self._field.configure(bg=FIELD_BORDER))
            self._pw_visible = False
            self._toggle = tk.Label(inner, text=t("Show", "Anzeigen"),
                                    bg=WHITE, fg=TEAL, font=f["small"],
                                    cursor="hand2", padx=12)
            self._toggle.pack(side="right")
            self._toggle.bind("<ButtonRelease-1>", self._toggle_pw)
            self._toggle.bind("<Enter>",
                              lambda _e: self._toggle.configure(fg=GOLD_DARK))
            self._toggle.bind("<Leave>",
                              lambda _e: self._toggle.configure(fg=TEAL))

        self._default = None
        for i, (label, value, kind) in enumerate(buttons):
            b = BrandButton(row, label, lambda v=value: self._done(v),
                            kind=kind, width=btn_widths[i])
            b.pack(side="right", padx=(BTN_GAP, 0))
            if i == len(buttons) - 1:
                self._default = value
        if default is not None:
            self._default = default   # Enter/Return triggers this button

        self.bind("<Return>",    lambda _e: self._done(self._default))
        self.bind("<KP_Enter>",  lambda _e: self._done(self._default))
        self.bind("<Escape>",    lambda _e: self._done(None))
        self.protocol("WM_DELETE_WINDOW", lambda: self._done(None))

        self.update_idletasks()
        center_window(self, self.dlg_w, self.winfo_reqheight())
        self.deiconify()
        raise_window(self)
        if self.entry is not None:
            self.entry.focus_set()
        self.grab_set()

    def _toggle_pw(self, _e=None):
        self._pw_visible = not self._pw_visible
        self.entry.configure(show="" if self._pw_visible else "\u2022")
        self._toggle.configure(
            text=t("Hide", "Verbergen") if self._pw_visible
            else t("Show", "Anzeigen"))

    def _done(self, value):
        if self.entry is not None and value is not None:
            self.result = (value, self.entry.get())
        else:
            self.result = (value, "")
        self.grab_release()
        self.destroy()

    def show(self):
        self.wait_window()
        return self.result if self.result else (None, "")


# ---------------------------------------------------------------------------
# Dialog helpers
# ---------------------------------------------------------------------------
def message(root, title, text, tone="normal", scroll=False):
    BrandDialog(root, title, text, [("OK", "ok", "primary")],
                tone=tone, scroll=scroll).show()


def ask_decrypt_password(root, prompt, error=""):
    """Single-entry password dialog for decryption (no confirmation needed).

    Returns the password string (possibly empty), or None if cancelled.
    An empty string is a valid password — some PDFs use one. When *error* is
    given, it is shown in red beneath the prompt (used by the retry loop).
    """
    val, pw = BrandDialog(
        root, "PDF-Decrypt", prompt,
        [(t("Cancel", "Abbrechen"), None, "quiet"),
         (t("Open", "Öffnen"), "ok", "primary")],
        secure=True, tone="error" if error else "normal", error=error).show()
    if val is None:
        return None
    return pw


WRONG_PW_NOTE = t("That password didn't work. Please try again.",
                  "Das Passwort war nicht korrekt. Bitte erneut versuchen.")


def ask_password_with_retry(root, filepath, prompt):
    """Prompt for *filepath*'s password, re-prompting on a wrong password.

    Returns the accepted password, or None if the user cancelled. A wrong
    password keeps the dialog open with a red note; anything a retry cannot
    fix (not encrypted, not a PDF, unreadable) is accepted and left to the
    run summary to report.
    """
    error = ""
    while True:
        pw = ask_decrypt_password(root, prompt, error)
        if pw is None:
            return None
        if decrypt.check_password(filepath, pw) == "wrong_password":
            error = WRONG_PW_NOTE
            continue
        return pw


def ask_same_password_with_retry(root, files, prompt):
    """Prompt once for the whole batch, validating the password against the
    first encrypted file and re-prompting on a wrong password.

    Returns the accepted password, or None if the user cancelled.
    """
    probe = next((f for f in files if decrypt.needs_password(f)), None)
    error = ""
    while True:
        pw = ask_decrypt_password(root, prompt, error)
        if pw is None:
            return None
        if probe is None:
            return pw  # nothing in the batch is encrypted
        if decrypt.check_password(probe, pw) == "wrong_password":
            error = WRONG_PW_NOTE
            continue
        return pw


def ask_open_pdfs(root):
    """Native 'open files' dialog for when the app is launched without any
    dropped files. Returns a (possibly empty) tuple of selected paths."""
    try:
        from tkinter import filedialog
    except Exception:
        return ()
    try:
        return filedialog.askopenfilenames(
            parent=root,
            title=t("Select encrypted PDF files",
                    "Verschlüsselte PDF-Dateien auswählen"),
            filetypes=[(t("PDF files", "PDF-Dateien"), "*.pdf"),
                       (t("All files", "Alle Dateien"), "*.*")])
    except Exception:
        return ()


def prompt_no_files(root):
    """Launched with nothing to process (e.g. the icon was double-clicked):
    show the help dialog with a 'Select PDF file' button beside OK.

    Returns a list of chosen PDF paths to process, or None if the user chose
    OK, pressed Escape, or closed the window. If the file picker is opened but
    cancelled, the help dialog is shown again.
    """
    while True:
        val, _ = BrandDialog(
            root, "PDF-Decrypt", HELP_TEXT,
            [("OK", "ok", "primary"),
             (t("Select PDF file", "PDF-Datei auswählen"), "select", "quiet")],
            default="ok").show()
        if val != "select":
            return None
        picked = ask_open_pdfs(root)
        chosen = [p for p in picked
                  if os.path.isfile(p)
                  and os.path.splitext(p)[1].lower() in SUPPORTED]
        if chosen:
            return chosen
        # Picker cancelled or nothing usable: show the help dialog again.


def ask_choice(root, count):
    """'same' | 'different' | None (for multiple-file drops)."""
    val, _ = BrandDialog(
        root, "PDF-Decrypt",
        t("You dropped {0} files.\nUse the same password for all, or a "
          "different password for each?",
          "Sie haben {0} Dateien abgelegt.\nFür alle dasselbe Passwort "
          "verwenden oder für jede ein eigenes?").format(count),
        [(t("Cancel", "Abbrechen"), None, "quiet"),
         (t("Different passwords", "Einzelne Passwörter"), "different", "quiet"),
         (t("Same password", "Gleiches Passwort"), "same", "primary")]).show()
    return val


# ---------------------------------------------------------------------------
# Progress window
# ---------------------------------------------------------------------------
class ProgressWindow(tk.Toplevel):
    """Progress window: caption, pill-shaped bar, counter, Stop."""

    def __init__(self, master, total, on_stop):
        super().__init__(master)
        self.withdraw()
        self.total   = total
        self.on_stop = on_stop
        self.title("PDF-Decrypt")
        self.configure(bg=BG)
        self.resizable(False, False)
        f = fonts(self)

        header = tk.Frame(self, bg=TEAL, height=48)
        header.pack(fill="x")
        header.pack_propagate(False)
        tk.Label(header,
                 text=t("Decrypting files…", "Dateien werden entschlüsselt…"),
                 bg=TEAL, fg=WHITE, font=f["bodybold"], anchor="w"
                 ).pack(side="left", padx=PAD)
        tk.Frame(self, bg=GOLD, height=RULE_H).pack(fill="x")

        footer = tk.Frame(self, bg=BG)
        footer.pack(side="bottom", fill="x")
        tk.Frame(footer, bg=LINE, height=1).pack(fill="x")
        row = tk.Frame(footer, bg=BG)
        row.pack(fill="x", padx=PAD, pady=14)

        body = tk.Frame(self, bg=WHITE, padx=PAD, pady=18)
        body.pack(side="top", fill="both", expand=True)

        self.bar_w   = PROGRESS_W - 2 * PAD
        self.caption = tk.Label(body, text="", bg=WHITE, fg=MUTED,
                                font=f["small"], anchor="nw", justify="left",
                                height=2, wraplength=self.bar_w)
        self.caption.pack(fill="x")

        self.canvas = tk.Canvas(body, height=BAR_H, width=self.bar_w, bg=WHITE,
                                highlightthickness=0, bd=0)
        self.canvas.pack(fill="x", pady=(12, 2))
        self.track = round_rect(self.canvas, 0, 0, self.bar_w, BAR_H,
                                BAR_H/2.0, fill=TEAL_LIGHT, outline="")
        self.bar = None
        self.cap = None

        self.count = tk.Label(row, text="", bg=BG, fg=MUTED, font=f["small"])
        self.count.pack(side="left")
        BrandButton(row, t("Stop", "Stopp"), self._stop,
                    kind="quiet").pack(side="right")

        self.protocol("WM_DELETE_WINDOW", self._stop)
        self.update_idletasks()
        center_window(self, PROGRESS_W, self.winfo_reqheight())
        self.deiconify()
        raise_window(self)

    def _stop(self):
        self.caption.configure(
            text=t("Stopping after the current file…",
                   "Wird nach der aktuellen Datei beendet…"))
        self.on_stop()

    @staticmethod
    def _elide(name, limit=88):
        if len(name) <= limit:
            return name
        head = (limit - 3) * 2 // 3
        return name[:head] + "…" + name[-(limit - 3 - head):]

    def update_progress(self, done, name):
        frac  = 0 if self.total == 0 else float(done) / self.total
        frac  = max(0.0, min(1.0, frac))
        filled = int(self.bar_w * frac)
        for item in (self.bar, self.cap):
            if item is not None:
                self.canvas.delete(item)
        self.bar = self.cap = None
        if filled > 0:
            self.bar = round_rect(self.canvas, 0, 0, filled, BAR_H,
                                  BAR_H/2.0, fill=TEAL, outline="")
            if filled >= BAR_H:
                self.cap = self.canvas.create_oval(
                    filled - BAR_H, 0, filled, BAR_H, fill=GOLD, width=0)
        self.count.configure(text="{0} / {1}   ·   {2} %".format(
            done, self.total, int(round(frac * 100))))
        if name:
            self.caption.configure(
                text=t("Processing: {0}", "In Bearbeitung: {0}").format(
                    self._elide(name)))
        self.update_idletasks()


# ---------------------------------------------------------------------------
# Worker thread
# ---------------------------------------------------------------------------
class Job(threading.Thread):
    def __init__(self, files, passwords, q, stop_event):
        super().__init__(daemon=True)
        self.files      = files
        self.passwords  = passwords
        self.q          = q
        self.stop_event = stop_event

    def run(self):
        used    = set()
        postfix = t("_decrypted", "_entschlüsselt")
        try:
            for i, src in enumerate(self.files):
                if self.stop_event.is_set():
                    self.q.put(("stopped", i))
                    return
                self.q.put(("start", i, os.path.basename(src)))
                dst = decrypt._derive_output(src, postfix, used)
                row = decrypt.process_item(
                    {"input": src, "output": dst,
                     "password": self.passwords[i]})
                self.q.put(("row", i, row))
        except Exception as exc:
            self.q.put(("fatal", str(exc)))
        finally:
            self.q.put(("done", None))


# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
def build_summary(rows, ignored, cancelled):
    ok = not_encrypted = wrong_pw = skipped = 0
    failures = []
    relocated = []

    for row in rows:
        status = row[0]
        name   = os.path.basename(row[1]) if len(row) > 1 else "?"
        extra  = row[2] if len(row) > 2 else ""

        if status == "ok":
            ok += 1
            src_dir = os.path.dirname(os.path.abspath(row[1])) if len(row) > 1 else ""
            out_dir = os.path.dirname(os.path.abspath(extra)) if extra else ""
            if out_dir and src_dir and out_dir != src_dir:
                relocated.append(extra)
        elif status == "not_encrypted":
            not_encrypted += 1
        elif status in ("not_pdf", "not_supported"):
            skipped += 1
        elif status == "wrong_password":
            wrong_pw += 1
            failures.append("{0}: {1}".format(name, t(
                "wrong password — the file could not be opened.",
                "falsches Passwort — die Datei konnte nicht geöffnet werden.")))
        elif status == "error":
            failures.append("{0}: {1}".format(name, extra))

    lines = []
    if ok:
        if relocated:
            lines.append(t("{0} file(s) decrypted.",
                           "{0} Datei(en) entschlüsselt.").format(ok))
        elif ok == 1:
            lines.append(t("{0} file decrypted (saved next to the original).",
                           "{0} Datei entschlüsselt (neben dem Original gespeichert).").format(ok))
        else:
            lines.append(t("{0} files decrypted (saved next to the originals).",
                           "{0} Dateien entschlüsselt (neben den Originalen gespeichert).").format(ok))
    if relocated:
        dirs = sorted({os.path.dirname(p) for p in relocated})
        lines.append(t(
            "Some originals were in a read-only folder, so those copies were saved to:",
            "Einige Originale lagen in einem schreibgeschützten Ordner; diese Kopien wurden gespeichert unter:"))
        for d in dirs:
            lines.append("  • " + d)
    if not_encrypted:
        lines.append(t("{0} not encrypted (skipped).",
                       "{0} nicht verschlüsselt (übersprungen).").format(not_encrypted))
    if skipped:
        lines.append(t("{0} skipped (not a supported file).",
                       "{0} übersprungen (keine unterstützte Datei).").format(skipped))
    if ignored:
        lines.append(t("{0} other file(s) ignored.",
                       "{0} andere Datei(en) ignoriert.").format(ignored))
    if cancelled:
        lines.append(t("{0} not processed (cancelled).",
                       "{0} nicht verarbeitet (abgebrochen).").format(cancelled))
    if failures:
        lines.append("")
        lines.append(t("Could not open:", "Konnte nicht geöffnet werden:"))
        for fl in failures:
            lines.append("  • " + fl)
    if not lines:
        lines = [t("Nothing to do.", "Nichts zu tun.")]

    return "\n".join(lines), bool(failures)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
SUPPORTED = decrypt.PDF_EXT

HELP_TEXT = t(
    "Drag one or more encrypted PDF files onto PDF-Decrypt to remove the "
    "password protection. A decrypted copy is saved next to each original.",
    "Ziehen Sie eine oder mehrere verschlüsselte PDF-Dateien auf PDF-Decrypt, "
    "um den Passwortschutz zu entfernen. Eine entschlüsselte Kopie wird neben "
    "dem Original gespeichert.")


def _enable_windows_dpi_awareness():
    if os.name != "nt":
        return
    try:
        import ctypes
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(1)
        except Exception:
            ctypes.windll.user32.SetProcessDPIAware()
    except Exception:
        pass


def main():
    os.environ.setdefault("PYTHONUTF8", "1")
    os.environ.setdefault("PYTHONIOENCODING", "utf-8")
    _enable_windows_dpi_awareness()
    register_bundled_fonts()
    root = tk.Tk()
    root.withdraw()
    apply_window_icon(root)
    fonts(root)

    args  = [a for a in sys.argv[1:] if a]
    files = [a for a in args
             if os.path.isfile(a) and os.path.splitext(a)[1].lower() in SUPPORTED]
    ignored = len(args) - len(files)

    # Launched with nothing to process (e.g. the icon was double-clicked):
    # show the help dialog, which now carries a 'Select PDF file' button.
    if not files:
        files = prompt_no_files(root)
        if not files:
            return
        ignored = 0

    # Collect passwords up front. Each prompt validates against the file(s)
    # and re-prompts on a wrong password, so the batch that runs is already
    # known-good.
    if len(files) == 1:
        pw = ask_password_with_retry(
            root, files[0],
            t("Enter the password for:\n{0}",
              "Passwort eingeben für:\n{0}").format(os.path.basename(files[0])))
        if pw is None:
            return
        passwords = [pw]
    else:
        choice = ask_choice(root, len(files))
        if choice is None:
            return
        if choice == "same":
            pw = ask_same_password_with_retry(
                root, files,
                t("Enter the password for all {0} files:",
                  "Passwort für alle {0} Dateien eingeben:").format(len(files)))
            if pw is None:
                return
            passwords = [pw] * len(files)
        else:
            passwords = []
            for f in files:
                pw = ask_password_with_retry(
                    root, f,
                    t("Password for:\n{0}",
                      "Passwort für:\n{0}").format(os.path.basename(f)))
                if pw is None:
                    return
                passwords.append(pw)

    # Run decryption.
    q          = queue.Queue()
    stop_event = threading.Event()
    prog       = ProgressWindow(root, len(files), stop_event.set)
    if prog:
        prog.update_progress(0, os.path.basename(files[0]))

    rows  = []
    state = {"cancelled": 0, "fatal": None, "finished": False}
    Job(files, passwords, q, stop_event).start()

    def pump():
        try:
            while True:
                msg  = q.get_nowait()
                kind = msg[0]
                if kind == "start" and prog:
                    prog.update_progress(msg[1], msg[2])
                elif kind == "row":
                    rows.append(msg[2])
                    if prog:
                        prog.update_progress(len(rows), "")
                elif kind == "stopped":
                    state["cancelled"] = len(files) - msg[1]
                elif kind == "fatal":
                    state["fatal"] = msg[1]
                elif kind == "done":
                    state["finished"] = True
        except queue.Empty:
            pass

        if state["finished"]:
            if prog:
                prog.destroy()
            if state["fatal"]:
                fatal_text = t(
                    "Could not decrypt the files.\n\n{0}",
                    "Die Dateien konnten nicht entschlüsselt werden.\n\n{0}"
                ).format(state["fatal"])
                message(root, "PDF-Decrypt", fatal_text, tone="error",
                        scroll=len(fatal_text.splitlines()) > 6
                        or len(fatal_text) > 500)
            else:
                text, had_failures = build_summary(rows, ignored, state["cancelled"])
                message(root, t("Done", "Fertig"), text,
                        tone="error" if had_failures else "normal",
                        scroll=len(text.splitlines()) > 6)
            root.quit()
        else:
            root.after(80, pump)

    root.after(80, pump)
    root.mainloop()


if __name__ == "__main__":
    try:
        main()
    except Exception:
        import traceback
        traceback.print_exc()
        try:
            from tkinter import messagebox
            _r = tk.Tk()
            _r.withdraw()
            apply_window_icon(_r)
            messagebox.showerror(
                "PDF-Decrypt",
                t("PDF-Decrypt ran into an unexpected error.",
                  "PDF-Decrypt ist auf einen unerwarteten Fehler gestoßen."))
        except Exception:
            pass
        sys.exit(1)
