from __future__ import annotations

import json
import os
import platform
import queue
import re
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog, ttk

from PIL import Image, ImageDraw

try:
    import pystray
except Exception:  # pragma: no cover
    pystray = None

from .classifier import SafetyClassifier
from .mailer import ReportMailer, send_desktop_notification
from .models import (
    AVATAR_COLORS,
    CATEGORIES,
    CATEGORY_COLORS,
    CATEGORY_LABELS,
    Detection,
    Profile,
)
from .scanner import ScanResult, ScreenScanner
from .storage import APP_DIR, ProfileStore, ReportStore, valid_email


APP_NAME = "Safety Judge V13"
APP_VERSION = "13.0.0"
LAUNCH_AGENT = Path.home() / "Library" / "LaunchAgents" / "com.safetyjudge.v13.plist"


class OverlayManager:
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.windows: list[tk.Toplevel] = []

    def clear(self) -> None:
        for window in self.windows:
            try:
                window.destroy()
            except tk.TclError:
                pass
        self.windows.clear()

    def show(self, detections: list[Detection], duration_ms: int = 5000) -> None:
        self.clear()
        for detection in detections[:20]:
            if detection.source != "screen" or detection.width <= 0 or detection.height <= 0:
                continue
            color = CATEGORY_COLORS.get(detection.category, "#DC2626")
            label_text = CATEGORY_LABELS.get(detection.category, detection.category)
            pad = 5
            badge_height = 20
            x = max(0, detection.x - pad)
            y = max(0, detection.y - pad - badge_height)
            width = max(80, detection.width + pad * 2)
            height = max(32, detection.height + pad * 2 + badge_height)

            window = tk.Toplevel(self.root)
            window.overrideredirect(True)
            window.attributes("-topmost", True)
            try:
                window.attributes("-alpha", 0.38)
            except tk.TclError:
                pass
            window.geometry(f"{width}x{height}+{x}+{y}")
            canvas = tk.Canvas(window, bg=color, highlightthickness=0)
            canvas.pack(fill="both", expand=True)
            canvas.create_rectangle(
                2,
                badge_height,
                width - 2,
                height - 2,
                outline="white",
                width=3,
            )
            canvas.create_text(
                7,
                2,
                anchor="nw",
                text=label_text,
                fill="white",
                font=("Helvetica", 10, "bold"),
            )
            self.windows.append(window)

        if self.windows:
            self.root.after(duration_ms, self.clear)


class ProfileDialog(tk.Toplevel):
    def __init__(
        self,
        parent: tk.Misc,
        *,
        title: str,
        profile: Profile | None = None,
    ) -> None:
        super().__init__(parent)
        self.title(title)
        self.resizable(False, False)
        self.transient(parent)
        self.grab_set()
        self.result: tuple[str, str, str] | None = None
        self.selected_color = profile.avatar_color if profile else AVATAR_COLORS[0]

        wrapper = ttk.Frame(self, padding=24)
        wrapper.pack(fill="both", expand=True)

        ttk.Label(wrapper, text=title, style="DialogTitle.TLabel").pack(anchor="w")
        ttk.Label(
            wrapper,
            text="Each profile keeps separate scan settings, history, and report email.",
            wraplength=430,
        ).pack(anchor="w", pady=(4, 20))

        ttk.Label(wrapper, text="Profile name", style="FieldLabel.TLabel").pack(anchor="w")
        self.name_var = tk.StringVar(value=profile.display_name if profile else "")
        name_entry = ttk.Entry(wrapper, textvariable=self.name_var, width=44)
        name_entry.pack(fill="x", pady=(5, 14), ipady=6)

        ttk.Label(wrapper, text="Report email (optional)", style="FieldLabel.TLabel").pack(anchor="w")
        self.email_var = tk.StringVar(value=profile.report_email if profile else "")
        ttk.Entry(wrapper, textvariable=self.email_var).pack(fill="x", pady=(5, 6), ipady=6)
        ttk.Label(
            wrapper,
            text="Reports and email drafts for this profile will use this address.",
            style="Muted.TLabel",
        ).pack(anchor="w", pady=(0, 15))

        ttk.Label(wrapper, text="Profile color", style="FieldLabel.TLabel").pack(anchor="w")
        colors = ttk.Frame(wrapper)
        colors.pack(anchor="w", pady=(7, 20))
        self.color_buttons: list[tk.Button] = []
        for color in AVATAR_COLORS:
            button = tk.Button(
                colors,
                bg=color,
                activebackground=color,
                width=3,
                height=1,
                bd=3 if color == self.selected_color else 1,
                relief="sunken" if color == self.selected_color else "flat",
                command=lambda c=color: self._select_color(c),
            )
            button.pack(side="left", padx=(0, 8))
            self.color_buttons.append(button)

        actions = ttk.Frame(wrapper)
        actions.pack(fill="x", pady=(4, 0))
        ttk.Button(actions, text="Cancel", command=self.destroy).pack(side="right")
        ttk.Button(actions, text="Save profile", style="Primary.TButton", command=self._save).pack(
            side="right", padx=(0, 8)
        )

        self.bind("<Return>", lambda _event: self._save())
        self.bind("<Escape>", lambda _event: self.destroy())
        name_entry.focus_set()
        self.update_idletasks()
        self.geometry(f"500x430+{parent.winfo_rootx()+160}+{parent.winfo_rooty()+100}")

    def _select_color(self, color: str) -> None:
        self.selected_color = color
        for button in self.color_buttons:
            selected = str(button.cget("bg")) == color
            button.configure(bd=3 if selected else 1, relief="sunken" if selected else "flat")

    def _save(self) -> None:
        name = " ".join(self.name_var.get().split())
        email = self.email_var.get().strip().lower()
        if not name:
            messagebox.showerror("Profile name needed", "Enter a name for this profile.", parent=self)
            return
        if not valid_email(email):
            messagebox.showerror("Invalid email", "Enter a valid email or leave it blank.", parent=self)
            return
        self.result = (name, email, self.selected_color)
        self.destroy()


class SafetyJudgeApp:
    def __init__(
        self,
        root: tk.Tk,
        *,
        start_background: bool = False,
        startup_profile_id: str = "",
    ) -> None:
        self.root = root
        self.root.title(APP_NAME)
        self.root.geometry("1180x760")
        self.root.minsize(960, 650)
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)
        self.screen_width = max(1, self.root.winfo_screenwidth())
        self.screen_height = max(1, self.root.winfo_screenheight())

        self.profile_store = ProfileStore()
        self.report_store = ReportStore(self.profile_store)
        self.classifier = SafetyClassifier()
        self.scanner = ScreenScanner(self.classifier)
        self.mailer = ReportMailer()
        self.overlay = OverlayManager(root)

        self.current_profile: Profile | None = None
        self.current_page = ""
        self.live_thread: threading.Thread | None = None
        self.stop_event = threading.Event()
        self.scan_lock = threading.Lock()
        self.result_queue: queue.Queue[tuple[str, object]] = queue.Queue()
        self.latest_detections: list[Detection] = []
        self.visible_detections: dict[str, Detection] = {}
        self.report_rows: dict[str, Detection] = {}
        self.manual_results_cache: list[Detection] = []
        self.tray_icon = None
        self.restore_after_scan = False
        self.shutting_down = False

        self._configure_style()
        self.root.after(120, self._poll_queue)

        startup_profile = self.profile_store.get_profile(startup_profile_id) if startup_profile_id else None
        if start_background and startup_profile:
            self.open_profile(startup_profile)
            self.root.after(500, self._start_background_mode)
        else:
            self.show_profile_chooser()

    # ---------- styling and shell ----------
    def _configure_style(self) -> None:
        style = ttk.Style()
        try:
            style.theme_use("clam")
        except tk.TclError:
            pass
        style.configure("App.TFrame", background="#F8FAFC")
        style.configure("Sidebar.TFrame", background="#0F172A")
        style.configure("Card.TFrame", background="#FFFFFF", relief="solid", borderwidth=1)
        style.configure("Title.TLabel", font=("Helvetica", 26, "bold"), background="#F8FAFC", foreground="#0F172A")
        style.configure("PageTitle.TLabel", font=("Helvetica", 22, "bold"), background="#F8FAFC", foreground="#0F172A")
        style.configure("DialogTitle.TLabel", font=("Helvetica", 18, "bold"))
        style.configure("Heading.TLabel", font=("Helvetica", 14, "bold"))
        style.configure("FieldLabel.TLabel", font=("Helvetica", 11, "bold"))
        style.configure("Muted.TLabel", foreground="#64748B", background="#F8FAFC")
        style.configure(
            "Primary.TButton",
            font=("Helvetica", 11, "bold"),
            padding=(14, 9),
            background="#2563EB",
            foreground="#FFFFFF",
            borderwidth=0,
        )
        style.map("Primary.TButton", background=[("active", "#1D4ED8"), ("pressed", "#1E40AF")])
        style.configure("Secondary.TButton", padding=(12, 8))
        style.configure(
            "Nav.TButton",
            font=("Helvetica", 11, "bold"),
            padding=(16, 12),
            anchor="w",
            background="#1E293B",
            foreground="#E2E8F0",
            borderwidth=0,
        )
        style.map("Nav.TButton", background=[("active", "#334155"), ("pressed", "#475569")])
        style.configure("Danger.TButton", padding=(12, 8))
        style.configure("StatValue.TLabel", font=("Helvetica", 25, "bold"), background="#FFFFFF")
        style.configure("StatLabel.TLabel", font=("Helvetica", 10), foreground="#64748B", background="#FFFFFF")
        style.configure("StatusOn.TLabel", font=("Helvetica", 12, "bold"), foreground="#15803D")
        style.configure("StatusOff.TLabel", font=("Helvetica", 12, "bold"), foreground="#B91C1C")
        style.configure("Treeview", rowheight=29, font=("Helvetica", 10))
        style.configure("Treeview.Heading", font=("Helvetica", 10, "bold"))

    def _clear_root(self) -> None:
        self.overlay.clear()
        for child in self.root.winfo_children():
            if isinstance(child, tk.Toplevel):
                continue
            child.destroy()

    def _build_shell(self) -> None:
        self._clear_root()
        outer = ttk.Frame(self.root, style="App.TFrame")
        outer.pack(fill="both", expand=True)

        self.sidebar = ttk.Frame(outer, style="Sidebar.TFrame", width=230)
        self.sidebar.pack(side="left", fill="y")
        self.sidebar.pack_propagate(False)

        self.content = ttk.Frame(outer, style="App.TFrame", padding=(24, 20))
        self.content.pack(side="left", fill="both", expand=True)

        profile = self._require_profile()
        profile_header = tk.Frame(self.sidebar, bg="#0F172A", padx=18, pady=22)
        profile_header.pack(fill="x")
        avatar = tk.Canvas(profile_header, width=52, height=52, bg="#0F172A", highlightthickness=0)
        avatar.pack(side="left")
        avatar.create_oval(2, 2, 50, 50, fill=profile.avatar_color, outline="")
        avatar.create_text(26, 27, text=profile.initials(), fill="white", font=("Helvetica", 15, "bold"))
        text_box = tk.Frame(profile_header, bg="#0F172A")
        text_box.pack(side="left", padx=(10, 0), fill="x", expand=True)
        tk.Label(
            text_box,
            text=profile.display_name,
            bg="#0F172A",
            fg="white",
            font=("Helvetica", 12, "bold"),
            anchor="w",
        ).pack(fill="x")
        tk.Label(
            text_box,
            text=profile.report_email or "No report email",
            bg="#0F172A",
            fg="#94A3B8",
            font=("Helvetica", 9),
            anchor="w",
        ).pack(fill="x", pady=(3, 0))

        nav = tk.Frame(self.sidebar, bg="#0F172A", padx=12, pady=10)
        nav.pack(fill="x")
        self.nav_buttons: dict[str, ttk.Button] = {}
        for page, label in [
            ("home", "⌂  Overview"),
            ("live", "◉  Live Protection"),
            ("analyze", "≡  Analyze Text"),
            ("reports", "▤  Reports"),
            ("profile", "⚙  Profile & Settings"),
        ]:
            button = ttk.Button(nav, text=label, style="Nav.TButton", command=lambda p=page: self.show_page(p))
            button.pack(fill="x", pady=3)
            self.nav_buttons[page] = button

        bottom = tk.Frame(self.sidebar, bg="#0F172A", padx=14, pady=18)
        bottom.pack(side="bottom", fill="x")
        self.sidebar_status_var = tk.StringVar(value="Protection off")
        tk.Label(
            bottom,
            textvariable=self.sidebar_status_var,
            bg="#0F172A",
            fg="#CBD5E1",
            font=("Helvetica", 10, "bold"),
            anchor="w",
        ).pack(fill="x", pady=(0, 8))
        ttk.Button(bottom, text="Switch profile", command=self.switch_profile).pack(fill="x")

        self.page_container = ttk.Frame(self.content, style="App.TFrame")
        self.page_container.pack(fill="both", expand=True)
        self.status_var = tk.StringVar(value="Ready")
        ttk.Label(self.content, textvariable=self.status_var, style="Muted.TLabel").pack(fill="x", pady=(8, 0))

    def show_page(self, page: str) -> None:
        if self.current_profile is None:
            return
        self.current_page = page
        for child in self.page_container.winfo_children():
            child.destroy()
        builders = {
            "home": self._build_home_page,
            "live": self._build_live_page,
            "analyze": self._build_analyze_page,
            "reports": self._build_reports_page,
            "profile": self._build_profile_page,
        }
        builders.get(page, self._build_home_page)()

    def _page_header(self, title: str, subtitle: str) -> ttk.Frame:
        header = ttk.Frame(self.page_container, style="App.TFrame")
        header.pack(fill="x", pady=(0, 18))
        ttk.Label(header, text=title, style="PageTitle.TLabel").pack(anchor="w")
        ttk.Label(header, text=subtitle, style="Muted.TLabel", wraplength=820).pack(anchor="w", pady=(3, 0))
        return header

    def _card(self, parent: tk.Misc, *, padding: int = 16) -> ttk.Frame:
        return ttk.Frame(parent, style="Card.TFrame", padding=padding)

    # ---------- profile chooser ----------
    def show_profile_chooser(self) -> None:
        self.stop_live()
        self.current_profile = None
        self._clear_root()
        self.root.deiconify()

        page = ttk.Frame(self.root, style="App.TFrame", padding=34)
        page.pack(fill="both", expand=True)

        top = ttk.Frame(page, style="App.TFrame")
        top.pack(fill="x")
        ttk.Label(top, text="Choose a profile", style="Title.TLabel").pack(side="left")
        ttk.Button(top, text="+ Create profile", style="Primary.TButton", command=self.create_profile).pack(side="right")
        ttk.Label(
            page,
            text="Profiles replace login accounts. Every person gets separate email settings, scanning preferences, and report history.",
            style="Muted.TLabel",
            wraplength=880,
        ).pack(anchor="w", pady=(7, 26))

        profiles = self.profile_store.list_profiles()
        if not profiles:
            empty = self._card(page, padding=30)
            empty.pack(fill="x", pady=20)
            ttk.Label(empty, text="No profiles yet", style="Heading.TLabel").pack(anchor="w")
            ttk.Label(
                empty,
                text="Create the first profile and add an optional email for reports.",
                style="Muted.TLabel",
            ).pack(anchor="w", pady=(5, 16))
            ttk.Button(empty, text="Create first profile", style="Primary.TButton", command=self.create_profile).pack(anchor="w")
            return

        canvas = tk.Canvas(page, bg="#F8FAFC", highlightthickness=0)
        scrollbar = ttk.Scrollbar(page, orient="vertical", command=canvas.yview)
        cards_frame = ttk.Frame(canvas, style="App.TFrame")
        cards_frame.bind("<Configure>", lambda _e: canvas.configure(scrollregion=canvas.bbox("all")))
        cards_window = canvas.create_window((0, 0), window=cards_frame, anchor="nw")
        canvas.bind("<Configure>", lambda event: canvas.itemconfigure(cards_window, width=event.width))
        canvas.configure(yscrollcommand=scrollbar.set)
        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        for profile in profiles:
            card = self._card(cards_frame, padding=18)
            card.pack(fill="x", pady=(0, 12), padx=(0, 8))
            avatar = tk.Canvas(card, width=58, height=58, bg="#FFFFFF", highlightthickness=0)
            avatar.pack(side="left")
            avatar.create_oval(3, 3, 55, 55, fill=profile.avatar_color, outline="")
            avatar.create_text(29, 30, text=profile.initials(), fill="white", font=("Helvetica", 16, "bold"))

            info = ttk.Frame(card, style="Card.TFrame")
            info.pack(side="left", fill="x", expand=True, padx=14)
            ttk.Label(info, text=profile.display_name, style="Heading.TLabel", background="#FFFFFF").pack(anchor="w")
            ttk.Label(
                info,
                text=profile.report_email or "Report email not set",
                style="Muted.TLabel",
                background="#FFFFFF",
            ).pack(anchor="w", pady=(3, 0))
            stats = self.report_store.stats(profile.profile_id)
            ttk.Label(
                info,
                text=f"{stats.get('total', 0)} saved detections • scans every {profile.scan_interval_seconds}s",
                style="Muted.TLabel",
                background="#FFFFFF",
            ).pack(anchor="w", pady=(3, 0))

            actions = ttk.Frame(card, style="Card.TFrame")
            actions.pack(side="right")
            ttk.Button(actions, text="Open", style="Primary.TButton", command=lambda p=profile: self.open_profile(p)).pack(
                side="left", padx=4
            )
            ttk.Button(actions, text="Edit", command=lambda p=profile: self.edit_profile_dialog(p)).pack(side="left", padx=4)
            ttk.Button(actions, text="Delete", command=lambda p=profile: self.delete_profile(p)).pack(side="left", padx=4)

    def create_profile(self) -> None:
        dialog = ProfileDialog(self.root, title="Create profile")
        self.root.wait_window(dialog)
        if not dialog.result:
            return
        try:
            profile = self.profile_store.create_profile(*dialog.result)
        except ValueError as exc:
            messagebox.showerror("Could not create profile", str(exc))
            return
        self.open_profile(profile)

    def edit_profile_dialog(self, profile: Profile) -> None:
        dialog = ProfileDialog(self.root, title="Edit profile", profile=profile)
        self.root.wait_window(dialog)
        if not dialog.result:
            return
        profile.display_name, profile.report_email, profile.avatar_color = dialog.result
        self.profile_store.save_profile(profile)
        if self.current_profile and self.current_profile.profile_id == profile.profile_id:
            self.current_profile = profile
            self._build_shell()
            self.show_page(self.current_page or "home")
        else:
            self.show_profile_chooser()

    def delete_profile(self, profile: Profile) -> None:
        confirmed = messagebox.askyesno(
            "Delete profile?",
            f"Delete {profile.display_name} and all of this profile's saved reports? This cannot be undone.",
        )
        if not confirmed:
            return
        self.profile_store.delete_profile(profile.profile_id)
        self.show_profile_chooser()

    def open_profile(self, profile: Profile) -> None:
        self.current_profile = profile
        self.profile_store.set_last_profile(profile.profile_id)
        self.latest_detections = []
        self._build_shell()
        self.show_page("home")

    def switch_profile(self) -> None:
        self.stop_live()
        self.show_profile_chooser()

    def _require_profile(self) -> Profile:
        if self.current_profile is None:
            raise RuntimeError("No profile is selected")
        return self.current_profile

    # ---------- overview ----------
    def _build_home_page(self) -> None:
        profile = self._require_profile()
        self._page_header(
            f"Welcome, {profile.display_name}",
            "See your protection status, recent detections, and profile-specific report totals.",
        )
        stats = self.report_store.stats(profile.profile_id)
        stats_row = ttk.Frame(self.page_container, style="App.TFrame")
        stats_row.pack(fill="x")
        for label, value in [
            ("Saved detections", stats.get("total", 0)),
            ("High risk", stats.get("high_risk", 0)),
            ("Scams", stats.get("SCAM", 0)),
            ("Dangerous advice", stats.get("DANGEROUS_ADVICE", 0)),
        ]:
            card = self._card(stats_row, padding=16)
            card.pack(side="left", fill="x", expand=True, padx=(0, 10))
            ttk.Label(card, text=str(value), style="StatValue.TLabel").pack(anchor="w")
            ttk.Label(card, text=label, style="StatLabel.TLabel").pack(anchor="w", pady=(2, 0))

        protection = self._card(self.page_container, padding=18)
        protection.pack(fill="x", pady=(18, 14))
        left = ttk.Frame(protection, style="Card.TFrame")
        left.pack(side="left", fill="x", expand=True)
        ttk.Label(left, text="Live Protection", style="Heading.TLabel", background="#FFFFFF").pack(anchor="w")
        is_running = bool(self.live_thread and self.live_thread.is_alive() and not self.stop_event.is_set())
        ttk.Label(
            left,
            text="ON — Safety Judge keeps scanning while hidden." if is_running else "OFF — Start protection to scan the screen repeatedly.",
            style="StatusOn.TLabel" if is_running else "StatusOff.TLabel",
            background="#FFFFFF",
        ).pack(anchor="w", pady=(5, 0))
        actions = ttk.Frame(protection, style="Card.TFrame")
        actions.pack(side="right")
        ttk.Button(
            actions,
            text="Stop" if is_running else "Start",
            style="Primary.TButton",
            command=self.stop_live if is_running else self.start_live,
        ).pack(side="left", padx=4)
        ttk.Button(actions, text="Scan once", command=self.scan_once).pack(side="left", padx=4)
        ttk.Button(actions, text="Hide app", command=self.hide_to_background).pack(side="left", padx=4)

        recent_card = self._card(self.page_container, padding=14)
        recent_card.pack(fill="both", expand=True)
        top = ttk.Frame(recent_card, style="Card.TFrame")
        top.pack(fill="x", pady=(0, 8))
        ttk.Label(top, text="Recent detections", style="Heading.TLabel", background="#FFFFFF").pack(side="left")
        ttk.Button(top, text="View all reports", command=lambda: self.show_page("reports")).pack(side="right")

        tree = ttk.Treeview(
            recent_card,
            columns=("time", "category", "risk", "text", "source"),
            show="headings",
            height=11,
        )
        for key, title, width in [
            ("time", "Time", 145),
            ("category", "Category", 130),
            ("risk", "Risk", 65),
            ("text", "Detected line", 475),
            ("source", "Source", 90),
        ]:
            tree.heading(key, text=title)
            tree.column(key, width=width, anchor="w")
        tree.pack(fill="both", expand=True)
        for detection in reversed(self.report_store.read(profile.profile_id, limit=12)):
            tree.insert(
                "",
                "end",
                values=(
                    detection.timestamp.replace("T", " "),
                    CATEGORY_LABELS.get(detection.category, detection.category),
                    f"{detection.risk:.0%}",
                    detection.text,
                    detection.source,
                ),
                tags=(detection.category,),
            )
        self._configure_tree_tags(tree)

    # ---------- live protection ----------
    def _build_live_page(self) -> None:
        self._page_header(
            "Live Protection",
            "OCR reads visible screen lines locally. Each warning appears on its own row with its category, risk, application, and screen coordinates.",
        )
        controls = self._card(self.page_container, padding=14)
        controls.pack(fill="x", pady=(0, 12))
        self.live_status_var = tk.StringVar()
        self._sync_live_status()
        ttk.Label(controls, text="Protection", style="FieldLabel.TLabel", background="#FFFFFF").pack(side="left")
        self.live_status_label = ttk.Label(controls, textvariable=self.live_status_var, background="#FFFFFF")
        self.live_status_label.pack(side="left", padx=(8, 20))
        ttk.Button(controls, text="Start", style="Primary.TButton", command=self.start_live).pack(side="left", padx=4)
        ttk.Button(controls, text="Stop", command=self.stop_live).pack(side="left", padx=4)
        ttk.Button(controls, text="Scan screen now", command=self.scan_once).pack(side="left", padx=4)
        ttk.Button(controls, text="Minimize to menu bar", command=self.hide_to_background).pack(side="left", padx=4)

        table_card = self._card(self.page_container, padding=12)
        table_card.pack(fill="both", expand=True)
        columns = ("category", "risk", "text", "application", "location", "time")
        self.detection_tree = ttk.Treeview(table_card, columns=columns, show="headings", height=17)
        for key, title, width in [
            ("category", "Category", 120),
            ("risk", "Risk", 55),
            ("text", "Detected line", 345),
            ("application", "Application", 95),
            ("location", "Screen location", 165),
            ("time", "Time", 72),
        ]:
            self.detection_tree.heading(key, text=title)
            self.detection_tree.column(key, width=width, anchor="w")
        self.detection_tree.pack(fill="both", expand=True)
        self._configure_tree_tags(self.detection_tree)

        buttons = ttk.Frame(table_card, style="Card.TFrame")
        buttons.pack(fill="x", pady=(10, 0))
        ttk.Button(buttons, text="Highlight selected", command=self.highlight_selected).pack(side="left")
        ttk.Button(buttons, text="Save visible warnings", command=self.save_visible_detections).pack(side="left", padx=7)
        ttk.Button(buttons, text="Email visible warnings", command=self.email_visible_detections).pack(side="left")
        ttk.Button(buttons, text="Clear screen list", command=self.clear_live_view).pack(side="right")

        for detection in self.latest_detections:
            self._insert_live_detection(detection)

    def _sync_live_status(self) -> None:
        running = bool(self.live_thread and self.live_thread.is_alive() and not self.stop_event.is_set())
        text = "ON" if running else "OFF"
        self.sidebar_status_var.set(f"Protection {text.lower()}") if hasattr(self, "sidebar_status_var") else None
        if hasattr(self, "live_status_var"):
            self.live_status_var.set(text)

    def start_live(self) -> None:
        if self.current_profile is None:
            return
        if self.live_thread and self.live_thread.is_alive() and not self.stop_event.is_set():
            self.status_var.set("Live protection is already running.")
            return
        self.stop_event.clear()
        self.live_thread = threading.Thread(target=self._live_loop, daemon=True, name="SafetyJudgeLiveScanner")
        self.live_thread.start()
        self._sync_live_status()
        self.status_var.set("Live protection started. It continues while the window is hidden.")
        if self.current_page == "home":
            self.show_page("home")

    def stop_live(self) -> None:
        self.stop_event.set()
        self.overlay.clear()
        self._sync_live_status()
        if hasattr(self, "status_var"):
            self.status_var.set("Live protection stopped.")
        if self.current_profile and self.current_page == "home" and hasattr(self, "page_container"):
            self.show_page("home")

    def _live_loop(self) -> None:
        while not self.stop_event.is_set() and self.current_profile is not None:
            self._scan_worker()
            profile = self.current_profile
            interval = profile.scan_interval_seconds if profile else 7
            self.stop_event.wait(max(3, interval))

    def scan_once(self) -> None:
        if self.current_profile is None:
            return
        threading.Thread(target=self._scan_worker, daemon=True, name="SafetyJudgeOneScan").start()

    def _scan_worker(self) -> None:
        if self.current_profile is None or not self.scan_lock.acquire(blocking=False):
            return
        try:
            self.result_queue.put(("status", "Scanning the visible screen with OCR…"))
            self.result_queue.put(("clear_overlay", None))
            time.sleep(0.12)
            profile_snapshot = Profile.from_dict(self.current_profile.to_dict())
            result = self.scanner.scan(profile_snapshot, self.screen_width, self.screen_height)
            self.result_queue.put(("scan_result", result))
        except Exception as exc:
            message = str(exc).strip() or "Unknown screen-capture error."
            if any(term in message.lower() for term in ("screen", "permission", "quartz", "capture")):
                message = (
                    "Screen capture failed. On macOS, open System Settings → Privacy & Security → "
                    "Screen & System Audio Recording, allow Terminal or Python, then restart Safety Judge."
                )
            self.result_queue.put(("error", message))
        finally:
            self.scan_lock.release()

    def _handle_scan_result(self, result: ScanResult) -> None:
        profile = self.current_profile
        if profile is None:
            return
        self.latest_detections = result.detections
        self.status_var.set(
            f"Scan complete in {result.elapsed_seconds:.1f}s: {len(result.detections)} new warning(s) from {result.ocr_line_count} OCR line(s)."
        )
        if not result.detections:
            return

        if profile.auto_save_reports:
            self.report_store.append(profile.profile_id, result.detections)

        if profile.show_highlights:
            self.overlay.show(result.detections)

        if profile.desktop_notifications:
            first = result.detections[0]
            label = CATEGORY_LABELS.get(first.category, first.category)
            send_desktop_notification(
                "Safety Judge warning",
                f"{label}: {first.text[:100]}",
            )

        if profile.auto_email_high_risk and any(item.risk >= 0.9 for item in result.detections):
            threading.Thread(
                target=self._auto_email,
                args=(Profile.from_dict(profile.to_dict()), result.detections),
                daemon=True,
            ).start()

        if hasattr(self, "detection_tree") and self.current_page == "live":
            for detection in result.detections:
                self._insert_live_detection(detection)
        elif self.current_page == "home":
            self.show_page("home")

    def _insert_live_detection(self, detection: Detection) -> None:
        if not hasattr(self, "detection_tree"):
            return
        iid = self.detection_tree.insert(
            "",
            0,
            values=(
                CATEGORY_LABELS.get(detection.category, detection.category),
                f"{detection.risk:.0%}",
                detection.text,
                detection.app_hint or "Unknown",
                detection.location_text,
                detection.timestamp.split("T")[-1],
            ),
            tags=(detection.category,),
        )
        self.visible_detections[iid] = detection

    def clear_live_view(self) -> None:
        if hasattr(self, "detection_tree"):
            for item in self.detection_tree.get_children():
                self.detection_tree.delete(item)
        self.visible_detections.clear()
        self.latest_detections = []
        self.overlay.clear()

    def highlight_selected(self) -> None:
        if not hasattr(self, "detection_tree"):
            return
        selected = self.detection_tree.selection()
        detections = [self.visible_detections[item] for item in selected if item in self.visible_detections]
        if not detections:
            detections = self.latest_detections
        if not detections:
            messagebox.showinfo("Nothing to highlight", "Run a screen scan first.")
            return
        self.overlay.show(detections, duration_ms=7000)

    def save_visible_detections(self) -> None:
        profile = self._require_profile()
        detections = list(self.visible_detections.values()) or self.latest_detections
        if not detections:
            messagebox.showinfo("No warnings", "There are no visible warnings to save.")
            return
        count = self.report_store.append(profile.profile_id, detections)
        self.status_var.set(f"Saved {count} warning(s) to {profile.display_name}'s report history.")

    def email_visible_detections(self) -> None:
        detections = list(self.visible_detections.values()) or self.latest_detections
        self._email_detections(detections)

    # ---------- manual analyzer ----------
    def _build_analyze_page(self) -> None:
        self._page_header(
            "Analyze Text",
            "Paste several lines. Safety Judge classifies every non-empty line separately and explains why it was flagged.",
        )
        input_card = self._card(self.page_container, padding=14)
        input_card.pack(fill="x", pady=(0, 12))
        self.manual_input = tk.Text(input_card, height=9, wrap="word", font=("Menlo", 11), undo=True)
        self.manual_input.pack(fill="x")
        actions = ttk.Frame(input_card, style="Card.TFrame")
        actions.pack(fill="x", pady=(10, 0))
        ttk.Button(actions, text="Analyze every line", style="Primary.TButton", command=self.analyze_manual_text).pack(side="left")
        ttk.Button(actions, text="Load examples", command=self.load_examples).pack(side="left", padx=7)
        ttk.Button(actions, text="Open text file", command=self.open_text_file).pack(side="left")
        ttk.Button(actions, text="Clear", command=lambda: self.manual_input.delete("1.0", "end")).pack(side="right")

        result_card = self._card(self.page_container, padding=12)
        result_card.pack(fill="both", expand=True)
        self.manual_tree = ttk.Treeview(
            result_card,
            columns=("line", "category", "risk", "text", "reason"),
            show="headings",
            height=15,
        )
        for key, title, width in [
            ("line", "Line", 45),
            ("category", "Category", 120),
            ("risk", "Risk", 55),
            ("text", "Text", 330),
            ("reason", "Reason", 335),
        ]:
            self.manual_tree.heading(key, text=title)
            self.manual_tree.column(key, width=width, anchor="w")
        self.manual_tree.pack(fill="both", expand=True)
        self._configure_tree_tags(self.manual_tree)
        row = ttk.Frame(result_card, style="Card.TFrame")
        row.pack(fill="x", pady=(10, 0))
        ttk.Button(row, text="Save flagged lines", command=self.save_manual_warnings).pack(side="left")
        ttk.Button(row, text="Email flagged lines", command=self.email_manual_warnings).pack(side="left", padx=7)

    def load_examples(self) -> None:
        examples = (
            "Your account will close in 10 minutes. Reply with your password and verification code.\n"
            "Scientists have confirmed that cold water always stops digestion.\n"
            "It is completely safe to put a metal fork in a microwave.\n"
            "Save 30% today when you subscribe to our new product.\n"
            "The chess tournament starts at 3 PM in the school library."
        )
        self.manual_input.delete("1.0", "end")
        self.manual_input.insert("1.0", examples)

    def open_text_file(self) -> None:
        path = filedialog.askopenfilename(filetypes=[("Text files", "*.txt"), ("All files", "*")])
        if not path:
            return
        try:
            text = Path(path).read_text(encoding="utf-8")
        except Exception as exc:
            messagebox.showerror("Could not open file", str(exc))
            return
        self.manual_input.delete("1.0", "end")
        self.manual_input.insert("1.0", text)

    def analyze_manual_text(self) -> None:
        profile = self._require_profile()
        text = self.manual_input.get("1.0", "end").strip()
        results = self.classifier.classify_lines(
            text,
            enabled_categories=profile.enabled_categories,
            source="manual",
        )
        self.manual_results_cache = results
        for item in self.manual_tree.get_children():
            self.manual_tree.delete(item)
        for index, detection in enumerate(results, start=1):
            self.manual_tree.insert(
                "",
                "end",
                values=(
                    index,
                    CATEGORY_LABELS.get(detection.category, detection.category),
                    f"{detection.risk:.0%}",
                    detection.text,
                    detection.reason,
                ),
                tags=(detection.category,),
            )
        warning_count = sum(1 for result in results if result.category != "SAFE" and result.risk >= profile.risk_threshold)
        self.status_var.set(f"Analyzed {len(results)} line(s); found {warning_count} warning(s).")

    def _manual_warnings(self) -> list[Detection]:
        profile = self._require_profile()
        return [
            item
            for item in self.manual_results_cache
            if item.category != "SAFE" and item.risk >= profile.risk_threshold
        ]

    def save_manual_warnings(self) -> None:
        profile = self._require_profile()
        warnings = self._manual_warnings()
        if not warnings:
            messagebox.showinfo("No flagged lines", "Analyze text first; no warning lines are currently available.")
            return
        self.report_store.append(profile.profile_id, warnings)
        self.status_var.set(f"Saved {len(warnings)} manual warning(s).")

    def email_manual_warnings(self) -> None:
        self._email_detections(self._manual_warnings())

    # ---------- reports ----------
    def _build_reports_page(self) -> None:
        profile = self._require_profile()
        self._page_header(
            "Reports",
            f"Only {profile.display_name}'s saved detections appear here. Filter, export, delete, highlight, or email them.",
        )
        filters = self._card(self.page_container, padding=12)
        filters.pack(fill="x", pady=(0, 12))
        ttk.Label(filters, text="Category", background="#FFFFFF").pack(side="left")
        self.report_category_var = tk.StringVar(value="All")
        category_box = ttk.Combobox(
            filters,
            textvariable=self.report_category_var,
            values=["All"] + [CATEGORY_LABELS[c] for c in CATEGORIES],
            state="readonly",
            width=19,
        )
        category_box.pack(side="left", padx=(6, 16))
        category_box.bind("<<ComboboxSelected>>", lambda _e: self.refresh_report_table())
        ttk.Label(filters, text="Search", background="#FFFFFF").pack(side="left")
        self.report_search_var = tk.StringVar()
        search = ttk.Entry(filters, textvariable=self.report_search_var, width=30)
        search.pack(side="left", padx=6)
        search.bind("<KeyRelease>", lambda _e: self.refresh_report_table())
        ttk.Button(filters, text="Refresh", command=self.refresh_report_table).pack(side="right")

        card = self._card(self.page_container, padding=12)
        card.pack(fill="both", expand=True)
        self.report_tree = ttk.Treeview(
            card,
            columns=("time", "category", "risk", "text", "application", "source"),
            show="headings",
            height=16,
            selectmode="extended",
        )
        for key, title, width in [
            ("time", "Time", 135),
            ("category", "Category", 120),
            ("risk", "Risk", 55),
            ("text", "Detected line", 345),
            ("application", "Application", 95),
            ("source", "Source", 75),
        ]:
            self.report_tree.heading(key, text=title)
            self.report_tree.column(key, width=width, anchor="w")
        self.report_tree.pack(fill="both", expand=True)
        self.report_tree.bind("<Double-1>", lambda _e: self.highlight_report_selection())
        self._configure_tree_tags(self.report_tree)

        actions = ttk.Frame(card, style="Card.TFrame")
        actions.pack(fill="x", pady=(10, 0))
        ttk.Button(actions, text="Email selected", style="Primary.TButton", command=self.email_report_selection).pack(side="left")
        ttk.Button(actions, text="Highlight selected", command=self.highlight_report_selection).pack(side="left", padx=7)
        ttk.Button(actions, text="Export CSV", command=self.export_reports_csv).pack(side="left")
        ttk.Button(actions, text="Delete selected", command=self.delete_report_selection).pack(side="right", padx=(7, 0))
        ttk.Button(actions, text="Clear all reports", command=self.clear_all_reports).pack(side="right")
        self.refresh_report_table()

    def refresh_report_table(self) -> None:
        if not hasattr(self, "report_tree") or self.current_profile is None:
            return
        for item in self.report_tree.get_children():
            self.report_tree.delete(item)
        self.report_rows.clear()
        category_display = self.report_category_var.get() if hasattr(self, "report_category_var") else "All"
        category = next((key for key, label in CATEGORY_LABELS.items() if label == category_display), "")
        query = self.report_search_var.get().strip().lower() if hasattr(self, "report_search_var") else ""
        for detection in reversed(self.report_store.read(self.current_profile.profile_id)):
            if category and detection.category != category:
                continue
            haystack = f"{detection.text} {detection.reason} {detection.app_hint}".lower()
            if query and query not in haystack:
                continue
            iid = self.report_tree.insert(
                "",
                "end",
                values=(
                    detection.timestamp.replace("T", " "),
                    CATEGORY_LABELS.get(detection.category, detection.category),
                    f"{detection.risk:.0%}",
                    detection.text,
                    detection.app_hint or "Unknown",
                    detection.source,
                ),
                tags=(detection.category,),
            )
            self.report_rows[iid] = detection

    def _selected_report_detections(self) -> list[Detection]:
        if not hasattr(self, "report_tree"):
            return []
        return [self.report_rows[iid] for iid in self.report_tree.selection() if iid in self.report_rows]

    def email_report_selection(self) -> None:
        self._email_detections(self._selected_report_detections())

    def highlight_report_selection(self) -> None:
        detections = [item for item in self._selected_report_detections() if item.source == "screen"]
        if not detections:
            messagebox.showinfo("No screen location", "Select a saved screen detection with coordinates.")
            return
        self.overlay.show(detections, duration_ms=7000)

    def export_reports_csv(self) -> None:
        profile = self._require_profile()
        default_name = f"Safety_Judge_{profile.display_name.replace(' ', '_')}_{datetime.now():%Y%m%d}.csv"
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            initialfile=default_name,
            filetypes=[("CSV files", "*.csv")],
        )
        if not path:
            return
        count = self.report_store.export_csv(profile.profile_id, Path(path))
        messagebox.showinfo("Reports exported", f"Exported {count} report row(s).")

    def delete_report_selection(self) -> None:
        profile = self._require_profile()
        selected = self._selected_report_detections()
        if not selected:
            messagebox.showinfo("No selection", "Select one or more report rows.")
            return
        if not messagebox.askyesno("Delete reports?", f"Delete {len(selected)} selected report row(s)?"):
            return
        selected_objects = {id(item) for item in selected}
        all_reports = self.report_store.read(profile.profile_id)
        # Objects were reloaded, so match by a stable tuple.
        keys = {
            (item.timestamp, item.category, item.text, item.source, item.x, item.y)
            for item in selected
        }
        remaining = [
            item
            for item in all_reports
            if (item.timestamp, item.category, item.text, item.source, item.x, item.y) not in keys
        ]
        self.report_store.replace(profile.profile_id, remaining)
        self.refresh_report_table()

    def clear_all_reports(self) -> None:
        profile = self._require_profile()
        if not messagebox.askyesno("Clear all reports?", f"Delete every saved report for {profile.display_name}?"):
            return
        self.report_store.clear(profile.profile_id)
        self.refresh_report_table()

    # ---------- profile settings ----------
    def _build_profile_page(self) -> None:
        profile = self._require_profile()
        self._page_header(
            "Profile & Settings",
            "Edit this person's identity, email destination, scan behavior, warning categories, and background options.",
        )
        canvas = tk.Canvas(self.page_container, bg="#F8FAFC", highlightthickness=0)
        scrollbar = ttk.Scrollbar(self.page_container, orient="vertical", command=canvas.yview)
        form = ttk.Frame(canvas, style="App.TFrame")
        form.bind("<Configure>", lambda _e: canvas.configure(scrollregion=canvas.bbox("all")))
        form_window = canvas.create_window((0, 0), window=form, anchor="nw")
        canvas.bind("<Configure>", lambda event: canvas.itemconfigure(form_window, width=event.width))
        canvas.configure(yscrollcommand=scrollbar.set)
        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        identity = self._card(form, padding=16)
        identity.pack(fill="x", pady=(0, 12), padx=(0, 8))
        ttk.Label(identity, text="Profile", style="Heading.TLabel", background="#FFFFFF").grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 12))
        ttk.Label(identity, text="Name", background="#FFFFFF").grid(row=1, column=0, sticky="w")
        self.settings_name_var = tk.StringVar(value=profile.display_name)
        ttk.Entry(identity, textvariable=self.settings_name_var, width=32).grid(row=2, column=0, sticky="ew", padx=(0, 14), pady=(4, 10), ipady=5)
        ttk.Label(identity, text="Report email", background="#FFFFFF").grid(row=1, column=1, sticky="w")
        self.settings_email_var = tk.StringVar(value=profile.report_email)
        ttk.Entry(identity, textvariable=self.settings_email_var, width=38).grid(row=2, column=1, sticky="ew", pady=(4, 10), ipady=5)
        ttk.Label(identity, text="Avatar color", background="#FFFFFF").grid(row=3, column=0, sticky="w", pady=(4, 5))
        self.settings_avatar_color = profile.avatar_color
        color_frame = ttk.Frame(identity, style="Card.TFrame")
        color_frame.grid(row=4, column=0, columnspan=2, sticky="w")
        self.settings_color_buttons: list[tk.Button] = []
        for color in AVATAR_COLORS:
            button = tk.Button(
                color_frame,
                bg=color,
                activebackground=color,
                width=3,
                bd=3 if color == profile.avatar_color else 1,
                relief="sunken" if color == profile.avatar_color else "flat",
                command=lambda c=color: self._select_settings_color(c),
            )
            button.pack(side="left", padx=(0, 7))
            self.settings_color_buttons.append(button)
        identity.columnconfigure(0, weight=1)
        identity.columnconfigure(1, weight=1)

        scan = self._card(form, padding=16)
        scan.pack(fill="x", pady=(0, 12), padx=(0, 8))
        ttk.Label(scan, text="Scanning", style="Heading.TLabel", background="#FFFFFF").grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 12))
        ttk.Label(scan, text="Scan interval (seconds)", background="#FFFFFF").grid(row=1, column=0, sticky="w")
        self.settings_interval_var = tk.IntVar(value=profile.scan_interval_seconds)
        ttk.Spinbox(scan, from_=3, to=120, width=9, textvariable=self.settings_interval_var).grid(row=2, column=0, sticky="w", pady=(4, 12), ipady=4)
        ttk.Label(scan, text="Risk threshold", background="#FFFFFF").grid(row=1, column=1, sticky="w", padx=(20, 0))
        self.settings_threshold_var = tk.DoubleVar(value=profile.risk_threshold)
        threshold = ttk.Scale(scan, from_=0.5, to=0.99, variable=self.settings_threshold_var, orient="horizontal", length=230)
        threshold.grid(row=2, column=1, sticky="w", padx=(20, 0), pady=(4, 12))
        self.threshold_display_var = tk.StringVar(value=f"{profile.risk_threshold:.0%}")
        ttk.Label(scan, textvariable=self.threshold_display_var, background="#FFFFFF").grid(row=2, column=2, padx=(8, 0))
        threshold.configure(command=lambda value: self.threshold_display_var.set(f"{float(value):.0%}"))
        ttk.Label(scan, text="Repeat-warning cooldown (seconds)", background="#FFFFFF").grid(row=3, column=0, sticky="w")
        self.settings_cooldown_var = tk.IntVar(value=profile.repeat_cooldown_seconds)
        ttk.Spinbox(scan, from_=0, to=3600, width=9, textvariable=self.settings_cooldown_var).grid(row=4, column=0, sticky="w", pady=(4, 8), ipady=4)

        self.settings_highlights_var = tk.BooleanVar(value=profile.show_highlights)
        self.settings_notifications_var = tk.BooleanVar(value=profile.desktop_notifications)
        self.settings_autosave_var = tk.BooleanVar(value=profile.auto_save_reports)
        self.settings_autoemail_var = tk.BooleanVar(value=profile.auto_email_high_risk)
        self.settings_hidden_var = tk.BooleanVar(value=profile.protect_while_hidden)
        options = ttk.Frame(scan, style="Card.TFrame")
        options.grid(row=5, column=0, columnspan=4, sticky="w", pady=(5, 0))
        ttk.Checkbutton(options, text="Show screen highlights", variable=self.settings_highlights_var).grid(row=0, column=0, sticky="w", padx=(0, 24), pady=3)
        ttk.Checkbutton(options, text="Desktop notifications", variable=self.settings_notifications_var).grid(row=0, column=1, sticky="w", pady=3)
        ttk.Checkbutton(options, text="Automatically save detections", variable=self.settings_autosave_var).grid(row=1, column=0, sticky="w", padx=(0, 24), pady=3)
        ttk.Checkbutton(options, text="Automatically email high-risk detections", variable=self.settings_autoemail_var).grid(row=1, column=1, sticky="w", pady=3)
        ttk.Checkbutton(options, text="Keep protection running whenever app is hidden", variable=self.settings_hidden_var).grid(row=2, column=0, columnspan=2, sticky="w", pady=3)

        categories = self._card(form, padding=16)
        categories.pack(fill="x", pady=(0, 12), padx=(0, 8))
        ttk.Label(categories, text="Warning categories", style="Heading.TLabel", background="#FFFFFF").pack(anchor="w", pady=(0, 10))
        self.category_vars: dict[str, tk.BooleanVar] = {}
        grid = ttk.Frame(categories, style="Card.TFrame")
        grid.pack(fill="x")
        for index, category in enumerate(CATEGORIES):
            variable = tk.BooleanVar(value=category in profile.enabled_categories)
            self.category_vars[category] = variable
            ttk.Checkbutton(grid, text=CATEGORY_LABELS[category], variable=variable).grid(
                row=index // 3,
                column=index % 3,
                sticky="w",
                padx=(0, 36),
                pady=4,
            )

        email_card = self._card(form, padding=16)
        email_card.pack(fill="x", pady=(0, 12), padx=(0, 8))
        ttk.Label(email_card, text="Automatic email (optional)", style="Heading.TLabel", background="#FFFFFF").grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 6))
        ttk.Label(
            email_card,
            text="Email drafts need no password. Automatic sending uses SMTP and stores the app password in the system keychain.",
            style="Muted.TLabel",
            background="#FFFFFF",
            wraplength=800,
        ).grid(row=1, column=0, columnspan=4, sticky="w", pady=(0, 12))
        ttk.Label(email_card, text="SMTP server", background="#FFFFFF").grid(row=2, column=0, sticky="w")
        self.smtp_server_var = tk.StringVar(value=profile.smtp_server)
        ttk.Entry(email_card, textvariable=self.smtp_server_var, width=26).grid(row=3, column=0, sticky="w", pady=(4, 10), ipady=4)
        ttk.Label(email_card, text="Port", background="#FFFFFF").grid(row=2, column=1, sticky="w", padx=(14, 0))
        self.smtp_port_var = tk.IntVar(value=profile.smtp_port)
        ttk.Spinbox(email_card, from_=1, to=65535, width=8, textvariable=self.smtp_port_var).grid(row=3, column=1, sticky="w", padx=(14, 0), pady=(4, 10), ipady=4)
        ttk.Label(email_card, text="Sender email", background="#FFFFFF").grid(row=2, column=2, sticky="w", padx=(14, 0))
        self.smtp_sender_var = tk.StringVar(value=profile.smtp_sender)
        ttk.Entry(email_card, textvariable=self.smtp_sender_var, width=30).grid(row=3, column=2, sticky="w", padx=(14, 0), pady=(4, 10), ipady=4)
        ttk.Button(email_card, text="Save SMTP password to keychain", command=self.save_smtp_password).grid(row=4, column=0, columnspan=2, sticky="w")
        smtp_status = "Password stored" if self.mailer.has_smtp_password(profile) else "No password stored"
        ttk.Label(email_card, text=smtp_status, style="Muted.TLabel", background="#FFFFFF").grid(row=4, column=2, sticky="w", padx=(14, 0))

        background = self._card(form, padding=16)
        background.pack(fill="x", pady=(0, 12), padx=(0, 8))
        ttk.Label(background, text="Background mode", style="Heading.TLabel", background="#FFFFFF").pack(anchor="w")
        ttk.Label(
            background,
            text="The app keeps running after the window is hidden. On macOS, launch at login opens this exact profile and starts live protection.",
            style="Muted.TLabel",
            background="#FFFFFF",
            wraplength=820,
        ).pack(anchor="w", pady=(5, 10))
        background_buttons = ttk.Frame(background, style="Card.TFrame")
        background_buttons.pack(anchor="w")
        ttk.Button(background_buttons, text="Enable launch at login", command=self.enable_launch_at_login).pack(side="left")
        ttk.Button(background_buttons, text="Disable launch at login", command=self.disable_launch_at_login).pack(side="left", padx=7)

        save_row = ttk.Frame(form, style="App.TFrame")
        save_row.pack(fill="x", pady=(0, 20), padx=(0, 8))
        ttk.Button(save_row, text="Save all profile settings", style="Primary.TButton", command=self.save_profile_settings).pack(side="left")
        ttk.Button(save_row, text="Delete this profile", command=lambda: self.delete_profile(profile)).pack(side="right")

    def _select_settings_color(self, color: str) -> None:
        self.settings_avatar_color = color
        for button in self.settings_color_buttons:
            selected = str(button.cget("bg")) == color
            button.configure(bd=3 if selected else 1, relief="sunken" if selected else "flat")

    def save_profile_settings(self) -> None:
        profile = self._require_profile()
        name = " ".join(self.settings_name_var.get().split())
        email = self.settings_email_var.get().strip().lower()
        smtp_sender = self.smtp_sender_var.get().strip().lower()
        if not name:
            messagebox.showerror("Name required", "Enter a profile name.")
            return
        if not valid_email(email):
            messagebox.showerror("Invalid report email", "Enter a valid email or leave it blank.")
            return
        if smtp_sender and not valid_email(smtp_sender):
            messagebox.showerror("Invalid sender email", "Enter a valid SMTP sender email or leave it blank.")
            return
        enabled = [category for category, variable in self.category_vars.items() if variable.get()]
        if not enabled:
            messagebox.showerror("Choose a category", "Enable at least one warning category.")
            return
        try:
            profile.scan_interval_seconds = max(3, min(120, int(self.settings_interval_var.get())))
            profile.risk_threshold = max(0.5, min(0.99, float(self.settings_threshold_var.get())))
            profile.repeat_cooldown_seconds = max(0, min(3600, int(self.settings_cooldown_var.get())))
            profile.smtp_port = max(1, min(65535, int(self.smtp_port_var.get())))
        except (ValueError, tk.TclError):
            messagebox.showerror("Invalid number", "Check the interval, threshold, cooldown, and SMTP port.")
            return
        profile.display_name = name
        profile.report_email = email
        profile.avatar_color = self.settings_avatar_color
        profile.show_highlights = bool(self.settings_highlights_var.get())
        profile.desktop_notifications = bool(self.settings_notifications_var.get())
        profile.auto_save_reports = bool(self.settings_autosave_var.get())
        profile.auto_email_high_risk = bool(self.settings_autoemail_var.get())
        profile.protect_while_hidden = bool(self.settings_hidden_var.get())
        profile.enabled_categories = enabled
        profile.smtp_server = self.smtp_server_var.get().strip() or "smtp.gmail.com"
        profile.smtp_sender = smtp_sender
        self.profile_store.save_profile(profile)
        self.current_profile = profile
        self._build_shell()
        self.show_page("profile")
        self.status_var.set("Profile settings saved.")

    def save_smtp_password(self) -> None:
        profile = self._require_profile()
        sender = self.smtp_sender_var.get().strip().lower()
        if not sender or not valid_email(sender):
            messagebox.showerror("Sender email needed", "Enter a valid SMTP sender email first.")
            return
        profile.smtp_sender = sender
        profile.smtp_server = self.smtp_server_var.get().strip() or "smtp.gmail.com"
        try:
            profile.smtp_port = int(self.smtp_port_var.get())
        except (ValueError, tk.TclError):
            messagebox.showerror("Invalid port", "Enter a valid SMTP port.")
            return
        password = simpledialog.askstring(
            "SMTP app password",
            "Enter an SMTP app password. For Gmail, use a Google App Password—not your regular password.",
            show="•",
            parent=self.root,
        )
        if not password:
            return
        self.profile_store.save_profile(profile)
        try:
            self.mailer.save_smtp_password(profile, password)
        except Exception as exc:
            messagebox.showerror("Could not save password", str(exc))
            return
        messagebox.showinfo("Password saved", "The SMTP app password was stored in the system keychain.")
        self.show_page("profile")

    # ---------- email ----------
    def _email_detections(self, detections: list[Detection]) -> None:
        profile = self._require_profile()
        if not detections:
            messagebox.showinfo("Nothing to email", "Select or create at least one warning first.")
            return
        if not profile.report_email:
            messagebox.showinfo("Add an email", "Open Profile & Settings and add a report email first.")
            return
        use_smtp = bool(profile.smtp_sender and self.mailer.has_smtp_password(profile))
        if use_smtp:
            try:
                self.mailer.send_smtp(profile, detections)
                messagebox.showinfo("Report sent", f"Sent the report to {profile.report_email}.")
                return
            except Exception as exc:
                open_draft = messagebox.askyesno(
                    "Automatic sending failed",
                    f"{exc}\n\nOpen a prepared email draft instead?",
                )
                if not open_draft:
                    return
        try:
            self.mailer.open_draft(profile, detections)
            self.status_var.set("Opened a prepared report in the default email app.")
        except Exception as exc:
            messagebox.showerror("Could not prepare email", str(exc))

    def _auto_email(self, profile: Profile, detections: list[Detection]) -> None:
        try:
            self.mailer.send_smtp(profile, detections)
            self.result_queue.put(("status", f"High-risk report emailed to {profile.report_email}."))
        except Exception as exc:
            self.result_queue.put(("status", f"Detection saved; automatic email failed: {exc}"))

    # ---------- background / tray ----------
    def hide_to_background(self) -> None:
        if self.current_profile is not None and self.current_profile.protect_while_hidden:
            self.start_live()
        if self.current_profile is None or pystray is None:
            self.root.iconify()
            return
        self.root.withdraw()
        if self.tray_icon is not None:
            return
        image = Image.new("RGB", (64, 64), "white")
        draw = ImageDraw.Draw(image)
        draw.rounded_rectangle((6, 6, 58, 58), radius=13, fill="#0F172A")
        draw.rectangle((17, 29, 47, 35), fill="#22C55E")
        draw.rectangle((29, 17, 35, 47), fill="#22C55E")

        menu = pystray.Menu(
            pystray.MenuItem("Show Safety Judge", lambda _i, _m: self.result_queue.put(("ui_action", "restore"))),
            pystray.MenuItem("Start protection", lambda _i, _m: self.result_queue.put(("ui_action", "start"))),
            pystray.MenuItem("Scan now", lambda _i, _m: self.result_queue.put(("ui_action", "scan"))),
            pystray.MenuItem("Stop protection", lambda _i, _m: self.result_queue.put(("ui_action", "stop"))),
            pystray.MenuItem("Quit", lambda _i, _m: self.result_queue.put(("ui_action", "quit"))),
        )
        self.tray_icon = pystray.Icon("safety_judge_v13", image, APP_NAME, menu)

        def run_tray() -> None:
            try:
                self.tray_icon.run()
            except Exception as exc:
                self.tray_icon = None
                self.result_queue.put(("status", f"Menu-bar icon failed: {exc}"))
                self.result_queue.put(("ui_action", "restore"))

        threading.Thread(target=run_tray, daemon=True, name="SafetyJudgeTray").start()

    def restore_window(self) -> None:
        self.root.deiconify()
        self.root.lift()
        self.root.focus_force()

    def _start_background_mode(self) -> None:
        self.start_live()
        self.hide_to_background()

    def enable_launch_at_login(self) -> None:
        profile = self._require_profile()
        if platform.system() != "Darwin":
            messagebox.showinfo("macOS only", "Launch-at-login setup is currently implemented for macOS.")
            return
        app_path = (Path(__file__).resolve().parent.parent / "main.py").resolve()
        venv_python = app_path.parent / ".venv" / "bin" / "python3"
        python_path = venv_python if venv_python.exists() else Path(sys.executable)
        LAUNCH_AGENT.parent.mkdir(parents=True, exist_ok=True)
        plist = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.safetyjudge.v13</string>
  <key>ProgramArguments</key>
  <array>
    <string>{python_path}</string>
    <string>{app_path}</string>
    <string>--background</string>
    <string>--profile</string>
    <string>{profile.profile_id}</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><false/>
  <key>StandardOutPath</key><string>{APP_DIR / 'launch.log'}</string>
  <key>StandardErrorPath</key><string>{APP_DIR / 'launch-error.log'}</string>
</dict>
</plist>
'''
        LAUNCH_AGENT.write_text(plist, encoding="utf-8")
        subprocess.run(["launchctl", "unload", str(LAUNCH_AGENT)], capture_output=True)
        result = subprocess.run(["launchctl", "load", str(LAUNCH_AGENT)], capture_output=True, text=True)
        if result.returncode == 0:
            messagebox.showinfo("Enabled", f"{profile.display_name}'s profile will start protection when you log in to your Mac.")
        else:
            messagebox.showwarning("Launch file created", result.stderr.strip() or "macOS did not load the launch agent immediately.")

    def disable_launch_at_login(self) -> None:
        if LAUNCH_AGENT.exists():
            subprocess.run(["launchctl", "unload", str(LAUNCH_AGENT)], capture_output=True)
            LAUNCH_AGENT.unlink(missing_ok=True)
        messagebox.showinfo("Disabled", "Safety Judge will no longer launch automatically at login.")

    # ---------- shared helpers ----------
    def _configure_tree_tags(self, tree: ttk.Treeview) -> None:
        for category, color in CATEGORY_COLORS.items():
            tree.tag_configure(category, foreground=color)

    def _poll_queue(self) -> None:
        if self.shutting_down:
            return
        try:
            while True:
                kind, payload = self.result_queue.get_nowait()
                if kind == "status" and hasattr(self, "status_var"):
                    self.status_var.set(str(payload))
                elif kind == "clear_overlay":
                    self.overlay.clear()
                elif kind == "scan_result":
                    self._handle_scan_result(payload)  # type: ignore[arg-type]
                elif kind == "ui_action":
                    actions = {
                        "restore": self.restore_window,
                        "start": self.start_live,
                        "scan": self.scan_once,
                        "stop": self.stop_live,
                        "quit": self.quit_app,
                    }
                    action = actions.get(str(payload))
                    if action is not None:
                        action()
                elif kind == "error":
                    if hasattr(self, "status_var"):
                        self.status_var.set(f"Error: {payload}")
                    messagebox.showerror("Safety Judge error", str(payload))
        except queue.Empty:
            pass
        self.root.after(140, self._poll_queue)

    def _on_close(self) -> None:
        if self.current_profile is None:
            self.quit_app()
        else:
            self.hide_to_background()

    def quit_app(self) -> None:
        self.shutting_down = True
        self.stop_event.set()
        self.overlay.clear()
        if self.tray_icon is not None:
            try:
                self.tray_icon.stop()
            except Exception:
                pass
        self.root.destroy()
