#!/usr/bin/env python3
"""Adhano for Linux: the next prayer in the top bar, notifications and the adhan.

Times come from the Adhano API (two months at a time, cached), so the app keeps
working offline for weeks. Settings live in ~/.config/adhano/config.json.
"""
import json
import os
import shutil
import sys
import threading
import urllib.parse
import urllib.request
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("AyatanaAppIndicator3", "0.1")
gi.require_version("Notify", "0.7")
gi.require_version("Gst", "1.0")
from gi.repository import AyatanaAppIndicator3 as AppIndicator, Gdk, GLib, Gst, Gtk, Notify  # noqa: E402

VERSION = "1.2.2"
API = os.environ.get("ADHANO_API", "https://prayer.yazanmy.com")
SITE = API
SHARE = os.environ.get("ADHANO_SHARE", "/usr/share/adhano")
CONFIG_DIR = os.path.join(GLib.get_user_config_dir(), "adhano")
CACHE_DIR = os.path.join(GLib.get_user_cache_dir(), "adhano")
CONFIG = os.path.join(CONFIG_DIR, "config.json")
AUTOSTART = os.path.join(GLib.get_user_config_dir(), "autostart", "adhano.desktop")

PRAYERS = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"]
CALLED = ["fajr", "dhuhr", "asr", "maghrib", "isha"]  # sunrise has no adhan

TEXT = {
    "en": {
        "fajr": "Fajr", "sunrise": "Sunrise", "dhuhr": "Dhuhr", "asr": "Asr", "maghrib": "Maghrib", "isha": "Isha",
        "jumuah": "Jumu'ah", "in": "in", "now": "now", "h": "h", "m": "m",
        "no_city": "Choose your city", "offline": "Offline: connect once to load the times",
        "play": "Play the adhan at prayer time", "notify_before": "Remind me {n} minutes before",
        "bar": "Show the countdown in the top bar", "clock24": "24-hour clock", "autostart": "Start at login",
        "asr_school": "Asr", "standard": "Standard (Shafi'i, Maliki, Hanbali)", "hanafi": "Hanafi (later)",
        "language": "Language", "auto": "System", "city": "Change city…", "sound": "Choose adhan recording…",
        "fajr_sound": "Choose Fajr recording…", "default_sound": "Use the default sound", "test": "Test the sound",
        "stop": "Stop the sound", "site": "Open Adhano website", "about": "About", "quit": "Quit",
        "find_city": "Find your city", "search": "Type a city name, in English or Arabic", "cancel": "Cancel", "ok": "Use this city",
        "time_for": "It's time for {p}", "soon": "{p} in {n} minutes", "at": "at {t}",
        "audio_files": "Audio files", "settings": "Settings", "updated": "Times updated for {c}",
        "no_results": "No city found. Try a nearby larger city.", "net_error": "Couldn't reach Adhano. Check your connection.",
        "app": "Adhano", "open": "Open Adhano", "next": "Next prayer", "today": "Today", "back": "Back", "choose_first": "Choose your city to see its prayer times.",
        "s_alerts": "Notifications and sound", "s_times": "Prayer times", "s_look": "App", "remind_min": "Reminder before each prayer",
        "minutes": "minutes (0 = off)", "adhan_file": "Adhan recording", "fajr_file": "Fajr recording", "chime": "Soft chime (default)",
        "same": "Same as the adhan", "reset": "Reset", "city_label": "City", "offline_note": "Offline: showing saved times.",
        "version": "Version {v} · times from prayer.yazanmy.com",
        "bar_seconds": "Show seconds in the top bar", "tomorrow": "Tomorrow", "qibla": "Qibla {d}° from north",
        "in_days": "{e} in {n} days", "tomorrow_event": "{e} is tomorrow", "today_event": "{e} is today",
        "detected": "Use {c}", "other_city": "Choose another city", "detected_hint": "It looks like you're in",
        "mute": "Adhan and reminder on for {p}. Click to mute.", "unmute": "{p} is muted. Click to turn the adhan and reminder back on.",
        "shortcuts": "Ctrl+, settings · Esc back · Ctrl+W close · Ctrl+Q quit",
    },
    "ar": {
        "fajr": "الفجر", "sunrise": "الشروق", "dhuhr": "الظهر", "asr": "العصر", "maghrib": "المغرب", "isha": "العشاء",
        "jumuah": "الجمعة", "in": "بعد", "now": "الآن", "h": "س", "m": "د",
        "no_city": "اختر مدينتك", "offline": "دون اتصال: اتصل مرة لتحميل المواقيت",
        "play": "تشغيل الأذان عند دخول الوقت", "notify_before": "ذكّرني قبل {n} دقائق",
        "bar": "إظهار العد التنازلي في الشريط العلوي", "clock24": "نظام 24 ساعة", "autostart": "التشغيل عند تسجيل الدخول",
        "asr_school": "العصر", "standard": "الجمهور (الشافعي والمالكي والحنبلي)", "hanafi": "الحنفي (متأخر)",
        "language": "اللغة", "auto": "لغة النظام", "city": "تغيير المدينة…", "sound": "اختيار تسجيل الأذان…",
        "fajr_sound": "اختيار أذان الفجر…", "default_sound": "استخدام الصوت الافتراضي", "test": "تجربة الصوت",
        "stop": "إيقاف الصوت", "site": "فتح موقع أذانو", "about": "حول", "quit": "إنهاء",
        "find_city": "ابحث عن مدينتك", "search": "اكتب اسم المدينة بالعربية أو الإنجليزية", "cancel": "إلغاء", "ok": "استخدم هذه المدينة",
        "time_for": "حان وقت صلاة {p}", "soon": "{p} بعد {n} دقائق", "at": "الساعة {t}",
        "audio_files": "ملفات صوتية", "settings": "الإعدادات", "updated": "تم تحديث المواقيت لـ {c}",
        "no_results": "لم نجد المدينة. جرّب مدينة كبيرة قريبة.", "net_error": "تعذّر الاتصال بأذانو. تحقق من الاتصال.",
        "app": "أذانو", "open": "فتح أذانو", "next": "الصلاة القادمة", "today": "اليوم", "back": "رجوع", "choose_first": "اختر مدينتك لعرض مواقيت الصلاة.",
        "s_alerts": "التنبيهات والصوت", "s_times": "المواقيت", "s_look": "التطبيق", "remind_min": "التذكير قبل كل صلاة",
        "minutes": "دقائق (0 = إيقاف)", "adhan_file": "تسجيل الأذان", "fajr_file": "أذان الفجر", "chime": "نغمة هادئة (افتراضي)",
        "same": "مثل الأذان", "reset": "إعادة", "city_label": "المدينة", "offline_note": "دون اتصال: المواقيت المحفوظة.",
        "version": "الإصدار {v} · المواقيت من prayer.yazanmy.com",
        "bar_seconds": "إظهار الثواني في الشريط العلوي", "tomorrow": "غداً", "qibla": "القبلة {d}° من الشمال",
        "in_days": "{e} بعد {n} يوماً", "tomorrow_event": "{e} غداً", "today_event": "{e} اليوم",
        "detected": "استخدم {c}", "other_city": "اختر مدينة أخرى", "detected_hint": "يبدو أنك في",
        "mute": "الأذان والتذكير مفعّلان لصلاة {p}. انقر للكتم.", "unmute": "صلاة {p} مكتومة. انقر لإعادة الأذان والتذكير.",
        "shortcuts": "Ctrl+, الإعدادات · Esc رجوع · Ctrl+W إغلاق · Ctrl+Q إنهاء",
    },
}

MONTHS = {
    "en": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
    "ar": ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"],
}
WEEKDAYS = {
    "en": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
    "ar": ["الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت", "الأحد"],
}

CSS = b"""
.hero { background-image: linear-gradient(135deg, #0f766e, #14b8a6); border-radius: 18px; padding: 18px 20px; }
.hero label { color: #ffffff; }
.hero button label { color: @theme_fg_color; }
.hero button.suggested-action label { color: #ffffff; }
.hero .eyebrow { font-size: 13px; font-weight: 600; opacity: 0.85; }
.hero .prayer { font-size: 30px; font-weight: 800; }
.hero .at { font-size: 18px; font-weight: 600; opacity: 0.9; }
.hero .countdown { font-size: 46px; font-weight: 800; font-feature-settings: "tnum"; }
.hero progressbar trough { background-color: alpha(#ffffff, 0.25); border: none; min-height: 6px; border-radius: 3px; }
.hero progressbar progress { background-color: #ffffff; border: none; min-height: 6px; border-radius: 3px; }
.hero .detected { font-size: 22px; font-weight: 800; }
.date { font-size: 13px; opacity: 0.75; }
.info { font-size: 13px; opacity: 0.75; }
.bell { padding: 2px 6px; min-height: 0; min-width: 0; }
.times row.past .bell, .times row.muted .bell { opacity: 0.6; }
.times row.muted .pname { text-decoration-line: line-through; }
.dark .times row.next { background-color: alpha(#2dd4bf, 0.16); }
.dark .times row.next label { color: #5eead4; }
.times { border-radius: 14px; }
.times row { padding: 11px 16px; }
.times row.next { background-color: alpha(#14b8a6, 0.16); }
.times row.next label { color: #0f766e; font-weight: 700; }
.times row.past label { opacity: 0.5; }
.pname, .ptime { font-size: 15px; }
.ptime { font-feature-settings: "tnum"; }
.section { font-weight: 700; font-size: 13px; opacity: 0.7; margin-top: 10px; }
.settings row { padding: 8px 14px; }
.hint { font-size: 12px; opacity: 0.65; }
.empty { font-size: 15px; opacity: 0.75; }
"""

DEFAULTS = {
    "city": None,  # {"slug", "name", "name_ar", "tz"}
    "madhab": "shafi",
    "lang": "auto",
    "clock24": None,  # None: by language (Arabic 24h, English 12h)
    "play": True,
    "sound": None,  # None: the bundled sound
    "fajr_sound": None,
    "notify_before": 10,
    "bar": True,
    "bar_seconds": False,
    "muted": [],  # prayers with no adhan or reminder
    "autostart": True,
}


def load_json(path, fallback):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError):
        return fallback


def save_json(path, data):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def get(path, **params):
    url = f"{API}{path}?{urllib.parse.urlencode(params)}"
    req = urllib.request.Request(url, headers={"User-Agent": f"Adhano-Linux/{VERSION}"})
    with urllib.request.urlopen(req, timeout=15) as r:
        body = json.load(r)
    if body.get("code") != 200:
        raise ValueError(body.get("errors") or body)
    return body["data"]


class App:
    application = None

    def __init__(self):
        self.cfg = {**DEFAULTS, **load_json(CONFIG, {})}
        self.days = {}  # "YYYY-MM-DD" -> timings
        self.tz = None
        self.fired = set()
        self.player = None
        self.offline = False
        self.fetching = False
        self.win = None
        self.extra = {}
        self.detected = None  # the city guessed from the IP on first run
        self.last_minute = None
        self.note = None
        self.load_cache()

        Notify.init("Adhano")
        self.ind = AppIndicator.Indicator.new("adhano", self.icon("adhano-tray"), AppIndicator.IndicatorCategory.APPLICATION_STATUS)
        self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE)
        self.ind.set_title("Adhano")
        self.build_menu()
        self.tick()
        # GNOME ignores a label sent before the icon registers, and an unchanged
        # label isn't sent again, so resend a slightly different one a few times.
        for delay in (3, 6, 12):
            GLib.timeout_add_seconds(delay, self.resend_label)
        GLib.timeout_add_seconds(1, self.tick)
        GLib.timeout_add_seconds(6 * 3600, self.refresh)
        self.refresh()
        self.set_autostart(self.cfg["autostart"])
        if not self.cfg["city"]:
            self.detect_city()
            GLib.idle_add(self.open_window)

    # Language and formatting

    @property
    def lang(self):
        if self.cfg["lang"] in ("en", "ar"):
            return self.cfg["lang"]
        return "ar" if (os.environ.get("LANGUAGE") or os.environ.get("LANG") or "").startswith("ar") else "en"

    def t(self, key, **kw):
        return TEXT[self.lang][key].format(**kw)

    def clock24(self):
        return self.cfg["clock24"] if self.cfg["clock24"] is not None else self.lang == "ar"

    def fmt(self, dt):
        if self.clock24():
            return dt.strftime("%H:%M")
        s = dt.strftime("%I:%M").lstrip("0")
        return f"{s} {'ص' if dt.hour < 12 else 'م'}" if self.lang == "ar" else f"{s} {'AM' if dt.hour < 12 else 'PM'}"

    def until(self, secs):
        mins = max(0, int(secs // 60) + (1 if secs % 60 else 0))
        h, m = divmod(mins, 60)
        if h:
            return f"{h}{self.t('h')} {m}{self.t('m')}"
        return f"{m}{self.t('m')}"

    def name(self, p, day=None):
        if p == "dhuhr" and day and day.weekday() == 4:
            return self.t("jumuah")
        return self.t(p)

    def icon(self, name):
        local = os.path.join(os.path.dirname(os.path.abspath(__file__)), f"{name}.svg")
        return local if os.path.exists(local) else name

    # Times

    def cache_path(self):
        c = self.cfg["city"]
        return os.path.join(CACHE_DIR, f"{c['slug']}-{self.cfg['madhab']}.json") if c else None

    def load_cache(self):
        path = self.cache_path()
        data = load_json(path, None) if path else None
        if data:
            self.days = data["days"]
            self.tz = ZoneInfo(data["tz"])
            self.extra = data.get("extra", {})

    def refresh(self):
        c = self.cfg["city"]
        if not c or self.fetching:
            return True
        self.fetching = True
        month = date.today().strftime("%Y-%m")
        madhab = self.cfg["madhab"]

        def work():
            try:
                data = get("/api/v1/calendar", city=c["slug"], month=month, months=2, madhab=madhab, output="json")
                ar = get("/api/v1/calendar", city=c["slug"], month=month, months=2, madhab=madhab, output="json", lang="ar")
                hijri_ar = {d["date"]["gregorian"]: d["date"]["hijri"]["label"] for d in ar["days"]}
                days = {
                    d["date"]["gregorian"]: {**d["timings"], "hijri": {"label_en": d["date"]["hijri"]["label"], "label_ar": hijri_ar.get(d["date"]["gregorian"], "")}}
                    for d in data["days"]
                }
                tz = data["meta"]["timezone"]
                extra = {}
                try:
                    extra["qibla"] = round(get("/api/v1/qibla", city=c["slug"])["direction"])
                    for lang in ("en", "ar"):
                        extra[f"events_{lang}"] = get("/api/v1/events", lang=lang)["events"][:3]
                except Exception:
                    pass
                save_json(self.cache_path(), {"tz": tz, "days": days, "extra": extra, "fetched": datetime.now().isoformat()})
                GLib.idle_add(self.loaded, days, tz, None, extra)
            except Exception as e:  # offline or server error: keep the cache
                GLib.idle_add(self.loaded, None, None, e, None)

        threading.Thread(target=work, daemon=True).start()
        return True

    def loaded(self, days, tz, err, extra):
        self.fetching = False
        if err is None:
            self.days, self.tz, self.offline = days, ZoneInfo(tz), False
            self.extra = extra or self.extra
        else:
            self.offline = True
            print(f"adhano: refresh failed: {err}", file=sys.stderr)
        self.tick()
        return False

    def events(self, day):
        """(prayer, aware datetime) for one city-local date, or [] if not cached."""
        t = self.days.get(day.isoformat())
        if not t or not self.tz:
            return []
        out = []
        for p in PRAYERS:
            h, m = map(int, t[p].split(":"))
            out.append((p, datetime(day.year, day.month, day.day, h, m, tzinfo=self.tz)))
        return out

    def now(self):
        return datetime.now(self.tz) if self.tz else datetime.now().astimezone()

    def next_event(self, now):
        for offset in range(0, 3):
            for p, at in self.events(now.date() + timedelta(days=offset)):
                if p in CALLED and at > now:
                    return p, at
        return None

    # Loop

    def tick(self):
        now = self.now()
        nxt = self.next_event(now)
        if not self.cfg["city"]:
            label = self.t("no_city")
        elif nxt is None:
            label = self.t("offline")
        else:
            p, at = nxt
            secs = (at - now).total_seconds()
            left = self.clock(secs) if self.cfg["bar_seconds"] else self.until(secs)
            label = f"{self.name(p, at.date())} {self.fmt(at)} · {left}"
        label = label if self.cfg["bar"] or not nxt else ""
        if label.rstrip() != (self.ind.get_label() or "").rstrip():
            self.ind.set_label(label, "")
            self.ind.set_title(f"Adhano · {label}")
        self.alerts(now)
        minute = now.replace(second=0, microsecond=0)
        if minute != self.last_minute:  # the menu is sent over D-Bus: once a minute is enough
            self.last_minute = minute
            self.update_rows(now, nxt)
        return True

    @staticmethod
    def clock(secs):
        secs = max(0, int(secs))
        h, rem = divmod(secs, 3600)
        return f"{h}:{rem // 60:02d}:{rem % 60:02d}"

    def resend_label(self):
        label = self.ind.get_label() or ""
        self.ind.set_label(label[:-1] if label.endswith(" ") else label + " ", "")
        self.last_minute = None
        return False

    def alerts(self, now):
        before = self.cfg["notify_before"]
        for p, at in self.events(now.date()):
            if p not in CALLED or p in self.cfg["muted"]:
                continue
            key = (at.date().isoformat(), p)
            # A two-minute window, so a suspended laptop doesn't replay a whole day.
            if at <= now < at + timedelta(minutes=2) and key not in self.fired:
                self.fired.add(key)
                playing = self.cfg["play"] and self.play(fajr=p == "fajr")
                self.notify(self.t("time_for", p=self.name(p, at.date())), self.t("at", t=self.fmt(at)), stop=bool(playing))
            soon = at - timedelta(minutes=before)
            if before and soon <= now < soon + timedelta(minutes=2) and key + ("soon",) not in self.fired:
                self.fired.add(key + ("soon",))
                self.notify(self.t("soon", p=self.name(p, at.date()), n=before), self.t("at", t=self.fmt(at)))

    def notify(self, title, body, stop=False):
        n = Notify.Notification.new(title, body, self.icon("adhano"))
        n.set_urgency(Notify.Urgency.NORMAL)
        if stop and "actions" in (Notify.get_server_caps() or []):
            n.add_action("stop", self.t("stop"), lambda *_: self.stop())
        self.note = n  # keep a reference so the action still works
        try:
            n.show()
        except GLib.Error as e:
            print(f"adhano: notification failed: {e}", file=sys.stderr)

    # Sound

    def sound_file(self, fajr=False):
        chosen = (fajr and self.cfg["fajr_sound"]) or self.cfg["sound"]
        if chosen and os.path.exists(chosen):
            return chosen
        for d in (SHARE, os.path.dirname(os.path.abspath(__file__))):
            f = os.path.join(d, "adhano-chime.wav")
            if os.path.exists(f):
                return f
        return None

    def play(self, fajr=False):
        f = self.sound_file(fajr)
        if not f:
            return
        self.stop()
        self.player = Gst.ElementFactory.make("playbin", "adhan")
        if not self.player:
            return
        self.player.set_property("uri", GLib.filename_to_uri(f, None))
        bus = self.player.get_bus()
        bus.add_signal_watch()
        bus.connect("message::eos", lambda *_: self.stop())
        bus.connect("message::error", lambda *_: self.stop())
        self.player.set_state(Gst.State.PLAYING)
        self.stop_item.set_visible(True)
        if self.win:
            self.win.sound_changed()
        return True

    def stop(self, *_):
        if self.note:
            try:
                self.note.close()
            except GLib.Error:
                pass
            self.note = None
        if self.player:
            self.player.set_state(Gst.State.NULL)
            self.player = None
        if hasattr(self, "stop_item"):
            self.stop_item.set_visible(False)
        if getattr(self, "win", None):
            self.win.sound_changed()

    # Menu

    def build_menu(self):
        self.stop()
        menu = Gtk.Menu()
        self.header = Gtk.MenuItem(label="")
        self.header.set_sensitive(False)
        menu.append(self.header)
        self.rows = {}
        for p in PRAYERS:
            item = Gtk.MenuItem(label="")
            item.set_sensitive(False)
            self.rows[p] = item
            menu.append(item)
        self.stop_item = Gtk.MenuItem(label=self.t("stop"))
        self.stop_item.connect("activate", self.stop)
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.stop_item)

        menu.append(self.item(self.t("open"), lambda *_: self.open_window()))
        play = Gtk.CheckMenuItem(label=self.t("play"))
        play.set_active(bool(self.cfg["play"]))
        play.connect("toggled", lambda w: self.set_option("play", w.get_active()))
        menu.append(play)
        menu.append(self.item(self.t("city"), lambda *_: self.choose_city()))
        menu.append(self.item(self.t("settings") + "…", lambda *_: self.open_window(settings=True)))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.item(self.t("site"), lambda *_: self.open_site()))
        menu.append(self.item(self.t("quit"), lambda *_: self.quit()))
        menu.show_all()
        self.stop_item.set_visible(False)
        self.ind.set_menu(menu)
        self.ind.set_secondary_activate_target(menu.get_children()[len(PRAYERS) + 3])
        self.tick()

    def item(self, label, cb):
        i = Gtk.MenuItem(label=label)
        i.connect("activate", cb)
        return i

    def radio_menu(self, label, key, options, on_change):
        sub, group = Gtk.Menu(), None
        for value, text in options:
            r = Gtk.RadioMenuItem.new_with_label_from_widget(group, text)
            group = group or r
            r.set_active(self.cfg[key] == value)

            def toggled(w, value=value):
                if w.get_active() and self.cfg[key] != value:
                    self.cfg[key] = value
                    self.save()
                    GLib.idle_add(on_change)

            r.connect("toggled", toggled)
            sub.append(r)
        root = Gtk.MenuItem(label=label)
        root.set_submenu(sub)
        return root

    def update_rows(self, now, nxt):
        c = self.cfg["city"]
        today = now.date()
        events = self.events(today)
        city = (c.get("name_ar") if self.lang == "ar" else c.get("name")) if c else self.t("no_city")
        hijri = self.days.get(today.isoformat(), {}).get("hijri", {}).get(f"label_{self.lang}", "")
        self.header.set_label(f"{city} · {hijri or today.strftime('%-d/%-m/%Y')}")
        for p in PRAYERS:
            at = next((a for q, a in events if q == p), None)
            mark = ""
            if nxt and nxt[0] == p and nxt[1].date() == today:
                mark = "   ◀"
            elif at and at <= now:
                mark = "   ✓"
            self.rows[p].set_label(f"{self.name(p, today)}   {self.fmt(at)}{mark}" if at else self.name(p, today))

    # Actions

    def save(self):
        save_json(CONFIG, self.cfg)

    def set_option(self, key, value):
        """A setting changed in the window or the menu."""
        if self.cfg.get(key) == value:
            return
        self.cfg[key] = value
        self.save()
        if key == "autostart":
            self.set_autostart(value)
        if key == "madhab":
            self.madhab_changed()
        if key in ("lang", "clock24", "play", "bar_seconds", "bar"):
            self.last_minute = None
        if key in ("lang", "clock24", "play"):
            self.build_menu()
        if key == "lang" and self.win:
            on_settings = self.win.on_settings()
            self.win.destroy()
            self.win = None
            self.open_window(settings=on_settings)
        self.tick()

    def toggle_mute(self, p):
        muted = [x for x in self.cfg["muted"] if x != p] if p in self.cfg["muted"] else self.cfg["muted"] + [p]
        self.set_option("muted", muted)
        if self.win:
            self.win.update()

    def detect_city(self):
        """Guess the city from the IP once, for the first-run screen."""
        def work():
            try:
                here = get("/api/v1/whereami")
                ar = get("/api/v1/geocode", q=here["slug"], lang="ar")
                a = next((c for c in ar if c["slug"] == here["slug"]), None)
                en = get("/api/v1/geocode", q=here["slug"])
                e = next((c for c in en if c["slug"] == here["slug"]), None)
                if e:
                    city = {"slug": e["slug"], "name": f"{e['name']}, {e['country']}", "name_ar": f"{a['name']}، {a['country']}" if a else e["name"], "tz": e["tz"]}
                    GLib.idle_add(self.got_detected, city)
            except Exception:
                pass

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

    def got_detected(self, city):
        self.detected = city
        if self.win:
            self.win.update()
        return False

    def use_city(self, city):
        self.cfg["city"] = city
        self.save()
        self.days, self.tz, self.fired, self.extra = {}, None, set(), {}
        self.load_cache()
        self.refresh()
        self.last_minute = None
        self.tick()
        if self.win:
            self.win.city_changed()

    def open_window(self, settings=False):
        if not self.win:
            self.win = MainWindow(self)
        self.win.show_page(settings)
        self.win.present()
        return False

    def quit(self):
        self.stop()
        if self.application:
            self.application.quit()

    def madhab_changed(self):
        self.load_cache()
        self.refresh()
        return False

    def set_autostart(self, on):
        if on:
            installed = shutil.which("adhano") == "/usr/bin/adhano"
            exe = "adhano" if installed else f"{sys.executable} {os.path.abspath(__file__)}"
            os.makedirs(os.path.dirname(AUTOSTART), exist_ok=True)
            with open(AUTOSTART, "w") as f:
                # --background: sit in the top bar at login without opening the window.
                f.write(f"[Desktop Entry]\nType=Application\nName=Adhano\nIcon=adhano\nExec={exe} --background\nX-GNOME-Autostart-enabled=true\n")
        elif os.path.exists(AUTOSTART):
            os.remove(AUTOSTART)

    def open_site(self):
        c = self.cfg["city"]
        Gtk.show_uri_on_window(None, f"{SITE}/{c['slug']}" if c else SITE, Gtk.get_current_event_time())

    def about(self):
        d = Gtk.AboutDialog(program_name="Adhano", version=VERSION, website=SITE, logo_icon_name="adhano",
                            comments="أذانو · مواقيت الصلاة\nPrayer times, notifications and the adhan.")
        d.run()
        d.destroy()

    def choose_sound(self, key):
        d = Gtk.FileChooserDialog(title=self.t(key if key == "fajr_sound" else "sound").rstrip("…"), action=Gtk.FileChooserAction.OPEN)
        d.add_buttons(self.t("cancel"), Gtk.ResponseType.CANCEL, "اختيار" if self.lang == "ar" else "Choose", Gtk.ResponseType.OK)
        f = Gtk.FileFilter()
        f.set_name(self.t("audio_files"))
        f.add_mime_type("audio/*")
        d.add_filter(f)
        if d.run() == Gtk.ResponseType.OK:
            self.cfg[key] = d.get_filename()
            self.save()
        d.destroy()

    def choose_city(self):
        d = Gtk.Dialog(title=self.t("find_city"), modal=True, transient_for=self.win)
        d.set_default_size(420, 420)
        d.add_buttons(self.t("cancel"), Gtk.ResponseType.CANCEL, self.t("ok"), Gtk.ResponseType.OK)
        d.set_default_response(Gtk.ResponseType.OK)
        if self.lang == "ar":
            d.set_default_direction(Gtk.TextDirection.RTL)
        box = d.get_content_area()
        box.set_spacing(8)
        box.set_border_width(12)
        entry = Gtk.SearchEntry(placeholder_text=self.t("search"))
        status = Gtk.Label(label="", xalign=0)
        store = Gtk.ListStore(str, object)
        view = Gtk.TreeView(model=store, headers_visible=False)
        view.append_column(Gtk.TreeViewColumn("", Gtk.CellRendererText(), text=0))
        scroll = Gtk.ScrolledWindow(vexpand=True)
        scroll.add(view)
        for w in (entry, status, scroll):
            box.pack_start(w, w is scroll, w is scroll, 0)
        ok = d.get_widget_for_response(Gtk.ResponseType.OK)
        ok.set_sensitive(False)
        view.get_selection().connect("changed", lambda s: ok.set_sensitive(s.get_selected()[1] is not None))
        view.connect("row-activated", lambda *_: d.response(Gtk.ResponseType.OK))
        seq = [0]

        def search(_):
            q = entry.get_text().strip()
            seq[0] += 1
            mine = seq[0]
            if len(q) < 2:
                store.clear()
                return

            def work():
                try:
                    en, ar = get("/api/v1/geocode", q=q), get("/api/v1/geocode", q=q, lang="ar")
                    err = None
                except Exception:
                    en, ar, err = [], [], self.t("net_error")
                GLib.idle_add(show, mine, en, ar, err)

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

        def show(mine, en, ar, err):
            if mine != seq[0]:
                return False
            store.clear()
            names_ar = {c["slug"]: c for c in ar}
            for c in en:
                a = names_ar.get(c["slug"], c)
                city = {"slug": c["slug"], "name": f"{c['name']}, {c['country']}", "name_ar": f"{a['name']}، {a['country']}", "tz": c["tz"]}
                store.append([city["name_ar"] if self.lang == "ar" else city["name"], city])
            status.set_text(err or ("" if en else self.t("no_results")))
            if en:
                view.get_selection().select_path(Gtk.TreePath.new_first())
            return False

        entry.connect("search-changed", search)
        entry.connect("activate", lambda *_: d.response(Gtk.ResponseType.OK) if ok.get_sensitive() else None)
        d.show_all()
        if d.run() == Gtk.ResponseType.OK:
            model, it = view.get_selection().get_selected()
            if it:
                self.use_city(model[it][1])
        d.destroy()
        return False


class MainWindow(Gtk.ApplicationWindow):
    """Today's times with the next prayer and a live countdown, and the settings."""

    def __init__(self, app):
        self.app = app
        Gtk.Widget.set_default_direction(Gtk.TextDirection.RTL if app.lang == "ar" else Gtk.TextDirection.LTR)
        super().__init__(application=app.application, title=app.t("app"))
        self.set_default_size(420, 640)
        self.set_icon_name("adhano")
        self.connect("delete-event", lambda *_: self.hide() or True)  # keeps running in the top bar
        t = app.t

        header = Gtk.HeaderBar(show_close_button=True, title=t("app"))
        self.set_titlebar(header)
        self.back = Gtk.Button.new_from_icon_name("go-previous-symbolic", Gtk.IconSize.BUTTON)
        self.back.set_tooltip_text(t("back"))
        self.back.connect("clicked", lambda *_: self.show_page(False))
        header.pack_start(self.back)
        self.gear = Gtk.Button.new_from_icon_name("emblem-system-symbolic", Gtk.IconSize.BUTTON)
        self.gear.set_tooltip_text(t("settings"))
        self.gear.connect("clicked", lambda *_: self.show_page(True))
        header.pack_end(self.gear)
        self.header = header

        self.stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
        self.stack.add_named(self.times_page(), "times")
        self.stack.add_named(self.settings_page(), "settings")
        self.add(self.stack)
        self.connect("key-press-event", self.on_key)
        self.connect("style-updated", lambda *_: self.mark_dark())
        self.show_all()
        self.mark_dark()
        self.update()
        self.sound_changed()
        GLib.timeout_add_seconds(1, self.second)

    def mark_dark(self):
        """Dark themes get a lighter teal for the next prayer."""
        fg = self.get_style_context().get_color(Gtk.StateFlags.NORMAL)
        ctx = self.get_style_context()
        (ctx.add_class if (fg.red + fg.green + fg.blue) / 3 > 0.6 else ctx.remove_class)("dark")

    def on_key(self, _w, ev):
        ctrl = ev.state & Gdk.ModifierType.CONTROL_MASK
        key = Gdk.keyval_name(ev.keyval)
        if ctrl and key == "comma":
            self.show_page(True)
        elif key == "Escape" and self.on_settings():
            self.show_page(False)
        elif ctrl and key in ("w", "W"):
            self.hide()
        elif ctrl and key in ("q", "Q"):
            self.app.quit()
        else:
            return False
        return True

    # Times page

    def times_page(self):
        t = self.app.t
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14, margin=18)

        hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        hero.get_style_context().add_class("hero")
        self.eyebrow = self.label("", "eyebrow")
        line = Gtk.Box(spacing=10)
        self.hero_name = self.label("", "prayer")
        self.hero_at = self.label("", "at")
        self.hero_at.set_valign(Gtk.Align.BASELINE)
        self.hero_name.set_valign(Gtk.Align.BASELINE)
        line.pack_start(self.hero_name, False, False, 0)
        line.pack_start(self.hero_at, False, False, 0)
        self.countdown = self.label("", "countdown")
        self.progress = Gtk.ProgressBar(margin_top=8)
        for w in (self.eyebrow, line, self.countdown, self.progress):
            hero.pack_start(w, False, False, 0)
        box.pack_start(hero, False, False, 0)

        # First run: the city guessed from the IP, one click to accept.
        self.welcome = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        self.welcome.get_style_context().add_class("hero")
        self.welcome.pack_start(self.label(t("detected_hint"), "eyebrow"), False, False, 0)
        self.detected_label = self.label("", "detected")
        self.welcome.pack_start(self.detected_label, False, False, 0)
        buttons = Gtk.Box(spacing=8, margin_top=8)
        self.use_detected = Gtk.Button()
        self.use_detected.get_style_context().add_class("suggested-action")
        self.use_detected.connect("clicked", lambda *_: self.app.detected and self.app.use_city(self.app.detected))
        other = Gtk.Button(label=t("other_city"))
        other.connect("clicked", lambda *_: self.app.choose_city())
        buttons.pack_start(self.use_detected, False, False, 0)
        buttons.pack_start(other, False, False, 0)
        self.welcome.pack_start(buttons, False, False, 0)
        box.pack_start(self.welcome, False, False, 0)

        self.date = self.label("", "date")
        self.date.set_line_wrap(True)
        box.pack_start(self.date, False, False, 0)

        self.list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        self.list.get_style_context().add_class("times")
        frame = Gtk.Frame()
        frame.add(self.list)
        self.rows = {}
        for p in PRAYERS:
            row = Gtk.ListBoxRow(activatable=False)
            r = Gtk.Box(spacing=8)
            name, at = self.label("", "pname"), self.label("", "ptime")
            bell = None
            if p in CALLED:
                bell = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
                bell.get_style_context().add_class("bell")
                bell.connect("clicked", lambda _w, p=p: self.app.toggle_mute(p))
                r.pack_end(bell, False, False, 0)
            else:
                r.pack_end(Gtk.Box(width_request=34), False, False, 0)  # keeps the times aligned
            r.pack_start(name, True, True, 0)
            r.pack_end(at, False, False, 0)
            row.add(r)
            self.list.add(row)
            self.rows[p] = (row, name, at, bell)
        box.pack_start(frame, False, False, 0)

        self.info = self.label("", "info")
        self.info.set_line_wrap(True)
        box.pack_start(self.info, False, False, 0)

        self.empty = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, valign=Gtk.Align.CENTER, vexpand=True)
        msg = self.label(t("choose_first"), "empty")
        msg.set_justify(Gtk.Justification.CENTER)
        msg.set_halign(Gtk.Align.CENTER)
        pick = Gtk.Button(label=t("city"), halign=Gtk.Align.CENTER)
        pick.get_style_context().add_class("suggested-action")
        pick.connect("clicked", lambda *_: self.app.choose_city())
        self.empty.pack_start(msg, False, False, 0)
        self.empty.pack_start(pick, False, False, 0)
        box.pack_start(self.empty, True, True, 0)

        self.offline = self.label(t("offline_note"), "hint")
        box.pack_start(self.offline, False, False, 0)

        actions = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER)
        city = Gtk.Button(label=t("city"))
        city.connect("clicked", lambda *_: self.app.choose_city())
        site = Gtk.Button(label=t("site"))
        site.connect("clicked", lambda *_: self.app.open_site())
        actions.pack_start(city, False, False, 0)
        actions.pack_start(site, False, False, 0)
        box.pack_end(actions, False, False, 0)
        self.hero, self.frame, self.actions_box = hero, frame, actions
        self.city_button_main = city
        return box

    def label(self, text, cls=None):
        l = Gtk.Label(label=text, xalign=0)
        if cls:
            l.get_style_context().add_class(cls)
        return l

    def second(self):
        if self.get_visible() and self.stack.get_visible_child_name() == "times":
            self.update()
        return True

    def update(self):
        app, t = self.app, self.app.t
        c = app.cfg["city"]
        now = app.now()
        nxt = app.next_event(now)
        # After Isha the list moves on to tomorrow, which is what's left to pray.
        day = nxt[1].date() if nxt and nxt[1].date() > now.date() else now.date()
        events = app.events(day)
        has = bool(c and events)
        for w in (self.hero, self.frame, self.date, self.info):
            w.set_visible(has)
        self.welcome.set_visible(not c and app.detected is not None)
        self.empty.set_visible(not c and app.detected is None)
        self.actions_box.set_visible(bool(c))
        self.offline.set_visible(bool(c) and app.offline)
        self.header.set_subtitle((c.get("name_ar") if app.lang == "ar" else c.get("name")) if c else "")
        if app.detected:
            d = app.detected
            self.detected_label.set_text(d["name_ar"] if app.lang == "ar" else d["name"])
            self.use_detected.set_label(t("detected", c=(d["name_ar"] if app.lang == "ar" else d["name"]).split("،")[0].split(",")[0]))
        if not has:
            return
        if nxt:
            p, at = nxt
            self.eyebrow.set_text(t("next"))
            self.hero_name.set_text(app.name(p, at.date()))
            self.hero_at.set_text(app.fmt(at))
            self.countdown.set_text(app.clock((at - now).total_seconds()))
            prev = max((a for d in (now.date() - timedelta(days=1), now.date()) for q, a in app.events(d) if q in CALLED and a <= now), default=None)
            if prev:
                self.progress.set_fraction(min(1, (now - prev).total_seconds() / max(1, (at - prev).total_seconds())))
            self.progress.set_visible(prev is not None)
        lang = app.lang
        hijri = app.days.get(day.isoformat(), {}).get("hijri", {}).get(f"label_{lang}", "")
        greg = f"{WEEKDAYS[lang][day.weekday()]} {day.day} {MONTHS[lang][day.month - 1]} {day.year}"
        prefix = f"{t('tomorrow')} · " if day > now.date() else ""
        self.date.set_text(prefix + (f"{greg} · {hijri}" if hijri else greg))
        for p in PRAYERS:
            row, name, at_label, bell = self.rows[p]
            at = next((a for q, a in events if q == p), None)
            name.set_text(app.name(p, day))
            at_label.set_text(app.fmt(at) if at else "")
            ctx = row.get_style_context()
            is_next = bool(nxt and nxt[0] == p and nxt[1].date() == day)
            muted = p in app.cfg["muted"]
            for cls, on in (("next", is_next), ("past", bool(at and at <= now and not is_next)), ("muted", muted)):
                (ctx.add_class if on else ctx.remove_class)(cls)
            if bell:
                bell.set_image(Gtk.Image.new_from_icon_name("audio-volume-muted-symbolic" if muted else "audio-volume-high-symbolic", Gtk.IconSize.BUTTON))
                bell.set_tooltip_text(t("unmute" if muted else "mute", p=app.name(p, day)))
        self.info.set_text(self.info_line(now))

    def info_line(self, now):
        app, t = self.app, self.app.t
        parts = []
        if app.extra.get("qibla") is not None:
            parts.append(t("qibla", d=app.extra["qibla"]))
        for e in app.extra.get(f"events_{app.lang}", [])[:1]:
            try:
                n = (date.fromisoformat(e["gregorian"]) - now.date()).days
            except (KeyError, ValueError):
                continue
            if n >= 0:
                parts.append(t("today_event", e=e["name"]) if n == 0 else t("tomorrow_event", e=e["name"]) if n == 1 else t("in_days", e=e["name"], n=n))
        return " · ".join(parts)

    # Settings page

    def settings_page(self):
        app, t = self.app, self.app.t
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=18)

        def section(title):
            box.pack_start(self.label(title, "section"), False, False, 0)
            lb = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
            lb.get_style_context().add_class("settings")
            fr = Gtk.Frame()
            fr.add(lb)
            box.pack_start(fr, False, False, 0)
            return lb

        def row(lb, title, widget, hint=None):
            r = Gtk.Box(spacing=12)
            texts = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, valign=Gtk.Align.CENTER)
            texts.pack_start(self.label(title), False, False, 0)
            if hint:
                texts.pack_start(self.label(hint, "hint"), False, False, 0)
            r.pack_start(texts, True, True, 0)
            widget.set_valign(Gtk.Align.CENTER)
            r.pack_end(widget, False, False, 0)
            lr = Gtk.ListBoxRow(activatable=False)
            lr.add(r)
            lb.add(lr)
            return widget

        def switch(key, value=None):
            sw = Gtk.Switch(active=bool(app.cfg[key]) if value is None else value)
            sw.connect("notify::active", lambda w, _: app.set_option(key, w.get_active()))
            return sw

        def combo(key, options):
            cb = Gtk.ComboBoxText()
            for value, text in options:
                cb.append(value, text)
            cb.set_active_id(app.cfg[key])
            cb.connect("changed", lambda w: app.set_option(key, w.get_active_id()))
            return cb

        alerts = section(t("s_alerts"))
        row(alerts, t("play"), switch("play"))
        spin = Gtk.SpinButton.new_with_range(0, 60, 5)
        spin.set_value(app.cfg["notify_before"] or 0)
        spin.connect("value-changed", lambda w: app.set_option("notify_before", int(w.get_value())))
        row(alerts, t("remind_min"), spin, t("minutes"))
        self.sound_buttons = {}
        for key, title, empty in (("sound", t("adhan_file"), t("chime")), ("fajr_sound", t("fajr_file"), t("same"))):
            b = Gtk.Box(spacing=6)
            pick = Gtk.Button()
            pick.connect("clicked", lambda _w, key=key: (app.choose_sound(key), self.sound_changed()))
            reset = Gtk.Button.new_from_icon_name("edit-clear-symbolic", Gtk.IconSize.BUTTON)
            reset.set_tooltip_text(t("reset"))
            reset.connect("clicked", lambda _w, key=key: (app.set_option(key, None), self.sound_changed()))
            b.pack_start(pick, False, False, 0)
            b.pack_start(reset, False, False, 0)
            self.sound_buttons[key] = (pick, reset, empty)
            row(alerts, title, b)
        self.test = Gtk.Button(label=t("test"))
        self.test.connect("clicked", lambda *_: app.stop() if app.player else app.play())
        row(alerts, "", self.test)

        times = section(t("s_times"))
        self.city_button = Gtk.Button()
        self.city_button.connect("clicked", lambda *_: app.choose_city())
        row(times, t("city_label"), self.city_button)
        row(times, t("asr_school"), combo("madhab", [("shafi", t("standard")), ("hanafi", t("hanafi"))]))

        look = section(t("s_look"))
        row(look, t("language"), combo("lang", [("auto", t("auto")), ("en", "English"), ("ar", "العربية")]))
        row(look, t("clock24"), switch("clock24", app.clock24()))
        row(look, t("bar"), switch("bar"))
        row(look, t("bar_seconds"), switch("bar_seconds"))
        row(look, t("autostart"), switch("autostart"))

        keys = self.label(t("shortcuts"), "hint")
        keys.set_halign(Gtk.Align.CENTER)
        keys.set_margin_top(8)
        box.pack_start(keys, False, False, 0)
        about = self.label(t("version", v=VERSION), "hint")
        about.set_halign(Gtk.Align.CENTER)
        about.set_margin_top(8)
        box.pack_start(about, False, False, 0)
        self.city_changed()
        scroll = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
        scroll.add(box)
        return scroll

    def sound_changed(self):
        app, t = self.app, self.app.t
        for key, (pick, reset, empty) in getattr(self, "sound_buttons", {}).items():
            f = app.cfg[key]
            name = os.path.basename(f) if f else empty
            pick.set_label(name if len(name) <= 24 else name[:21] + "…")
            pick.set_tooltip_text(f or "")
            reset.set_sensitive(bool(f))
        if hasattr(self, "test"):
            self.test.set_label(t("stop") if app.player else t("test"))

    def city_changed(self):
        c = self.app.cfg["city"]
        self.city_button.set_label((c.get("name_ar") if self.app.lang == "ar" else c.get("name")) if c else self.app.t("no_city"))
        self.update()

    def show_page(self, settings):
        self.stack.set_visible_child_name("settings" if settings else "times")
        self.back.set_visible(settings)
        self.gear.set_visible(not settings)
        self.header.set_title(self.app.t("settings") if settings else self.app.t("app"))
        self.update()

    def on_settings(self):
        return self.stack.get_visible_child_name() == "settings"


def main():
    if "--version" in sys.argv:
        print(f"Adhano {VERSION}")
        return
    Gst.init(None)
    GLib.set_prgname("adhano")
    GLib.set_application_name("Adhano")
    # ADHANO_APP_ID lets a test copy run beside the installed one.
    application = Gtk.Application(application_id=os.environ.get("ADHANO_APP_ID", "com.yazanmy.Adhano"))
    state = {"app": None, "background": "--background" in sys.argv}

    def startup(a):
        provider = Gtk.CssProvider()
        provider.load_from_data(CSS)
        Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        a.hold()  # stays alive in the top bar with no window open
        App.application = a
        state["app"] = App()

    def activate(_a):
        # First start at login stays in the top bar; any later launch opens the window.
        if state["background"]:
            state["background"] = False
            return
        state["app"].open_window()

    application.connect("startup", startup)
    application.connect("activate", activate)
    application.run([a for a in sys.argv if a != "--background"])


if __name__ == "__main__":
    main()
