#!/usr/bin/env python3
"""EyePatch — block websites by writing /etc/hosts."""

from __future__ import annotations

import os
import re
import subprocess
import sys
import threading
import tkinter as tk
from pathlib import Path

from hosts import HostsBlocker
from state import AppState, format_block_time, resource_dir
from unlock_api import UnlockAPIError, lookup_key, request_unlock

APP_NAME = "EyePatch"

BG = "#080908"
SURFACE = "#121512"
SURFACE_2 = "#1a1e1a"
TEXT = "#eef0ee"
MUTED = "#8b938b"
ACCENT = "#2f7a3a"
ACCENT_HOVER = "#3a9446"
SUCCESS = "#3a9446"
DANGER = "#d96b6b"
BORDER = "#1f241f"

TITLE_FONT = ("Helvetica Neue", 34, "bold")
SUB_FONT = ("Helvetica Neue", 14)
BODY_FONT = ("Helvetica Neue", 15)
SMALL_FONT = ("Helvetica Neue", 12)
BUTTON_FONT = ("Helvetica Neue", 16, "bold")
LINK_FONT = ("Helvetica Neue", 14, "bold")

EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


class PillButton(tk.Canvas):
    def __init__(
        self,
        master: tk.Misc,
        text: str,
        command,
        width: int = 280,
        height: int = 56,
        **kwargs,
    ) -> None:
        super().__init__(
            master,
            width=width,
            height=height,
            bg=BG,
            highlightthickness=0,
            cursor="hand2",
            **kwargs,
        )
        self._text = text
        self._command = command
        self._width = width
        self._height = height
        self._enabled = True
        self._draw(ACCENT)
        self.bind("<Button-1>", self._on_click)
        self.bind("<Enter>", lambda _e: self._draw(ACCENT_HOVER) if self._enabled else None)
        self.bind("<Leave>", lambda _e: self._draw(ACCENT) if self._enabled else None)

    def _round_rect(self, color: str) -> None:
        r = 4
        w, h = self._width, self._height
        self.create_polygon(
            [
                r, 1, w - r, 1, w - 1, 1, w - 1, r,
                w - 1, h - r, w - 1, h - 1, w - r, h - 1,
                r, h - 1, 1, h - 1, 1, h - r, 1, r, 1, 1,
            ],
            smooth=True,
            fill=color,
            outline="",
        )

    def _draw(self, color: str) -> None:
        self.delete("all")
        if not self._enabled:
            color = "#4a4a52"
        self._round_rect(color)
        fill = TEXT if self._enabled else MUTED
        self.create_text(
            self._width / 2,
            self._height / 2,
            text=self._text,
            fill=fill,
            font=BUTTON_FONT,
        )

    def set_enabled(self, enabled: bool) -> None:
        self._enabled = enabled
        self.configure(cursor="hand2" if enabled else "arrow")
        self._draw(ACCENT if enabled else "#4a4a52")

    def set_text(self, text: str) -> None:
        self._text = text
        self._draw(ACCENT if self._enabled else "#4a4a52")

    def _on_click(self, _event) -> None:
        if self._enabled and self._command:
            self._command()


class LockInApp:
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.state = AppState()
        self._blocking = False
        self._unlock_requesting = False
        self._redeeming = False
        self._unlock_checks: dict[str, tk.BooleanVar] = {}

        root.title(APP_NAME)
        root.configure(bg=BG)
        root.resizable(False, False)
        self._center(420, 640)
        self._bring_to_front()

        self.container = tk.Frame(root, bg=BG)
        self.container.pack(fill="both", expand=True)
        self.container.grid_rowconfigure(0, weight=1)
        self.container.grid_columnconfigure(0, weight=1)

        self.home = tk.Frame(self.container, bg=BG)
        self.list_page = tk.Frame(self.container, bg=BG)
        self.request_page = tk.Frame(self.container, bg=BG)
        self.key_page = tk.Frame(self.container, bg=BG)
        for page in (self.home, self.list_page, self.request_page, self.key_page):
            page.grid(row=0, column=0, sticky="nsew")

        self._build_home()
        self._build_list_page()
        self._build_request_page()
        self._build_key_page()
        self.show_home()
        root.bind("<Escape>", lambda _e: self.show_home())

    def _center(self, width: int, height: int) -> None:
        self.root.update_idletasks()
        x = (self.root.winfo_screenwidth() - width) // 2
        y = (self.root.winfo_screenheight() - height) // 3
        self.root.geometry(f"{width}x{height}+{max(x, 0)}+{max(y, 0)}")

    def _bring_to_front(self) -> None:
        self.root.lift()
        self.root.attributes("-topmost", True)
        self.root.after(300, lambda: self.root.attributes("-topmost", False))
        try:
            subprocess.Popen(
                [
                    "/usr/bin/osascript",
                    "-e",
                    f'tell application "System Events" to set frontmost of every process whose unix id is {os.getpid()} to true',
                ],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except OSError:
            pass

    def _link(self, parent, text: str, command) -> tk.Label:
        link = tk.Label(
            parent,
            text=text,
            fg=ACCENT,
            bg=BG,
            font=LINK_FONT,
            cursor="hand2",
        )
        link.pack(pady=4)
        link.bind("<Button-1>", lambda _e: command())
        return link

    def _back_header(self, parent, title: str, back_to) -> None:
        header = tk.Frame(parent, bg=BG)
        header.pack(fill="x", padx=24, pady=(22, 8))
        back = tk.Label(
            header,
            text="←  Back",
            fg=ACCENT,
            bg=BG,
            font=LINK_FONT,
            cursor="hand2",
        )
        back.pack(side="left")
        back.bind("<Button-1>", lambda _e: back_to())
        tk.Label(header, text=title, fg=TEXT, bg=BG, font=BODY_FONT).pack(side="right")

    def _build_home(self) -> None:
        inner = tk.Frame(self.home, bg=BG)
        inner.place(relx=0.5, rely=0.46, anchor="center")

        icon_path = resource_dir() / "icon.png"
        self._home_icon = None
        if icon_path.exists():
            try:
                raw = tk.PhotoImage(file=str(icon_path))
                # Shrink large PNG for the home lockup.
                factor = max(1, raw.width() // 56)
                self._home_icon = raw.subsample(factor, factor)
                tk.Label(inner, image=self._home_icon, bg=BG).pack(pady=(0, 10))
            except tk.TclError:
                pass

        tk.Label(inner, text="EyePatch", fg=TEXT, bg=BG, font=TITLE_FONT).pack(pady=(0, 0))
        tk.Label(
            inner,
            text="Gouge it out.",
            fg=ACCENT,
            bg=BG,
            font=SUB_FONT,
        ).pack(pady=(8, 6))
        tk.Label(
            inner,
            text="Eliminate distractions on this Mac — ruthlessly.",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            wraplength=320,
            justify="center",
        ).pack(pady=(0, 22))

        self.count_label = tk.Label(inner, text="", fg=TEXT, bg=BG, font=BODY_FONT)
        self.count_label.pack(pady=(0, 18))

        self.block_button = PillButton(inner, "Block", self.start_block)
        self.block_button.pack()

        self.status = tk.Label(
            inner,
            text="macOS will ask for your password.",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            wraplength=320,
            justify="center",
        )
        self.status.pack(pady=(16, 20))

        self._link(inner, "Edit blocklist  →", self.show_list)
        self._link(inner, "Request unlock  →", self.show_request)
        self._link(inner, "Enter key  →", self.show_key)
        self._refresh_home_copy()

    def _build_list_page(self) -> None:
        self._back_header(self.list_page, "Blocklist", self.show_home)
        tk.Label(
            self.list_page,
            text="Blocked sites show in grey with their block time. Add new sites, then press Block.",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            wraplength=360,
            justify="left",
        ).pack(anchor="w", padx=24, pady=(4, 12))

        add_row = tk.Frame(self.list_page, bg=BG)
        add_row.pack(fill="x", padx=24)
        self.entry = tk.Entry(
            add_row,
            font=BODY_FONT,
            bg=SURFACE,
            fg=TEXT,
            insertbackground=TEXT,
            relief="flat",
            highlightthickness=1,
            highlightbackground=BORDER,
            highlightcolor=ACCENT,
        )
        self.entry.pack(side="left", fill="x", expand=True, ipady=8, padx=(0, 8))
        self.entry.bind("<Return>", lambda _e: self.add_domain())
        self._set_placeholder()

        add_btn = tk.Label(
            add_row,
            text="Add",
            fg="white",
            bg=ACCENT,
            font=LINK_FONT,
            padx=16,
            pady=8,
            cursor="hand2",
        )
        add_btn.pack(side="right")
        add_btn.bind("<Button-1>", lambda _e: self.add_domain())

        self.list_status = tk.Label(
            self.list_page,
            text="",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            anchor="w",
        )
        self.list_status.pack(fill="x", padx=24, pady=(8, 4))

        list_wrap = tk.Frame(self.list_page, bg=SURFACE, highlightthickness=1, highlightbackground=BORDER)
        list_wrap.pack(fill="both", expand=True, padx=24, pady=(4, 16))
        self._list_canvas = tk.Canvas(list_wrap, bg=SURFACE, highlightthickness=0, borderwidth=0)
        scroll = tk.Scrollbar(list_wrap, command=self._list_canvas.yview)
        self._list_inner = tk.Frame(self._list_canvas, bg=SURFACE)
        self._list_window = self._list_canvas.create_window((0, 0), window=self._list_inner, anchor="nw")
        self._list_canvas.configure(yscrollcommand=scroll.set)
        self._list_inner.bind(
            "<Configure>",
            lambda _e: self._list_canvas.configure(scrollregion=self._list_canvas.bbox("all")),
        )
        self._list_canvas.bind(
            "<Configure>",
            lambda e: self._list_canvas.itemconfigure(self._list_window, width=e.width),
        )
        self._list_canvas.pack(side="left", fill="both", expand=True, padx=(8, 0), pady=8)
        scroll.pack(side="right", fill="y", pady=8, padx=(0, 4))
        self._bind_mousewheel(self._list_canvas)
        self._bind_mousewheel(self._list_inner)
        self._selected_domain: str | None = None
        self._row_widgets: dict[str, tk.Frame] = {}
        self.list_page.bind("<BackSpace>", lambda _e: self.remove_selected())
        self.list_page.bind("<Delete>", lambda _e: self.remove_selected())

        footer = tk.Frame(self.list_page, bg=BG)
        footer.pack(fill="x", padx=24, pady=(0, 20))
        remove = tk.Label(
            footer,
            text="Remove selected",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            cursor="hand2",
        )
        remove.pack(side="left")
        remove.bind("<Button-1>", lambda _e: self.remove_selected())
        tk.Label(
            footer,
            text="Blocked sites can't be removed.",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
        ).pack(side="right")

    def _build_request_page(self) -> None:
        self._back_header(self.request_page, "Request unlock", self.show_home)
        tk.Label(
            self.request_page,
            text="Pick blocked sites. A key will be emailed in about 2 hours. It only works if those sites still have the same block times.",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            wraplength=360,
            justify="left",
        ).pack(anchor="w", padx=24, pady=(4, 12))

        req_wrap = tk.Frame(self.request_page, bg=SURFACE, highlightthickness=1, highlightbackground=BORDER)
        req_wrap.pack(fill="both", expand=True, padx=24, pady=(0, 12))
        self._req_canvas = tk.Canvas(req_wrap, bg=SURFACE, highlightthickness=0, borderwidth=0)
        req_scroll = tk.Scrollbar(req_wrap, command=self._req_canvas.yview)
        self._req_inner = tk.Frame(self._req_canvas, bg=SURFACE)
        self._req_window = self._req_canvas.create_window((0, 0), window=self._req_inner, anchor="nw")
        self._req_canvas.configure(yscrollcommand=req_scroll.set)
        self._req_inner.bind(
            "<Configure>",
            lambda _e: self._req_canvas.configure(scrollregion=self._req_canvas.bbox("all")),
        )
        self._req_canvas.bind(
            "<Configure>",
            lambda e: self._req_canvas.itemconfigure(self._req_window, width=e.width),
        )
        self._req_canvas.pack(side="left", fill="both", expand=True, padx=(8, 0), pady=8)
        req_scroll.pack(side="right", fill="y", pady=8, padx=(0, 4))
        self._bind_mousewheel(self._req_canvas, self._req_canvas)
        self._bind_mousewheel(self._req_inner, self._req_canvas)

        tk.Label(self.request_page, text="Email", fg=TEXT, bg=BG, font=SMALL_FONT, anchor="w").pack(
            fill="x", padx=24, pady=(0, 4)
        )
        self.email_entry = tk.Entry(
            self.request_page,
            font=BODY_FONT,
            bg=SURFACE,
            fg=TEXT,
            insertbackground=TEXT,
            relief="flat",
            highlightthickness=1,
            highlightbackground=BORDER,
            highlightcolor=ACCENT,
        )
        self.email_entry.pack(fill="x", padx=24, ipady=8)

        self.request_status = tk.Label(
            self.request_page,
            text="",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            wraplength=360,
            justify="left",
        )
        self.request_status.pack(fill="x", padx=24, pady=(8, 8))

        self.request_button = PillButton(
            self.request_page,
            "Request unlock",
            self.submit_unlock_request,
            width=320,
            height=48,
        )
        self.request_button.pack(pady=(0, 20))

    def _build_key_page(self) -> None:
        self._back_header(self.key_page, "Enter key", self.show_home)
        tk.Label(
            self.key_page,
            text="Paste the unlock key from your email. It only works if the selected sites still have the same block times as when you requested it.",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            wraplength=360,
            justify="left",
        ).pack(anchor="w", padx=24, pady=(4, 20))

        self.key_entry = tk.Entry(
            self.key_page,
            font=BODY_FONT,
            bg=SURFACE,
            fg=TEXT,
            insertbackground=TEXT,
            relief="flat",
            highlightthickness=1,
            highlightbackground=BORDER,
            highlightcolor=ACCENT,
        )
        self.key_entry.pack(fill="x", padx=24, ipady=10)
        self.key_entry.bind("<Return>", lambda _e: self.submit_key())

        self.key_status = tk.Label(
            self.key_page,
            text="",
            fg=MUTED,
            bg=BG,
            font=SMALL_FONT,
            wraplength=360,
            justify="left",
        )
        self.key_status.pack(fill="x", padx=24, pady=(12, 12))

        self.key_button = PillButton(
            self.key_page,
            "Unlock",
            self.submit_key,
            width=320,
            height=48,
        )
        self.key_button.pack(pady=(0, 20))

    def _set_placeholder(self) -> None:
        self._placeholder = True
        self.entry.delete(0, "end")
        self.entry.insert(0, "youtube.com")
        self.entry.configure(fg=MUTED)
        self.entry.bind("<FocusIn>", self._clear_placeholder)
        self.entry.bind("<FocusOut>", self._restore_placeholder)

    def _clear_placeholder(self, _event=None) -> None:
        if self._placeholder:
            self.entry.delete(0, "end")
            self.entry.configure(fg=TEXT)
            self._placeholder = False

    def _restore_placeholder(self, _event=None) -> None:
        if not self.entry.get().strip():
            self._set_placeholder()

    def _hosts_text(self) -> str:
        try:
            return HostsBlocker.current_hosts_text()
        except OSError:
            return ""

    def _refresh_home_copy(self) -> None:
        hosts_text = self._hosts_text()
        pending = self.state.pending_not_blocked(hosts_text)
        blocked_n = len(HostsBlocker.blocked_domains(hosts_text))
        if pending:
            n = len(pending)
            word = "site" if n == 1 else "sites"
            self.count_label.configure(text=f"{n} {word} ready to block")
            if not self._blocking:
                self.block_button.set_enabled(True)
        elif blocked_n:
            word = "site" if blocked_n == 1 else "sites"
            self.count_label.configure(text=f"{blocked_n} {word} blocked on this Mac")
            self.block_button.set_enabled(False)
        else:
            self.count_label.configure(text="No sites on your blocklist yet")
            self.block_button.set_enabled(False)

    def _bind_mousewheel(self, widget: tk.Misc, canvas: tk.Canvas | None = None) -> None:
        target = canvas or self._list_canvas

        def on_wheel(event) -> str:
            if getattr(event, "delta", 0):
                target.yview_scroll(-1 if event.delta > 0 else 1, "units")
            elif getattr(event, "num", None) == 4:
                target.yview_scroll(-1, "units")
            elif getattr(event, "num", None) == 5:
                target.yview_scroll(1, "units")
            return "break"

        widget.bind("<MouseWheel>", on_wheel, add="+")
        widget.bind("<Button-4>", on_wheel, add="+")
        widget.bind("<Button-5>", on_wheel, add="+")

    def _select_domain(self, domain: str, blocked: bool) -> None:
        if blocked:
            return
        self._selected_domain = domain
        for name, row in self._row_widgets.items():
            is_blocked = row.blocked  # type: ignore[attr-defined]
            selected = name == domain
            bg = SURFACE_2 if selected else SURFACE
            fg = MUTED if is_blocked else TEXT
            row.configure(bg=bg)
            for child in row.winfo_children():
                child.configure(bg=bg, fg=fg)

    def _reload_listbox(self) -> None:
        hosts_text = self._hosts_text()
        self.state.prune_pending_blocked()
        blocked = self.state.blocked_with_times(hosts_text)
        pending = self.state.pending_not_blocked(hosts_text)
        visible: list[tuple[str, bool, str | None]] = [
            (d, False, None) for d in pending
        ] + [(d, True, ts) for d, ts in blocked]

        visible_names = {name for name, _, _ in visible}
        for child in self._list_inner.winfo_children():
            child.destroy()
        self._row_widgets = {}
        if self._selected_domain not in visible_names or (
            self._selected_domain and HostsBlocker.is_blocked(self._selected_domain, hosts_text)
        ):
            self._selected_domain = None

        for domain, is_blocked, block_time in visible:
            row = tk.Frame(self._list_inner, bg=SURFACE)
            row.blocked = is_blocked  # type: ignore[attr-defined]
            row.pack(fill="x")
            left = tk.Frame(row, bg=SURFACE)
            left.pack(side="left", fill="x", expand=True, padx=(8, 8), pady=7)
            tk.Label(
                left,
                text=domain,
                font=BODY_FONT,
                fg=MUTED if is_blocked else TEXT,
                bg=SURFACE,
                anchor="w",
            ).pack(anchor="w")
            if is_blocked and block_time:
                tk.Label(
                    left,
                    text=f"blocked · {format_block_time(block_time)}",
                    font=SMALL_FONT,
                    fg=MUTED,
                    bg=SURFACE,
                    anchor="w",
                ).pack(anchor="w")
            widgets = [row, left, *left.winfo_children()]
            if is_blocked:
                tag = tk.Label(row, text="blocked", font=SMALL_FONT, fg=MUTED, bg=SURFACE)
                tag.pack(side="right", padx=(0, 10))
                widgets.append(tag)
            for widget in widgets:
                if not is_blocked:
                    widget.configure(cursor="hand2")
                    widget.bind("<Button-1>", lambda _e, d=domain: self._select_domain(d, False))
                self._bind_mousewheel(widget)
            self._row_widgets[domain] = row

        if self._selected_domain:
            self._select_domain(self._selected_domain, False)
        self._list_canvas.yview_moveto(0)
        self._refresh_home_copy()

    def _reload_request_list(self) -> None:
        for child in self._req_inner.winfo_children():
            child.destroy()
        self._unlock_checks = {}
        blocked = self.state.blocked_with_times()
        if not blocked:
            tk.Label(
                self._req_inner,
                text="No blocked sites to unlock.",
                font=BODY_FONT,
                fg=MUTED,
                bg=SURFACE,
                anchor="w",
            ).pack(fill="x", padx=8, pady=12)
            self.request_button.set_enabled(False)
            return

        for domain, block_time in blocked:
            var = tk.BooleanVar(value=False)
            self._unlock_checks[domain] = var
            row = tk.Frame(self._req_inner, bg=SURFACE)
            row.pack(fill="x", pady=2)
            cb = tk.Checkbutton(
                row,
                variable=var,
                bg=SURFACE,
                activebackground=SURFACE,
                highlightthickness=0,
                command=self._update_request_button,
            )
            cb.pack(side="left", padx=(4, 8))
            text = domain
            if block_time:
                text += f"  ·  {format_block_time(block_time)}"
            tk.Label(
                row,
                text=text,
                font=BODY_FONT,
                fg=MUTED,
                bg=SURFACE,
                anchor="w",
            ).pack(side="left", fill="x", expand=True, pady=6)
            self._bind_mousewheel(row, self._req_canvas)
            self._bind_mousewheel(cb, self._req_canvas)
        self._update_request_button()

    def _update_request_button(self) -> None:
        email_ok = bool(EMAIL_RE.match(self.email_entry.get().strip()))
        selected = any(v.get() for v in self._unlock_checks.values())
        self.request_button.set_enabled(email_ok and selected and not self._unlock_requesting)

    def show_home(self) -> None:
        self._refresh_home_copy()
        self.home.tkraise()

    def show_list(self) -> None:
        self.list_status.configure(text="")
        self._reload_listbox()
        self.list_page.tkraise()
        self.entry.focus_set()

    def show_request(self) -> None:
        self.request_status.configure(text="")
        self._reload_request_list()
        self.email_entry.bind("<KeyRelease>", lambda _e: self._update_request_button())
        self.request_page.tkraise()
        self.email_entry.focus_set()

    def show_key(self) -> None:
        self.key_status.configure(text="")
        self.key_page.tkraise()
        self.key_entry.focus_set()

    def add_domain(self) -> None:
        raw = "" if self._placeholder else self.entry.get()
        try:
            domain = self.state.add_pending(raw)
        except ValueError as exc:
            self.list_status.configure(text=str(exc), fg=DANGER)
            return
        self._reload_listbox()
        self.list_status.configure(text=f"Added {domain}", fg=SUCCESS)
        self._set_placeholder()
        self.entry.focus_set()

    def remove_selected(self) -> None:
        domain = self._selected_domain
        if not domain:
            self.list_status.configure(text="Select a site that isn't blocked yet.", fg=MUTED)
            return
        try:
            self.state.remove_pending(domain)
        except ValueError as exc:
            self.list_status.configure(text=str(exc), fg=DANGER)
            return
        self._selected_domain = None
        self._reload_listbox()
        self.list_status.configure(text=f"Removed {domain} from the list.", fg=MUTED)

    def start_block(self) -> None:
        pending = self.state.pending_not_blocked()
        if self._blocking or not pending:
            return
        self._blocking = True
        self.block_button.set_enabled(False)
        self.block_button.set_text("Blocking…")
        self.status.configure(text="macOS will ask for your password.", fg=MUTED)
        threading.Thread(target=self._block_worker, args=(pending,), daemon=True).start()

    def _block_worker(self, pending: list[str]) -> None:
        try:
            newly_blocked = HostsBlocker.apply(pending)
            self.state.stamp_newly_blocked(newly_blocked)
        except PermissionError:
            self.root.after(0, lambda: self._finish_block("Authentication canceled.", DANGER))
        except Exception as exc:
            message = str(exc) or "Could not update hosts."
            self.root.after(0, lambda m=message: self._finish_block(m, DANGER))
        else:
            n = len(newly_blocked)
            if n == 0:
                msg = "Already blocked — nothing new to add."
            elif n == 1:
                msg = "Locked in 1 site."
            else:
                msg = f"Locked in {n} sites."
            self.root.after(0, lambda m=msg: self._finish_block(m, SUCCESS))

    def _finish_block(self, message: str, color: str) -> None:
        self._blocking = False
        self.block_button.set_text("Block")
        self.state.prune_pending_blocked()
        self._reload_listbox()
        self.status.configure(text=message, fg=color)

    def submit_unlock_request(self) -> None:
        if self._unlock_requesting:
            return
        email = self.email_entry.get().strip()
        if not EMAIL_RE.match(email):
            self.request_status.configure(text="Enter a valid email.", fg=DANGER)
            return
        selected: dict[str, str] = {}
        for domain, var in self._unlock_checks.items():
            if not var.get():
                continue
            ts = self.state.block_times.get(domain)
            if not ts:
                self.request_status.configure(
                    text=f"No block time recorded for {domain}.",
                    fg=DANGER,
                )
                return
            selected[domain] = ts
        if not selected:
            self.request_status.configure(text="Select at least one site.", fg=DANGER)
            return

        self._unlock_requesting = True
        self.request_button.set_enabled(False)
        self.request_button.set_text("Sending…")
        self.request_status.configure(text="Sending unlock request…", fg=MUTED)
        threading.Thread(
            target=self._unlock_request_worker,
            args=(email, selected),
            daemon=True,
        ).start()

    def _unlock_request_worker(self, email: str, sites: dict[str, str]) -> None:
        try:
            result = request_unlock(email, sites)
            self.state.set_pending_unlock(email, sites)
            request_id = result.get("id", "")
            delivery = result.get("delivery") or {}
            sent = int(delivery.get("sent") or 0)
            errors = delivery.get("errors") or []
            if sent > 0:
                msg = (
                    f"Request sent. Check {email} for your unlock key "
                    "(including spam/junk)."
                )
            elif errors:
                msg = f"Request saved, but email failed: {errors[0]}"
            else:
                msg = (
                    f"Request sent. Your key will be emailed to {email} in about 2 hours. "
                    f"Check spam/junk if you don't see it. Request id: {request_id}"
                )
        except UnlockAPIError as exc:
            self.root.after(0, lambda m=str(exc): self._finish_unlock_request(m, DANGER))
        except Exception as exc:
            self.root.after(0, lambda m=str(exc): self._finish_unlock_request(m, DANGER))
        else:
            color = SUCCESS if sent > 0 else (DANGER if errors else SUCCESS)
            self.root.after(0, lambda m=msg, c=color: self._finish_unlock_request(m, c))

    def _finish_unlock_request(self, message: str, color: str) -> None:
        self._unlock_requesting = False
        self.request_button.set_text("Request unlock")
        self._update_request_button()
        self.request_status.configure(text=message, fg=color)

    def submit_key(self) -> None:
        if self._redeeming:
            return
        key = self.key_entry.get().strip()
        if not key:
            self.key_status.configure(text="Paste your unlock key.", fg=DANGER)
            return
        self._redeeming = True
        self.key_button.set_enabled(False)
        self.key_button.set_text("Unlocking…")
        self.key_status.configure(text="Checking key…", fg=MUTED)
        threading.Thread(target=self._redeem_worker, args=(key,), daemon=True).start()

    def _redeem_worker(self, key: str) -> None:
        try:
            sites = lookup_key(key)
            if not self.state.pending_unlock_matches(sites):
                raise ValueError(
                    "This key doesn't match a pending unlock request on this Mac."
                )
            for domain, snapshot_time in sites.items():
                current = self.state.block_times.get(domain)
                if current != snapshot_time:
                    raise ValueError(
                        "Key no longer matches. Block times changed since the request."
                    )
                if not HostsBlocker.is_blocked(domain):
                    raise ValueError(f"{domain} is not blocked anymore.")
            HostsBlocker.unblock(list(sites.keys()))
            self.state.remove_block_times(list(sites.keys()))
            self.state.clear_pending_unlock()
        except PermissionError:
            self.root.after(0, lambda: self._finish_redeem("Authentication canceled.", DANGER, False))
        except (UnlockAPIError, ValueError) as exc:
            self.root.after(0, lambda m=str(exc): self._finish_redeem(m, DANGER, False))
        except Exception as exc:
            self.root.after(0, lambda m=str(exc): self._finish_redeem(m, DANGER, False))
        else:
            self.root.after(
                0,
                lambda: self._finish_redeem(
                    "Unlocked successfully.",
                    SUCCESS,
                    True,
                ),
            )

    def _finish_redeem(self, message: str, color: str, success: bool) -> None:
        self._redeeming = False
        self.key_button.set_text("Unlock")
        self.key_button.set_enabled(True)
        self.key_status.configure(text=message, fg=color)
        if success:
            self._reload_listbox()
            self._reload_request_list()
            self._refresh_home_copy()


def main() -> None:
    if sys.platform != "darwin":
        print("EyePatch is a macOS app.", file=sys.stderr)
        sys.exit(1)
    root = tk.Tk()
    try:
        root.createcommand("tk::mac::Quit", root.destroy)
    except tk.TclError:
        pass
    LockInApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
