#!/usr/bin/env python3
# ─────────────────────────────────────────────────────────────────────────────
# FORUM SCOUT — Multi-forum search tool (GTK3)
# Sources: Mabox · EndeavourOS · Manjaro · CachyOS · Garuda · RebornOS (Discourse)
#          Arch Wiki · Manjaro Wiki (MediaWiki) · Arch BBS (DuckDuckGo site-search)
# ─────────────────────────────────────────────────────────────────────────────

import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gtk, Gdk, GLib, Pango

import threading
import os
import json
import subprocess
import datetime
import urllib.parse
import locale
from html.parser import HTMLParser

try:
    import requests
except ImportError:
    print("Error: 'requests' not found. Install with: pip install requests")
    raise SystemExit(1)

# ─── Paths ────────────────────────────────────────────────────────────────────
CACHE_DIR     = os.path.expanduser("~/.cache/forum-scout")
BOOKMARK_FILE = os.path.join(CACHE_DIR, "bookmarks.log")
HISTORY_FILE  = os.path.join(CACHE_DIR, "history.log")
os.makedirs(CACHE_DIR, exist_ok=True)

CONFIG_DIR    = os.path.expanduser("~/.config/forum-scout")
SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json")
os.makedirs(CONFIG_DIR, exist_ok=True)

APP_TITLE    = "Forum Scout"
DEFAULT_HITS = 10

_VERSION = "0.3.4"
if _VERSION.startswith("__"):
    try:
        _VERSION = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION")).read().strip()
    except Exception:
        _VERSION = "dev"

# ─── Forum registry ───────────────────────────────────────────────────────────
FORUMS = [
    {"name": "Mabox",       "type": "discourse",  "url": "https://forum.maboxlinux.org",        "color": "#c49000", "on": True},
    {"name": "EndeavourOS", "type": "discourse",  "url": "https://forum.endeavouros.com",        "color": "#0891b2", "on": True},
    {"name": "Manjaro",     "type": "discourse",  "url": "https://forum.manjaro.org",            "color": "#16a34a", "on": True},
    {"name": "CachyOS",     "type": "discourse",  "url": "https://discuss.cachyos.org",          "color": "#7c3aed", "on": True},
    {"name": "Garuda",      "type": "discourse",  "url": "https://forum.garudalinux.org",        "color": "#db2777", "on": True},
    {"name": "RebornOS",    "type": "discourse",  "url": "https://rebornos.discourse.group",     "color": "#dc2626", "on": True},
    {"name": "Arch Wiki",    "type": "mediawiki",  "url": "https://wiki.archlinux.org",           "color": "#2563eb", "on": True},
    {"name": "Manjaro Wiki", "type": "mediawiki",  "url": "https://wiki.manjaro.org",             "color": "#22c55e", "on": True,  "page": "index.php?title={slug}"},
    {"name": "Arch BBS",     "type": "ddg",        "url": "bbs.archlinux.org",                    "color": "#ea580c", "on": False},
]

# ─── i18n ─────────────────────────────────────────────────────────────────────
_lang = (locale.getlocale()[0] or "en")[:2]

_EN_STRINGS = {
    "search_ph":   "Type keywords and press Enter or click Search…",
    "search_btn":  "Search",
    "tab_results": "Results",
    "tab_bm":      "Bookmarks",
    "tab_hist":    "History",
    "tab_about":   "About",
    "hits_label":  "Hits per source:",
    "ready":       "Ready.",
    "fetching":    "Fetching: '{}'…",
    "done":        "{} result(s) from {} source(s).",
    "no_results":  "No results.",
    "col_n":       "#",
    "col_forum":   "Forum",
    "col_title":   "Title",
    "col_link":    "Link",
    "col_time":    "Time",
    "col_query":   "Query",
    "ctx_open":      "Open in browser",
    "ctx_copy":      "Copy link",
    "ctx_bm":        "Add to bookmarks",
    "ctx_bm_remove": "Remove bookmark",
    "bm_open":     "Open",
    "bm_copy":     "Copy link",
    "bm_del":      "Remove",
    "bm_added":    "Bookmark added: {}",
    "bm_removed":  "Bookmark removed.",
    "hist_rerun":  "Re-run search",
    "hist_clear":  "Clear history",
    "via_ddg":     " ⁽ᴰᴰᴳ⁾",
    "col_date":    "Added",
}

def _load_translation(lang: str) -> dict:
    """Load translation from external JSON file; fall back to English."""
    search_dirs = [
        os.path.join(os.path.dirname(os.path.abspath(__file__)), "translations"),
        os.path.expanduser("~/.local/share/forum-scout/translations"),
        "/usr/share/forum-scout/translations",
    ]
    for lang_code in (lang, "en"):
        for d in search_dirs:
            path = os.path.join(d, f"{lang_code}.json")
            if os.path.exists(path):
                try:
                    with open(path, encoding="utf-8") as f:
                        data = json.load(f)
                        return {**_EN_STRINGS, **data}
                except Exception:
                    pass
    return _EN_STRINGS

S = _load_translation(_lang)

# ─── HTTP session ─────────────────────────────────────────────────────────────
_session = requests.Session()
_session.headers["User-Agent"] = (
    f"forum-scout/{_VERSION} (https://github.com/musqz/forum-scout)"
)

# ─── DuckDuckGo HTML parser ───────────────────────────────────────────────────
class _DDGParser(HTMLParser):
    """Minimal parser that extracts result links from DDG HTML response."""

    def __init__(self):
        super().__init__()
        self.results: list[tuple[str, str]] = []
        self._link: str | None = None
        self._title_buf: list[str] = []
        self._in_a: bool = False

    def handle_starttag(self, tag, attrs):
        if tag != "a":
            return
        d = dict(attrs)
        cls = d.get("class", "")
        if "result__a" not in cls:
            return
        href = d.get("href", "")
        # DDG wraps real URLs in a redirect — unwrap if present
        if "uddg=" in href:
            qs = urllib.parse.parse_qs(urllib.parse.urlparse(href).query)
            href = urllib.parse.unquote(qs.get("uddg", [""])[0])
        self._link = href
        self._title_buf = []
        self._in_a = True

    def handle_endtag(self, tag):
        if tag == "a" and self._in_a:
            self._in_a = False
            title = "".join(self._title_buf).strip()
            if self._link and title:
                self.results.append((title, self._link))
            self._link = None
            self._title_buf = []

    def handle_data(self, data):
        if self._in_a:
            self._title_buf.append(data)


# ─── Fetcher functions ────────────────────────────────────────────────────────
def _fmt_date(iso: str) -> str:
    """Parse an ISO-8601 timestamp and return YYYY-MM-DD, or '' on failure."""
    try:
        # Replace trailing Z for Python < 3.11 compat
        return datetime.datetime.fromisoformat(
            iso.replace("Z", "+00:00")
        ).strftime("%Y-%m-%d")
    except Exception:
        return ""


def _fetch_discourse(forum: dict, query: str, hits: int) -> list[tuple[str, str, str]]:
    url = f"{forum['url']}/search.json"
    try:
        r = _session.get(url, params={"q": query}, timeout=9)
        data = r.json()
        out = []
        base = forum["url"]
        for t in data.get("topics", [])[:hits]:
            link = f"{base}/t/{t['slug']}/{t['id']}"
            date = _fmt_date(t.get("created_at", ""))
            out.append((t["title"], link, date))
        return out
    except Exception:
        return []


def _fetch_mediawiki(forum: dict, query: str, hits: int) -> list[tuple[str, str, str]]:
    try:
        r = _session.get(
            f"{forum['url']}/api.php",
            params={
                "action": "query",
                "list": "search",
                "srsearch": query,
                "srlimit": hits,
                "format": "json",
            },
            timeout=9,
        )
        data = r.json()
        out = []
        base = forum["url"]
        page_tpl = forum.get("page", "title/{slug}")
        for item in data.get("query", {}).get("search", []):
            slug = urllib.parse.quote(item["title"].replace(" ", "_"))
            date = _fmt_date(item.get("timestamp", ""))
            out.append((item["title"], f"{base}/{page_tpl.format(slug=slug)}", date))
        return out
    except Exception:
        return []


def _fetch_ddg(forum: dict, query: str, hits: int) -> list[tuple[str, str, str]]:
    site = forum["url"]
    try:
        r = requests.get(
            "https://html.duckduckgo.com/html/",
            params={"q": f"site:{site} {query}"},
            headers={"User-Agent": _session.headers["User-Agent"]},
            timeout=12,
        )
        parser = _DDGParser()
        parser.feed(r.text)
        out = []
        for title, link in parser.results:
            if site in link:
                out.append((title, link, "—"))   # DDG returns no date
                if len(out) >= hits:
                    break
        return out
    except Exception:
        return []


_FETCHERS = {
    "discourse":  _fetch_discourse,
    "mediawiki":  _fetch_mediawiki,
    "ddg":        _fetch_ddg,
}

# ─── Live suggestion fetchers ─────────────────────────────────────────────────
_SUGGEST_LIMIT   = 5     # results per forum for suggestions
_SUGGEST_TIMEOUT = 4     # seconds — must be fast or feel broken
_SUGGEST_DELAY   = 400   # ms debounce — wait this long after last keystroke


def _suggest_discourse(forum: dict, term: str) -> list[str]:
    try:
        r = _session.get(
            f"{forum['url']}/search.json",
            params={"q": term},
            timeout=_SUGGEST_TIMEOUT,
        )
        data = r.json()
        return [t["title"] for t in data.get("topics", [])[:_SUGGEST_LIMIT]]
    except Exception:
        return []


def _suggest_mediawiki(forum: dict, term: str) -> list[str]:
    """Uses MediaWiki opensearch — the purpose-built typeahead endpoint."""
    try:
        r = _session.get(
            f"{forum['url']}/api.php",
            params={
                "action":    "opensearch",
                "search":    term,
                "limit":     _SUGGEST_LIMIT,
                "namespace": 0,
                "format":    "json",
            },
            timeout=_SUGGEST_TIMEOUT,
        )
        data = r.json()
        return list(data[1])[:_SUGGEST_LIMIT] if len(data) > 1 else []
    except Exception:
        return []


_SUGGESTERS = {
    "discourse": _suggest_discourse,
    "mediawiki": _suggest_mediawiki,
    # "ddg" intentionally omitted — too slow for typeahead
}

# Forum name → color, used for consistent coloring across all tabs
_FORUM_COLOR: dict[str, str] = {f["name"]: f["color"] for f in FORUMS}

# ─── Autocomplete seed terms ──────────────────────────────────────────────────
# Common Linux forum search topics shown to new users before history builds up
_SEED_TERMS = [
    # Boot & system
    "black screen after update", "black screen on boot", "boot loop",
    "grub not found", "uefi boot", "initramfs error", "kernel panic",
    "slow boot", "systemd timeout", "failed to start",
    # Hardware
    "nvidia driver", "amd gpu", "intel graphics", "screen tearing",
    "wifi not working", "bluetooth not working", "no sound", "audio crackling",
    "touchpad not working", "webcam not detected", "dual monitor setup",
    "monitor not detected", "hdmi no signal",
    # Package management
    "pacman error", "yay aur", "package conflict", "dependency error",
    "signature invalid", "keyring update", "partial upgrade",
    # Desktop & compositor
    "picom animations", "picom vsync", "openbox keybind", "tint2 config",
    "conky not showing", "jgmenu setup", "wayland issues", "xorg crash",
    # Network
    "networkmanager wifi", "ethernet not working", "vpn setup", "dns slow",
    # Suspend & power
    "suspend not working", "hibernate resume", "wake from sleep",
    "screen blank after suspend", "battery drain",
    # Apps
    "firefox slow", "steam not launching", "flatpak permission",
    "wine not working", "virtualbox error",
]


# ─── CSS ──────────────────────────────────────────────────────────────────────
# ─── Main window ──────────────────────────────────────────────────────────────
class ScoutWindow(Gtk.Window):

    def __init__(self):
        super().__init__(title=APP_TITLE)
        self.set_default_size(960, 640)
        self.set_size_request(820, 400)
        self.set_border_width(0)

        self._busy               = False
        self._results            = []
        self._bm_data            = []     # master list for bookmark filter/sort
        self._suggest_timer      = None   # GLib debounce timer id
        self._suggest_token      = 0      # incremented per request to cancel stale responses
        self._live_count         = 0      # live suggestions currently at front of completion store
        self._forums_bar_visible = True

        self._build_ui()
        self._load_settings()          # apply persisted prefs after widgets exist

        self.connect("key-press-event", self._on_key_press)
        self.connect("delete-event",    self._on_delete)
        self.connect("destroy", Gtk.main_quit)
        self.show_all()
        if not self._forums_bar_visible:
            self._forums_bar.hide()
            self._forums_toggle.set_label("Forums ▸")

    # ── UI ────────────────────────────────────────────────────────────────────
    def _build_ui(self):
        root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.add(root)

        root.pack_start(self._build_topbar(),  False, False, 0)
        root.pack_start(self._build_notebook(), True,  True,  0)
        root.pack_start(self._build_statusbar(), False, False, 0)

    # ── Top bar ───────────────────────────────────────────────────────────────
    def _build_topbar(self):
        bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)

        # Row 1 — search entry + button + spinner
        row1 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        bar.pack_start(row1, False, False, 0)

        self._entry = Gtk.Entry()
        self._entry.set_placeholder_text(S["search_ph"])
        self._entry.connect("activate", self._on_search)
        self._entry.connect("changed",  self._on_entry_changed)
        self._entry.set_completion(self._build_completion())
        row1.pack_start(self._entry, True, True, 0)

        self._btn = Gtk.Button(label=S["search_btn"])
        self._btn.connect("clicked", self._on_search)
        row1.pack_start(self._btn, False, False, 0)

        self._spinner = Gtk.Spinner()
        row1.pack_start(self._spinner, False, False, 0)

        self._forums_toggle = Gtk.Button(label="Forums ▾")
        self._forums_toggle.set_tooltip_text("Show/hide forums bar (Ctrl+F)")
        self._forums_toggle.connect("clicked", self._toggle_forums_bar)
        row1.pack_start(self._forums_toggle, False, False, 0)

        # Forums bar — rows 2 and 3, toggled as a unit
        self._forums_bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        bar.pack_start(self._forums_bar, False, False, 0)

        # Row 2 — all forum checkboxes
        row2 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self._forums_bar.pack_start(row2, False, False, 0)

        # Row 3 — hits spinner
        row3 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self._forums_bar.pack_start(row3, False, False, 0)

        self._checks: dict[str, Gtk.CheckButton] = {}
        for f in FORUMS:
            cb = Gtk.CheckButton()
            cb.set_active(f["on"])
            lbl = Gtk.Label()
            lbl.set_markup(f'<span foreground="{f["color"]}" weight="bold">{f["name"]}</span>')
            cb.add(lbl)
            self._checks[f["name"]] = cb
            if f["type"] == "discourse":
                row2.pack_start(cb, False, False, 0)
            else:
                row3.pack_start(cb, False, False, 0)

        row3.pack_start(Gtk.Label(), True, True, 0)  # spacer

        hits_label = Gtk.Label(label=S["hits_label"])
        hits_label.get_style_context().add_class("dim-label")
        row3.pack_start(hits_label, False, False, 0)

        adj = Gtk.Adjustment(value=DEFAULT_HITS, lower=1, upper=50, step_increment=1, page_increment=5)
        self._hits_spin = Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=0)
        row3.pack_start(self._hits_spin, False, False, 0)

        return bar

    def _toggle_forums_bar(self, *_):
        self._forums_bar_visible = not self._forums_bar_visible
        self._forums_bar.set_visible(self._forums_bar_visible)
        self._forums_toggle.set_label("Forums ▾" if self._forums_bar_visible else "Forums ▸")

    # ── Notebook ──────────────────────────────────────────────────────────────
    def _build_notebook(self):
        self._notebook = Gtk.Notebook()

        self._res_tab_label = Gtk.Label(label=S["tab_results"])
        self._notebook.append_page(self._build_results_tab(), self._res_tab_label)
        self._notebook.append_page(self._build_bm_tab(),      Gtk.Label(label=S["tab_bm"]))
        self._notebook.append_page(self._build_hist_tab(),    Gtk.Label(label=S["tab_hist"]))
        self._notebook.append_page(self._build_about_tab(),  Gtk.Label(label=S["tab_about"]))

        return self._notebook

    # ── Results tab ───────────────────────────────────────────────────────────
    def _build_results_tab(self):
        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)

        # columns: idx(str) | forum_display(str) | forum_color(str) | title(str) | link(str) | date(str)
        # idx is "★" when the URL is already bookmarked, otherwise a sequence number
        self._res_store = Gtk.ListStore(str, str, str, str, str, str)
        tv = Gtk.TreeView(model=self._res_store)
        tv.connect("row-activated",      self._on_result_activate)
        tv.connect("button-press-event", self._on_result_rclick)
        tv.connect("motion-notify-event", self._on_result_hover)
        tv.connect("leave-notify-event",  self._on_result_hover_leave)
        self._res_view = tv

        def _col(title, col_idx, expand=False, fixed_w=None, weight=None):
            r = Gtk.CellRendererText()
            r.set_property("ellipsize", Pango.EllipsizeMode.END)
            if weight:
                r.set_property("weight", weight)
            kw = {"text": col_idx, "foreground": 2} if col_idx == 1 else {"text": col_idx}
            c = Gtk.TreeViewColumn(title, r, **kw)
            if expand:
                c.set_expand(True)
                c.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
            elif fixed_w:
                c.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
                c.set_fixed_width(fixed_w)
            tv.append_column(c)
            return c

        self._col_res_n     = _col(S["col_n"],     0, fixed_w=28)
        self._col_res_forum = _col(S["col_forum"], 1, fixed_w=150)   # foreground=col 2
        _col(S["col_title"], 3, expand=True, weight=Pango.Weight.SEMIBOLD)
        self._col_res_date  = _col(S["col_date"],  5, fixed_w=100)
        # link (col 4) kept in store for double-click/right-click, not shown

        # Sortable columns — click header to toggle sort
        self._col_res_forum.set_sort_column_id(1)
        self._col_res_date.set_sort_column_id(5)
        self._res_store.set_sort_func(1, self._sort_forum)
        self._res_store.set_sort_func(5, self._sort_date)
        self._res_store.set_sort_column_id(5, Gtk.SortType.DESCENDING)  # default: newest first

        sw.add(tv)
        return sw

    # ── Bookmarks tab ─────────────────────────────────────────────────────────
    def _build_bm_tab(self):
        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        vbox.set_border_width(6)

        # Filter entry
        self._bm_filter_entry = Gtk.SearchEntry()
        self._bm_filter_entry.set_placeholder_text("Filter bookmarks…")
        self._bm_filter_entry.connect("changed", self._on_bm_filter_changed)
        vbox.pack_start(self._bm_filter_entry, False, False, 0)

        tb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        vbox.pack_start(tb, False, False, 0)
        for label, cb in [
            (S["bm_open"], self._bm_open),
            (S["bm_copy"], self._bm_copy),
            (S["bm_del"],  self._bm_remove),
        ]:
            btn = Gtk.Button(label=label)
            btn.connect("clicked", cb)
            tb.pack_start(btn, False, False, 0)

        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)

        # forum(str) | title(str) | link(str) [hidden] | date(str) | color(str) [hidden]
        self._bm_store = Gtk.ListStore(str, str, str, str, str)
        tv = Gtk.TreeView(model=self._bm_store)
        tv.connect("row-activated", self._on_bm_activate)
        self._bm_view = tv

        # Forum — fixed width, colored (foreground bound to col 4)
        r = Gtk.CellRendererText()
        r.set_property("ellipsize", Pango.EllipsizeMode.END)
        r.set_property("weight", Pango.Weight.BOLD)
        c = Gtk.TreeViewColumn(S["col_forum"], r, text=0, foreground=4)
        c.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
        c.set_fixed_width(130)
        tv.append_column(c)
        self._col_bm_forum = c
        # Title — expands
        r = Gtk.CellRendererText()
        r.set_property("ellipsize", Pango.EllipsizeMode.END)
        r.set_property("weight", Pango.Weight.SEMIBOLD)
        c = Gtk.TreeViewColumn(S["col_title"], r, text=1)
        c.set_expand(True)
        c.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
        tv.append_column(c)
        # Date — fixed width (index 3; index 2 = link, hidden)
        r = Gtk.CellRendererText()
        c = Gtk.TreeViewColumn(S["col_date"], r, text=3)
        c.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
        c.set_fixed_width(145)
        tv.append_column(c)
        self._col_bm_date = c

        # Sortable columns
        self._col_bm_forum.set_sort_column_id(0)
        self._col_bm_date.set_sort_column_id(3)
        self._bm_store.set_sort_func(0, self._sort_bm_forum)
        self._bm_store.set_sort_func(3, self._sort_bm_date)
        self._bm_store.set_sort_column_id(3, Gtk.SortType.DESCENDING)

        sw.add(tv)
        vbox.pack_start(sw, True, True, 0)
        self._load_bookmarks()
        return vbox

    # ── History tab ───────────────────────────────────────────────────────────
    def _build_hist_tab(self):
        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        vbox.set_border_width(6)

        tb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        vbox.pack_start(tb, False, False, 0)
        for label, cb in [
            (S["hist_rerun"],  self._hist_rerun),
            (S["hist_clear"],  self._hist_clear),
        ]:
            btn = Gtk.Button(label=label)
            btn.connect("clicked", cb)
            tb.pack_start(btn, False, False, 0)

        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)

        # time(str) | query(str)
        self._hist_store = Gtk.ListStore(str, str)
        tv = Gtk.TreeView(model=self._hist_store)
        tv.connect("row-activated", self._on_hist_activate)
        self._hist_view = tv

        self._col_hist_time = None
        for i, (title, fixed_w, expands) in enumerate([
            (S["col_time"],  160, False),
            (S["col_query"], 0,   True),
        ]):
            r = Gtk.CellRendererText()
            r.set_property("ellipsize", Pango.EllipsizeMode.END)
            c = Gtk.TreeViewColumn(title, r, text=i)
            if expands:
                c.set_expand(True)
                c.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
            else:
                c.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
                c.set_fixed_width(fixed_w)
                self._col_hist_time = c
            tv.append_column(c)

        sw.add(tv)
        vbox.pack_start(sw, True, True, 0)
        self._load_history()
        return vbox

    # ── About tab ─────────────────────────────────────────────────────────────
    def _build_about_tab(self):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        inner.set_border_width(32)
        inner.set_valign(Gtk.Align.CENTER)
        inner.set_halign(Gtk.Align.CENTER)
        outer.set_valign(Gtk.Align.CENTER)

        def _lbl(text, bold=False, size=None, wrap=False):
            l = Gtk.Label(label=text)
            l.set_selectable(True)
            if wrap:
                l.set_line_wrap(True)
                l.set_max_width_chars(60)
                l.set_justify(Gtk.Justification.CENTER)
            attrs = Pango.AttrList()
            if bold:
                attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
            if size:
                attrs.insert(Pango.attr_scale_new(size))
            l.set_attributes(attrs)
            return l

        inner.pack_start(_lbl("Forum Scout",      bold=True, size=2.0),    False, False, 0)
        inner.pack_start(_lbl(f"v{_VERSION}",  size=1.1),                 False, False, 4)
        inner.pack_start(Gtk.Separator(),                                  False, False, 6)
        inner.pack_start(_lbl(
            "Searches Arch-based forums simultaneously\n"
            "and presents results sorted by date.",
            wrap=True
        ),                                                                  False, False, 0)
        inner.pack_start(Gtk.Separator(),                                  False, False, 6)

        repo_lbl = Gtk.Label()
        repo_lbl.set_markup('<a href="https://github.com/musqz/forum-scout">github.com/musqz/forum-scout</a>')
        repo_lbl.set_selectable(True)
        inner.pack_start(repo_lbl,                                         False, False, 0)
        inner.pack_start(_lbl("Author: musqz"),                            False, False, 0)
        inner.pack_start(_lbl("License: MIT"),                             False, False, 0)
        inner.pack_start(Gtk.Separator(),                                  False, False, 6)
        inner.pack_start(_lbl("Keyboard shortcuts", bold=True),            False, False, 0)

        grid = Gtk.Grid(column_spacing=24, row_spacing=4)
        grid.set_halign(Gtk.Align.CENTER)
        for row, (key, desc) in enumerate((
            ("Ctrl+L",   "Focus search bar"),
            ("Ctrl+F",   "Toggle forums bar"),
            ("F5",       "Re-run last search"),
            ("Escape",   "Clear search"),
        )):
            key_lbl = _lbl(key, bold=True)
            key_lbl.set_halign(Gtk.Align.END)
            grid.attach(key_lbl,      0, row, 1, 1)
            grid.attach(_lbl(desc),   1, row, 1, 1)
        inner.pack_start(grid,                                             False, False, 4)

        outer.pack_start(inner, True, True, 0)
        return outer

    # ── Status bar ────────────────────────────────────────────────────────────
    def _build_statusbar(self):
        self._statusbar = Gtk.Statusbar()
        self._statusbar.get_style_context().add_class("statusbar")
        self._ctx       = self._statusbar.get_context_id("main")
        self._hover_ctx = self._statusbar.get_context_id("hover")
        self._hover_link = None
        self._set_status(S["ready"])

        self._suggest_lbl = Gtk.Label(label="loading suggestions…")
        self._suggest_lbl.set_margin_end(8)
        self._suggest_lbl.set_no_show_all(True)

        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        box.pack_start(self._statusbar,   True,  True,  0)
        box.pack_end(self._suggest_lbl,   False, False, 0)
        return box

    def _set_status(self, msg: str):
        self._statusbar.pop(self._ctx)
        self._statusbar.push(self._ctx, msg)

    # ── Search logic ─────────────────────────────────────────────────────────
    def _on_search(self, *_):
        query = self._entry.get_text().strip()
        if not query or self._busy:
            return

        active = [f for f in FORUMS if self._checks[f["name"]].get_active()]
        if not active:
            self._set_status(S["no_results"])
            return

        self._busy = True
        self._btn.set_sensitive(False)
        self._spinner.start()
        self._res_store.clear()
        self._results       = []
        self._search_total  = len(active)
        self._search_done   = 0
        self._search_idx    = 0
        self._ddg_empty     = []
        self._search_query  = query
        self._tab_switched  = False
        # snapshot of bookmarked URLs so _add_forum_results can mark them
        self._bm_urls = self._bookmarked_urls()
        self._set_status(S["fetching"].format(query))
        self._log_history(query)

        hits = int(self._hits_spin.get_value())
        for f in active:
            threading.Thread(
                target=self._fetch_one_forum,
                args=(query, hits, f),
                daemon=True,
            ).start()

    def _fetch_one_forum(self, query: str, hits: int, forum: dict):
        items   = _FETCHERS[forum["type"]](forum, query, hits)
        via_ddg = forum["type"] == "ddg"
        results = [
            (forum["name"], forum["color"], title, link, date, via_ddg)
            for title, link, date in items
        ]
        ddg_empty = forum["name"] if via_ddg and not items else None
        GLib.idle_add(self._add_forum_results, results, ddg_empty)

    def _add_forum_results(self, new_results: list, ddg_empty_name):
        for forum, color, title, link, date, via_ddg in new_results:
            self._search_idx += 1
            display = forum + (S["via_ddg"] if via_ddg else "")
            marker  = "★" if link in self._bm_urls else ""
            self._res_store.append([marker, display, color, title, link, date])
            self._results.append((self._search_idx, forum, color, title, link, date, via_ddg))

        if ddg_empty_name:
            self._ddg_empty.append(ddg_empty_name)

        self._search_done += 1
        total = len(self._results)
        self._res_tab_label.set_text(f"{S['tab_results']} ({total})")

        if self._search_done < self._search_total:
            self._set_status(
                f"{S['fetching'].format(self._search_query)}"
                f"  ({self._search_done}/{self._search_total})"
            )
        else:
            sources = len({r[1] for r in self._results})
            status  = S["done"].format(total, sources)
            if self._ddg_empty:
                status += "  ·  " + ", ".join(self._ddg_empty) + ": no results (DDG — try again)"
            self._set_status(status)
            self._spinner.stop()
            self._btn.set_sensitive(True)
            self._busy = False

        if new_results and not self._tab_switched:
            self._notebook.set_current_page(0)
            self._tab_switched = True

    # ── Sort helpers ──────────────────────────────────────────────────────────
    @staticmethod
    def _sort_forum(model, iter_a, iter_b, _data):
        a = model.get_value(iter_a, 1)
        b = model.get_value(iter_b, 1)
        return (a > b) - (a < b)

    @staticmethod
    def _sort_date(model, iter_a, iter_b, _data):
        a = model.get_value(iter_a, 5)
        b = model.get_value(iter_b, 5)
        a = "0000-00-00" if a in ("—", "") else a
        b = "0000-00-00" if b in ("—", "") else b
        return (a > b) - (a < b)

    @staticmethod
    def _sort_bm_forum(model, iter_a, iter_b, _data):
        a = model.get_value(iter_a, 0)
        b = model.get_value(iter_b, 0)
        return (a > b) - (a < b)

    @staticmethod
    def _sort_bm_date(model, iter_a, iter_b, _data):
        a = model.get_value(iter_a, 3)
        b = model.get_value(iter_b, 3)
        return (a > b) - (a < b)

    # ── Result interactions ───────────────────────────────────────────────────
    def _on_result_activate(self, _view, path, _col):
        it = self._res_store.get_iter(path)
        self._open_url(self._res_store.get_value(it, 4))

    def _on_result_rclick(self, view, event):
        if event.button != 3:
            return False
        info = view.get_path_at_pos(int(event.x), int(event.y))
        if not info:
            return False
        path = info[0]
        view.get_selection().select_path(path)
        it = self._res_store.get_iter(path)
        forum = self._res_store.get_value(it, 1)
        title = self._res_store.get_value(it, 3)
        link  = self._res_store.get_value(it, 4)

        already_bm = link in {r[2] for r in self._bm_data}
        bm_label   = S["ctx_bm_remove"] if already_bm else S["ctx_bm"]
        bm_action  = (lambda *_: self._bm_remove_by_link(link)) if already_bm else (lambda *_: self._add_bookmark(forum, title, link))

        menu = Gtk.Menu()
        actions = [
            (S["ctx_open"], lambda *_: self._open_url(link)),
            (S["ctx_copy"], lambda *_: self._copy(link)),
            (bm_label,      bm_action),
        ]
        for label, cb in actions:
            item = Gtk.MenuItem(label=label)
            item.connect("activate", cb)
            menu.append(item)
        menu.show_all()
        menu.popup_at_pointer(event)
        return True

    # ── Bookmarks ─────────────────────────────────────────────────────────────
    def _add_bookmark(self, forum: str, title: str, link: str):
        date  = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
        color = _FORUM_COLOR.get(forum, "#cdd6f4")
        with open(BOOKMARK_FILE, "a") as f:
            f.write(f"[{forum}] {title} - {link}|||{date}\n")
        self._bm_data.append([forum, title, link, date, color])
        self._bm_refresh()
        self._mark_result_bookmarked(link)
        self._set_status(S["bm_added"].format(title))

    def _mark_result_bookmarked(self, link: str):
        it = self._res_store.get_iter_first()
        while it:
            if self._res_store.get_value(it, 4) == link:
                self._res_store.set_value(it, 0, "★")
            it = self._res_store.iter_next(it)

    def _mark_result_unbookmarked(self, link: str):
        it = self._res_store.get_iter_first()
        while it:
            if self._res_store.get_value(it, 4) == link:
                self._res_store.set_value(it, 0, "")
            it = self._res_store.iter_next(it)

    def _bookmarked_urls(self) -> set:
        return {row[2] for row in self._bm_data}

    def _bm_refresh(self):
        text = self._bm_filter_entry.get_text().strip().lower()
        self._bm_store.clear()
        for row in self._bm_data:
            forum, title, link, date, color = row
            if not text or text in forum.lower() or text in title.lower() or text in link.lower():
                self._bm_store.append(row)

    def _on_bm_filter_changed(self, _entry):
        self._bm_refresh()

    def _load_bookmarks(self):
        self._bm_data = []
        if not os.path.exists(BOOKMARK_FILE):
            self._bm_refresh()
            return
        with open(BOOKMARK_FILE) as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    forum = line.split("]")[0].lstrip("[")
                    rest  = line.split("] ", 1)[1]
                    if "|||" in rest:
                        body, date = rest.rsplit("|||", 1)
                    else:
                        body, date = rest, ""
                    cut = body.rfind(" - http")
                    if cut == -1:
                        cut = body.rfind(" - ")
                    title = body[:cut]
                    link  = body[cut + 3:]
                    color = _FORUM_COLOR.get(forum, "#cdd6f4")
                    self._bm_data.append([forum, title, link, date, color])
                except Exception:
                    pass
        self._bm_refresh()

    def _bm_open(self, *_):
        it = self._bm_selected()
        if it:
            self._open_url(self._bm_store.get_value(it, 2))

    def _bm_copy(self, *_):
        it = self._bm_selected()
        if it:
            self._copy(self._bm_store.get_value(it, 2))

    def _bm_remove(self, *_):
        it = self._bm_selected()
        if not it:
            return
        link = self._bm_store.get_value(it, 2)
        self._bm_data = [r for r in self._bm_data if r[2] != link]
        self._bm_refresh()
        with open(BOOKMARK_FILE, "w") as fh:
            for f, t, l, d, _ in self._bm_data:
                fh.write(f"[{f}] {t} - {l}|||{d}\n")
        self._set_status(S["bm_removed"])

    def _bm_remove_by_link(self, link: str):
        self._bm_data = [r for r in self._bm_data if r[2] != link]
        self._bm_refresh()
        self._mark_result_unbookmarked(link)
        with open(BOOKMARK_FILE, "w") as fh:
            for f, t, l, d, _ in self._bm_data:
                fh.write(f"[{f}] {t} - {l}|||{d}\n")
        self._set_status(S["bm_removed"])

    def _bm_selected(self):
        _, it = self._bm_view.get_selection().get_selected()
        return it

    def _on_bm_activate(self, _view, path, _col):
        it = self._bm_store.get_iter(path)
        self._open_url(self._bm_store.get_value(it, 2))

    # ── History ───────────────────────────────────────────────────────────────
    def _log_history(self, query: str):
        ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        with open(HISTORY_FILE, "a") as f:
            f.write(f"{ts} - {query}\n")
        self._hist_store.prepend([ts, query])
        self._completion_add(query)   # make it available for next search

    def _load_history(self):
        self._hist_store.clear()
        if not os.path.exists(HISTORY_FILE):
            return
        rows = []
        with open(HISTORY_FILE) as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    ts, query = line.split(" - ", 1)
                    rows.append([ts, query])
                except Exception:
                    pass
        # reverse so newest appears first
        for row in reversed(rows):
            self._hist_store.append(row)

    def _hist_rerun(self, *_):
        _, it = self._hist_view.get_selection().get_selected()
        if it:
            query = self._hist_store.get_value(it, 1)
            self._entry.set_text(query)
            self._on_search()
            self._notebook.set_current_page(0)

    def _hist_clear(self, *_):
        self._hist_store.clear()
        open(HISTORY_FILE, "w").close()
        # Reset completion to seeds only
        self._completion_store.clear()
        self._completion_seen.clear()
        for term in _SEED_TERMS:
            key = term.lower()
            if key not in self._completion_seen:
                self._completion_seen.add(key)
                self._completion_store.append([term])

    def _on_hist_activate(self, _view, path, _col):
        it = self._hist_store.get_iter(path)
        query = self._hist_store.get_value(it, 1)
        self._entry.set_text(query)
        self._on_search()
        self._notebook.set_current_page(0)

    # ── Autocomplete ──────────────────────────────────────────────────────────
    def _build_completion(self) -> Gtk.EntryCompletion:
        """Build EntryCompletion from seed terms + existing history (deduplicated)."""
        self._completion_store = Gtk.ListStore(str)
        self._completion_seen: set[str] = set()

        # Seeds first — provide value even on a fresh install
        for term in _SEED_TERMS:
            key = term.lower()
            if key not in self._completion_seen:
                self._completion_seen.add(key)
                self._completion_store.append([term])

        # History on top of seeds — personal terms override nothing, just dedup
        if os.path.exists(HISTORY_FILE):
            try:
                with open(HISTORY_FILE) as f:
                    for line in f:
                        line = line.strip()
                        if not line:
                            continue
                        try:
                            _, query = line.split(" - ", 1)
                            self._completion_add(query)
                        except Exception:
                            pass
            except Exception:
                pass

        completion = Gtk.EntryCompletion()
        completion.set_model(self._completion_store)
        completion.set_minimum_key_length(2)
        completion.set_inline_completion(False)
        completion.set_popup_set_width(True)
        completion.set_popup_completion(True)
        completion.set_match_func(self._completion_match)
        completion.connect("match-selected", self._on_completion_selected)

        # Ellipsize long titles so the popup never exceeds the window width
        renderer = Gtk.CellRendererText()
        renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
        completion.pack_start(renderer, True)
        completion.add_attribute(renderer, "text", 0)

        return completion

    def _completion_match(self, completion, typed_key, it):
        """
        Show a suggestion if every typed word appears somewhere in the title.
        'typed_key' is already lowercased by GTK.
        Example: typing 'screen nvidia' matches 'Black screen after NVIDIA update'.
        """
        text = self._completion_store.get_value(it, 0).lower()
        return all(word in text for word in typed_key.split())

    def _completion_add(self, query: str):
        """Add a query to the completion model if not already present."""
        key = query.strip().lower()
        if key and key not in self._completion_seen:
            self._completion_seen.add(key)
            self._completion_store.prepend([query.strip()])  # newest at top

    def _on_completion_selected(self, completion, model, it):
        """User picked a suggestion — fill entry and fire search immediately."""
        term = model.get_value(it, 0)
        self._entry.set_text(term)
        self._on_search()
        return True   # prevent default handler from overwriting the entry

    # ── Live suggestions ──────────────────────────────────────────────────────
    def _on_entry_changed(self, entry):
        """Debounce: schedule live suggestion fetch 400ms after last keystroke."""
        text = entry.get_text().strip()

        # Cancel any pending timer
        if self._suggest_timer is not None:
            GLib.source_remove(self._suggest_timer)
            self._suggest_timer = None

        if len(text) < 3 or self._busy:
            return

        self._suggest_timer = GLib.timeout_add(
            _SUGGEST_DELAY, self._fire_suggestions, text
        )

    def _fire_suggestions(self, term: str):
        """Called by GLib timer — increment token and launch background thread."""
        self._suggest_timer = None
        self._suggest_token += 1
        token = self._suggest_token

        active = [
            f for f in FORUMS
            if self._checks[f["name"]].get_active()
            and f["type"] in _SUGGESTERS
        ]
        if not active:
            return False

        self._suggest_lbl.show()
        threading.Thread(
            target=self._suggestions_thread,
            args=(term, token, active),
            daemon=True,
        ).start()
        return False   # don't repeat the GLib timer

    def _suggestions_thread(self, term: str, token: int, forums: list):
        """Background: fetch suggestions from each active forum, deduplicated."""
        seen:    set[str]  = set()
        results: list[str] = []
        for f in forums:
            suggester = _SUGGESTERS[f["type"]]
            for title in suggester(f, term):
                key = title.lower()
                if key not in seen:
                    seen.add(key)
                    results.append(title)
        GLib.idle_add(self._apply_live_suggestions, results, token)

    def _apply_live_suggestions(self, suggestions: list[str], token: int):
        """Main thread: replace previous live suggestions with new ones."""
        if token != self._suggest_token:
            return   # stale — a newer request already fired
        self._suggest_lbl.hide()

        # Remove previous live suggestions from the front of the store
        for _ in range(self._live_count):
            it = self._completion_store.get_iter_first()
            if it:
                self._completion_store.remove(it)
        self._live_count = 0

        # Prepend new live suggestions that aren't already in permanent store
        new_live: list[str] = []
        for s in suggestions:
            if s.lower() not in self._completion_seen:
                new_live.append(s)

        # Insert in reverse so first result ends up at top
        for s in reversed(new_live):
            self._completion_store.prepend([s])
        self._live_count = len(new_live)

        # Force the popup to appear with the new suggestions
        if new_live:
            self._entry.get_completion().complete()

    # ── Tooltip ───────────────────────────────────────────────────────────────
    def _on_result_hover(self, widget, event):
        info = widget.get_path_at_pos(int(event.x), int(event.y))
        if not info:
            self._on_result_hover_leave()
            return False
        it   = self._res_store.get_iter(info[0])
        link = self._res_store.get_value(it, 4)
        if link != self._hover_link:
            self._hover_link = link
            self._statusbar.pop(self._hover_ctx)
            self._statusbar.push(self._hover_ctx, link)
        return False

    def _on_result_hover_leave(self, *_):
        self._hover_link = None
        self._statusbar.pop(self._hover_ctx)

    # ── Keyboard shortcuts ────────────────────────────────────────────────────
    def _on_key_press(self, _widget, event):
        key  = event.keyval
        ctrl = event.state & Gdk.ModifierType.CONTROL_MASK

        if ctrl and key == Gdk.KEY_l:          # Ctrl+L — focus search bar
            self._entry.grab_focus()
            self._entry.select_region(0, -1)
            return True
        if ctrl and key == Gdk.KEY_f:          # Ctrl+F — toggle forums bar
            self._toggle_forums_bar()
            return True
        if key == Gdk.KEY_Escape:              # Escape — clear search entry
            self._entry.set_text("")
            self._entry.grab_focus()
            return True
        if key == Gdk.KEY_F5:                  # F5 — re-run last search
            self._on_search()
            return True
        return False

    # ── Settings persist ──────────────────────────────────────────────────────
    def _load_settings(self):
        try:
            with open(SETTINGS_FILE) as f:
                cfg = json.load(f)
            w = cfg.get("width",  960)
            h = cfg.get("height", 640)
            self.resize(w, h)
            self._hits_spin.set_value(cfg.get("hits", DEFAULT_HITS))
            self._forums_bar_visible = cfg.get("forums_bar_visible", True)
            for name, state in cfg.get("forums", {}).items():
                if name in self._checks:
                    self._checks[name].set_active(state)
        except Exception:
            pass   # first run or corrupt file — silently use defaults

    def _save_settings(self):
        try:
            w, h = self.get_size()
            cfg = {
                "width":             w,
                "height":            h,
                "hits":              int(self._hits_spin.get_value()),
                "forums_bar_visible": self._forums_bar_visible,
                "forums":            {n: cb.get_active() for n, cb in self._checks.items()},
            }
            with open(SETTINGS_FILE, "w") as f:
                json.dump(cfg, f, indent=2)
        except Exception:
            pass

    def _on_delete(self, *_):
        self._save_settings()
        return False   # let the destroy signal proceed

    # ── Helpers ───────────────────────────────────────────────────────────────
    @staticmethod
    def _open_url(url: str):
        subprocess.Popen(["xdg-open", url],
                         stdout=subprocess.DEVNULL,
                         stderr=subprocess.DEVNULL)

    @staticmethod
    def _copy(text: str):
        cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
        cb.set_text(text, -1)
        cb.store()


# ─── Entry point ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
    win = ScoutWindow()
    Gtk.main()
