# -*- coding: utf-8 -*-
"""
MPC Tray Watcher v41
Windows-Tray-Tool zur Überwachung der MPC-Archivseite.

Benötigt:
  python -m pip install pystray pillow requests

Build:
  python -m PyInstaller --noconsole --onefile --name MPCTrayWatcher mpc_tray_v41.py
"""

from __future__ import annotations

import csv
import json
import locale
import os
import re
import subprocess
import sys
import tempfile
import threading
import time
import traceback
import webbrowser
import zipfile
from collections import deque
from datetime import datetime, timedelta
from email.message import EmailMessage
from email.policy import SMTP
from html import unescape
from pathlib import Path
from urllib.parse import quote, urljoin

import requests
from PIL import Image, ImageDraw
from pystray import Icon, Menu, MenuItem

import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog, ttk


URL = "https://www.minorplanetcenter.net/iau/ECS/MPCArchive/MPCArchive_TBL.html"
APP_NAME = "MPCTrayWatcher"
APP_VERSION = "2026.05.27.1"
WEB_BASE_URL = "https://www.jostjahn.de/amrum-software/MPCTrayWatcher/"
WEB_URL = WEB_BASE_URL + "index.html"
HELP_URL = WEB_BASE_URL + "hilfe.html"
INFO_URL = WEB_BASE_URL + "info.html"
VERSIONS_URL = WEB_BASE_URL + "versionen.html"
SETUP_URL = WEB_BASE_URL + "downloads/MPCTrayWatcherSetup.exe"
VERSIONS_TXT_URL = WEB_BASE_URL + "versions.txt"
COUNTER_URL = "https://www.jostjahn.de/software/MPC-Seiten.txt"
COUNTER_TIMEOUT_SECONDS = 0.8
UPDATE_CHECK_TIMEOUT_SECONDS = 5
UPDATE_CHECK_INTERVAL_DAYS = 28
REGISTRY_APP_KEY = r"Software\AmrumSoftware\MPC Tray Watcher"
AUTOSTART_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
AUTOSTART_APPROVED_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"
AUTOSTART_APPROVED_STARTUP_FOLDER_KEY = r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder"
AUTOSTART_APPROVED_ENABLED = bytes([2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
AUTOSTART_APPROVED_DISABLED = bytes([3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
AUTOSTART_TASK_NAME = "MPCTrayWatcher"
AUTOSTART_STARTUP_SHORTCUT_NAME = "MPCTrayWatcher.lnk"
AUTOSTART_ARGUMENT = "--autostart"
SUPPORT_EMAIL = "mpc-pubdate@jostjahn.de"

# ---------------------------------------------------------------------------
# v41 single-instance guard
# ---------------------------------------------------------------------------
_SINGLE_INSTANCE_MUTEX_HANDLES: list[int] = []
_SINGLE_INSTANCE_SOCKET = None
_SINGLE_INSTANCE_CHECKED = False
_SINGLE_INSTANCE_ALLOWED = True


def _create_named_mutex(name: str) -> bool | None:
    """Return False if an existing mutex was found, True if acquired."""
    try:
        import ctypes
        from ctypes import wintypes

        kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
        create_mutex = kernel32.CreateMutexW
        create_mutex.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
        create_mutex.restype = wintypes.HANDLE

        ctypes.set_last_error(0)
        handle = create_mutex(None, False, name)
        if not handle:
            return None
        _SINGLE_INSTANCE_MUTEX_HANDLES.append(handle)
        return ctypes.get_last_error() != 183
    except Exception as exc:
        write_startup_log(f"Mutex check failed for {name}: {exc}")
        return None


def _bind_single_instance_socket() -> bool:
    global _SINGLE_INSTANCE_SOCKET
    try:
        import socket

        _SINGLE_INSTANCE_SOCKET = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        _SINGLE_INSTANCE_SOCKET.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
        _SINGLE_INSTANCE_SOCKET.bind(("127.0.0.1", 47635))
        _SINGLE_INSTANCE_SOCKET.listen(1)
        return True
    except Exception as exc:
        write_startup_log(f"Socket fallback says instance exists: {exc}")
        return False


def ensure_single_instance_v41() -> bool:
    """Return True only for the first running instance."""
    global _SINGLE_INSTANCE_CHECKED, _SINGLE_INSTANCE_ALLOWED

    if _SINGLE_INSTANCE_CHECKED:
        return _SINGLE_INSTANCE_ALLOWED
    _SINGLE_INSTANCE_CHECKED = True

    if os.name == "nt":
        acquired_any_mutex = False
        for name in (
            "Local\\JostJahn_MPCTrayWatcher_SingleInstance",
            "Local\\JostJahn_MPCTrayWatcher_v41_SingleInstance",
            "Global\\JostJahn_MPCTrayWatcher_v41_SingleInstance",
            "Global\\MPCTrayWatcher_SingleInstance_JostJahn",
        ):
            result = _create_named_mutex(name)
            if result is False:
                _SINGLE_INSTANCE_ALLOWED = False
                write_startup_log(f"Existing instance detected by mutex: {name}")
                return False
            if result is True:
                acquired_any_mutex = True
        if acquired_any_mutex:
            _SINGLE_INSTANCE_ALLOWED = True
            return True

    _SINGLE_INSTANCE_ALLOWED = _bind_single_instance_socket()
    return _SINGLE_INSTANCE_ALLOWED


def is_autostart_invocation() -> bool:
    return any(arg.strip().lower() == AUTOSTART_ARGUMENT for arg in sys.argv[1:])


def exit_if_already_running_v41() -> None:
    if ensure_single_instance_v41():
        return
    if is_autostart_invocation():
        write_startup_log("Existing instance detected during autostart; exiting silently.")
        raise SystemExit(0)
    message = "MPC Tray Watcher läuft bereits."
    state = read_runtime_state()
    if state:
        pid = state.get("pid")
        executable = state.get("executable")
        started_at = state.get("started_at")
        details = [f"PID: {pid}" if pid else "", f"Start: {started_at}" if started_at else "", f"EXE: {executable}" if executable else ""]
        details = [part for part in details if part]
        if details:
            message += "\n\n" + "\n".join(details)
    try:
        import tkinter as _tk
        from tkinter import messagebox as _messagebox
        root = _tk.Tk()
        root.withdraw()
        root.attributes("-topmost", True)
        _messagebox.showinfo("MPC Tray Watcher", message, parent=root)
        root.destroy()
    except Exception as exc:
        write_startup_log(f"Already-running dialog failed: {exc}")
    raise SystemExit(0)

APP_DOMAIN = "amrum-software.de"
APP_EMAIL = "mpc-pubdate@jostjahn.de"
APP_AUTHOR = "Jost Jahn"

def get_info_text() -> str:
    """Return central about text for the Info dialog."""
    return (
        "MPC Tray Watcher\n\n"
        f"Version {APP_VERSION}\n\n"
        f"{APP_AUTHOR}\n"
        f"{APP_DOMAIN}\n"
        f"{APP_EMAIL}\n\n"
        "Weitere Sprachen ergänze ich auf Anfrage."
    )

BUILD_TIME = "2026-05-27 10:19:03"
DEFAULT_INTERVAL_SECONDS = 900
PERFORMANCE_SNAPSHOT_INTERVAL_SECONDS = 1800
SOUND_ALARM_REPEAT_SECONDS = 60
SOUND_ALARM_MAX_REPEATS = 10
SUPPORTED_LANGUAGES = ("de", "en", "es", "fr", "it", "pt", "ru")


def setup_download_url(version: str = APP_VERSION) -> str:
    return f"{SETUP_URL}?v={quote(version)}"

def _first_writable_app_dir(*base_dirs: str | None) -> Path:
    candidates: list[Path] = []
    for base_dir in base_dirs:
        if base_dir:
            candidates.append(Path(base_dir) / APP_NAME)
    candidates.append(Path.home() / APP_NAME)
    candidates.append(Path(tempfile.gettempdir()) / APP_NAME)
    candidates.append(Path.cwd() / APP_NAME)

    for candidate in candidates:
        try:
            candidate.mkdir(parents=True, exist_ok=True)
            test_file = candidate / ".write_test"
            test_file.write_text("ok", encoding="utf-8")
            test_file.unlink(missing_ok=True)
            return candidate
        except Exception:
            pass
    return Path.cwd()


CONFIG_DIR = _first_writable_app_dir(os.environ.get("APPDATA"), os.environ.get("LOCALAPPDATA"))
CONFIG_FILE = CONFIG_DIR / "config.json"
LOG_FILE = CONFIG_DIR / "log.txt"
HISTORY_FILE = CONFIG_DIR / "history.json"
CSV_FILE = CONFIG_DIR / "mpc_current_table.csv"
REPORT_DIR = CONFIG_DIR / "fehlerberichte"

RUNTIME_DIR = _first_writable_app_dir(os.environ.get("LOCALAPPDATA"), os.environ.get("APPDATA"))
RUNTIME_STATE_FILE = RUNTIME_DIR / "runtime_state.json"
STARTUP_LOG_FILE = RUNTIME_DIR / "startup.log"


def _now_text() -> str:
    return time.strftime("%Y-%m-%d %H:%M:%S")


def write_startup_log(message: str) -> None:
    try:
        with STARTUP_LOG_FILE.open("a", encoding="utf-8") as f:
            f.write(f"{_now_text()}  {message}\n")
    except Exception:
        pass


def read_runtime_state() -> dict:
    try:
        if RUNTIME_STATE_FILE.exists():
            return json.loads(RUNTIME_STATE_FILE.read_text(encoding="utf-8"))
    except Exception as exc:
        write_startup_log(f"Runtime state read failed: {exc}")
    return {}


def write_runtime_state(status: str, sync_registry: bool = True, **extra) -> None:
    state = {
        "app": APP_NAME,
        "version": APP_VERSION,
        "status": status,
        "pid": os.getpid(),
        "executable": sys.executable,
        "cwd": str(Path.cwd()),
        "started_at": STARTED_AT,
        "updated_at": _now_text(),
        "tray_icon_registered": bool(extra.pop("tray_icon_registered", False)),
        "tray_icon_visible": bool(extra.pop("tray_icon_visible", False)),
        "tray_icon_last_error": str(extra.pop("tray_icon_last_error", "")),
    }
    state.update(extra)
    try:
        RUNTIME_STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
        if sync_registry:
            try:
                sync_app_registry(status=status)
            except NameError:
                pass
    except Exception as exc:
        write_startup_log(f"Runtime state write failed: {exc}")


def runtime_metrics() -> dict[str, object]:
    metrics: dict[str, object] = {
        "uptime_seconds": int(max(0, time.time() - STARTED_AT_EPOCH)),
        "cpu_process_seconds": round(time.process_time(), 3),
        "python_thread_count": threading.active_count(),
        "sound_alarm_active": bool(sound_alarm_active),
        "sound_alarm_repeats": int(sound_alarm_repeats),
        "log_entries_memory_count": len(log_entries),
    }
    if last_icon_signature:
        metrics["tray_icon_state"] = last_icon_signature[0]
    if LOG_FILE.exists():
        try:
            metrics["log_file_bytes"] = LOG_FILE.stat().st_size
        except Exception:
            pass
    if os.name == "nt":
        try:
            import ctypes
            from ctypes import wintypes

            class ProcessMemoryCountersEx(ctypes.Structure):
                _fields_ = [
                    ("cb", wintypes.DWORD),
                    ("PageFaultCount", wintypes.DWORD),
                    ("PeakWorkingSetSize", ctypes.c_size_t),
                    ("WorkingSetSize", ctypes.c_size_t),
                    ("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
                    ("QuotaPagedPoolUsage", ctypes.c_size_t),
                    ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
                    ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
                    ("PagefileUsage", ctypes.c_size_t),
                    ("PeakPagefileUsage", ctypes.c_size_t),
                    ("PrivateUsage", ctypes.c_size_t),
                ]

            counters = ProcessMemoryCountersEx()
            counters.cb = ctypes.sizeof(counters)
            process = ctypes.windll.kernel32.GetCurrentProcess()
            if ctypes.windll.psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), counters.cb):
                metrics["working_set_bytes"] = int(counters.WorkingSetSize)
                metrics["private_bytes"] = int(counters.PrivateUsage)
                metrics["pagefile_bytes"] = int(counters.PagefileUsage)
            handle_count = wintypes.DWORD()
            if ctypes.windll.kernel32.GetProcessHandleCount(process, ctypes.byref(handle_count)):
                metrics["handle_count"] = int(handle_count.value)
        except Exception as exc:
            metrics["process_metrics_error"] = str(exc)
    return metrics


def maybe_write_performance_snapshot(force: bool = False) -> None:
    global last_performance_snapshot_at
    now = time.monotonic()
    if not force and now - last_performance_snapshot_at < PERFORMANCE_SNAPSHOT_INTERVAL_SECONDS:
        return
    last_performance_snapshot_at = now
    write_runtime_state(
        "running",
        sync_registry=False,
        tray_icon_registered=True,
        tray_icon_visible=True,
        **runtime_metrics(),
    )


def install_exception_logging() -> None:
    def log_unhandled_exception(exc_type, exc, tb):
        try:
            text = "".join(traceback.format_exception(exc_type, exc, tb))
            write_startup_log("Unhandled exception:\n" + text)
            write_runtime_state("crashed", tray_icon_last_error=str(exc))
        finally:
            sys.__excepthook__(exc_type, exc, tb)

    sys.excepthook = log_unhandled_exception


STARTED_AT_EPOCH = time.time()
STARTED_AT = _now_text()
install_exception_logging()

ROW_RE = re.compile(r"(?is)<tr\b[^>]*>(.*?)</tr>")
CELL_RE = re.compile(r"(?is)<t[dh]\b[^>]*>(.*?)</t[dh]>")
LINK_RE = re.compile(r"""(?is)<a\b[^>]*href\s*=\s*['"]([^'"]+)['"][^>]*>(.*?)</a>""")
DATE_RE = re.compile(r"\b\d{4}/\d{2}/\d{2}\b")


BASE_EN = {
    "show_data": "Show data", "pause": "Pause", "resume": "Resume",
    "interval": "Set interval ({minutes} min)", "autostart": "Autostart",
    "check_autostart": "Autostart prüfen",
    "autostart_ok": "Autostart ist aktiviert.",
    "autostart_missing": "Autostart ist nicht aktiviert.",
    "check_autostart": "Check autostart",
    "autostart_ok": "Autostart is enabled.",
    "autostart_missing": "Autostart is not enabled.",
    "repair_autostart": "Repair autostart",
    "autostart_repair_ok": "Autostart was repaired.",
    "autostart_repair_failed": "Autostart repair failed:\n{error}",
    "show_diagnostics": "Show diagnostics",
    "diagnostics_title": "MPC Tray Watcher - Diagnostics",
    "copy_diagnostics": "Copy diagnostics",
    "save_diagnostics": "Save diagnostics",
    "diagnostics_copied": "Diagnostics were copied to the clipboard.",
    "diagnostics_saved": "Diagnostics saved:\n{path}",
    "open_site": "Open original website", "language": "Language", "help": "Help",
    "info": "About", "exit": "Exit", "title": "MPC Tray Watcher - MPC issues",
    "top": "Last known top row: {line}", "date": "Date", "mpc": "MPC issues",
    "mps": "MPS issue", "mpo": "MPO issues", "ellipsis": "...",
    "close": "Close", "open_page_close": "Open original website and close window",
    "interval_title": "Set interval", "interval_prompt": "Check interval in minutes:",
    "no_rows": "No MPC date rows found.", "sound_alarm": "Voice alarm",
    "stop_sound": "Stop voice alarm", "test_sound": "Test voice alarm",
    "sound_message": "New data file at the MPC", "open_issue_title": "Open issue",
    "open_mpc": "Open MPC issues", "open_mps": "Open MPS issue", "open_mpo": "Open MPO issues",
    "no_link": "No link available.", "reset_known": "Reset last known state",
    "mark_read": "Mark change as read", "log": "Show log", "log_title": "MPC Tray Watcher - Log",
    "reset_done": "The saved state was set to the current top MPC row.",
    "reset_no_data": "No current MPC state found.", "log_empty": "No log entries yet.",
    "open_log_file": "Open log file", "clear_log": "Clear log", "log_cleared": "The log has been cleared.",
    "export_csv": "Export table as CSV", "csv_done": "CSV file created:\n{path}",
    "csv_error": "CSV export failed:\n{error}", "open_csv": "Open last CSV",
    "csv_missing": "No CSV file exists.", "export_table": "Export table",
    "open_export": "Open export", "log_export": "Export log",
    "log_export_done": "Log export created:\n{path}", "offline": "No connection to MPC",
    "new_entries": "{count} new entries detected",
    "info_text": "MPC Tray Watcher\nVersion {version}\nBuild {build_time}\n\nJost Jahn\namrum-software.de\nmpc-pubdate@jostjahn.de\n\nAdditional languages can be added on request.",
    "help_text": "MPC Tray Watcher monitors the MPC archive page for new issues.\n\nIt checks the top table row that starts with a date in yyyy/mm/dd format.\n\nIf this row changes compared with the saved state, the tray icon blinks and an optional voice alarm repeats for a limited time.\n\nShow data opens the MPC table with Date, MPC issues, MPS issue and MPO issues.\n\nCSV export and opening are located at the bottom of the data window.\n\nThe log opens from the tray; clearing and exporting are inside the log window.\n\nNo alarm is raised on the first start without comparison data.",
}

I18N = {lang: dict(BASE_EN) for lang in SUPPORTED_LANGUAGES}
I18N["de"].update({
    "show_data": "Daten anzeigen", "pause": "Pause", "resume": "Fortsetzen",
    "interval": "Intervall einstellen ({minutes} min)", "open_site": "Original-Webseite öffnen",
    "language": "Sprache", "help": "Hilfe", "info": "Info", "exit": "Beenden",
    "title": "MPC Tray Watcher - MPC-Ausgaben", "top": "Oberste bekannte Zeile: {line}",
    "date": "Datum", "mpc": "MPC-Ausgaben", "mps": "MPS-Ausgabe", "mpo": "MPO-Ausgaben",
    "close": "Schließen", "open_page_close": "Original-Webseite öffnen und Fenster schließen",
    "interval_title": "Intervall einstellen", "interval_prompt": "Prüfintervall in Minuten:",
    "no_rows": "Keine MPC-Datumszeilen gefunden.", "sound_alarm": "Sprachalarm",
    "stop_sound": "Sprachalarm beenden", "test_sound": "Sprachalarm testen",
    "sound_message": "Neue Datendatei beim MPC", "open_issue_title": "Ausgabe öffnen",
    "open_mpc": "MPC-Ausgaben öffnen", "open_mps": "MPS-Ausgabe öffnen",
    "open_mpo": "MPO-Ausgaben öffnen", "no_link": "Kein Link vorhanden.",
    "reset_known": "Letzten Stand zurücksetzen", "mark_read": "Änderung als gelesen markieren",
    "log": "Protokoll anzeigen", "log_title": "MPC Tray Watcher - Protokoll",
    "reset_done": "Der gespeicherte Stand wurde auf die aktuell oberste MPC-Zeile gesetzt.",
    "reset_no_data": "Kein aktueller MPC-Stand gefunden.", "log_empty": "Noch keine Protokolleinträge vorhanden.",
    "open_log_file": "Protokolldatei öffnen", "clear_log": "Protokoll löschen",
    "log_cleared": "Das Protokoll wurde gelöscht.", "export_csv": "Tabelle als CSV exportieren",
    "csv_done": "CSV-Datei wurde erstellt:\n{path}", "csv_error": "CSV-Export fehlgeschlagen:\n{error}",
    "open_csv": "Letzte CSV öffnen", "csv_missing": "Keine CSV-Datei vorhanden.",
    "export_table": "Tabelle exportieren", "open_export": "Export öffnen",
    "log_export": "Protokoll exportieren", "log_export_done": "Protokollexport wurde erstellt:\n{path}",
    "offline": "Keine Verbindung zum MPC", "new_entries": "{count} neue Einträge erkannt",
    "repair_autostart": "Autostart reparieren",
    "autostart_repair_ok": "Autostart wurde repariert.",
    "autostart_repair_failed": "Autostart-Reparatur fehlgeschlagen:\n{error}",
    "show_diagnostics": "Diagnose anzeigen/kopieren",
    "diagnostics_title": "MPC Tray Watcher - Diagnose",
    "copy_diagnostics": "Diagnose kopieren",
    "save_diagnostics": "Diagnose speichern",
    "diagnostics_copied": "Die Diagnose wurde in die Zwischenablage kopiert.",
    "diagnostics_saved": "Die Diagnose wurde gespeichert:\n{path}",
    "info_text": "MPC Tray Watcher\nVersion {version}\nBuild {build_time}\n\nJost Jahn\namrum-software.de\nmpc-pubdate@jostjahn.de\n\nWeitere Sprachen ergänze ich auf Anfrage.",
    "help_text": "MPC Tray Watcher überwacht die MPC-Archivseite auf neue Ausgaben.\n\nGeprüft wird die oberste Tabellenzeile, die mit einem Datum im Format yyyy/mm/dd beginnt.\n\nWenn sich diese Zeile gegenüber dem gespeicherten Stand ändert, blinkt das Tray-Symbol und optional läuft der Sprachalarm nur zeitlich begrenzt.\n\nDaten anzeigen öffnet die MPC-Tabelle mit Datum, MPC-Ausgaben, MPS-Ausgabe und MPO-Ausgaben.\n\nCSV-Export und CSV-Öffnen befinden sich unten im Datenfenster.\n\nDas Protokoll wird über das Tray geöffnet; Löschen und Export befinden sich im Protokollfenster.\n\nBeim allerersten Start ohne Vergleichsdaten erfolgt kein Alarm.",
})
I18N["es"].update({"show_data": "Mostrar datos", "pause": "Pausar", "resume": "Continuar", "language": "Idioma", "help": "Ayuda", "info": "Información", "exit": "Salir", "date": "Fecha", "sound_message": "Nuevo archivo de datos en el MPC"})
I18N["fr"].update({"show_data": "Afficher les données", "pause": "Pause", "resume": "Reprendre", "language": "Langue", "help": "Aide", "info": "Info", "exit": "Quitter", "sound_message": "Nouveau fichier de données au MPC"})
I18N["it"].update({"show_data": "Mostra dati", "pause": "Pausa", "resume": "Riprendi", "language": "Lingua", "help": "Aiuto", "info": "Info", "exit": "Esci", "sound_message": "Nuovo file di dati presso il MPC"})
I18N["pt"].update({"show_data": "Mostrar dados", "pause": "Pausar", "resume": "Retomar", "language": "Idioma", "help": "Ajuda", "info": "Informações", "exit": "Sair", "sound_message": "Novo arquivo de dados no MPC"})
I18N["ru"].update({"show_data": "Показать данные", "pause": "Пауза", "resume": "Продолжить", "language": "Язык", "help": "Справка", "info": "Информация", "exit": "Выход", "sound_message": "Новый файл данных в MPC"})

EXTRA_I18N = {
    "de": {
        "report_issue": "Fehler melden",
        "report_issue_confirm": "Es wird jetzt ein Fehlerbericht vorbereitet.\n\nDie Registry-Einträge, die interne Protokolldatei und die aktuelle Konfiguration werden als ZIP-Datei gesammelt. Der Pfad wird in die Zwischenablage kopiert und ein vorbereiteter E-Mail-Entwurf wird geöffnet.\n\nSoll jetzt fortgefahren werden?",
        "report_issue_created": "Der Fehlerbericht wurde erstellt:\n{path}\n\nDer Pfad wurde in die Zwischenablage kopiert.",
        "report_issue_failed": "Der Fehlerbericht konnte nicht erstellt werden:\n{error}",
        "report_mail_body": "Guten Tag,\n\nim Anhang befindet sich der automatisch erstellte Fehlerbericht.\nBitte schildern Sie kurz, was passiert ist.\n\nHinweis: Die ZIP-Datei kann lokale Einstellungen und Protokolleinträge enthalten.",
        "online_help": "Online-Hilfe",
        "pdf_help": "PDF-Hilfe",
        "versions": "Versionsliste",
        "website": "Website",
        "installer_link": "Installer",
        "codex_developed": "Dieses Programm wurde komplett in Codex entwickelt.",
        "copy_log_path": "Protokollpfad kopieren",
        "log_path_copied": "Der Protokollpfad wurde in die Zwischenablage kopiert:\n{path}",
        "check_update": "Update prüfen",
        "update_check_policy": "Beim Start und danach spätestens alle vier Wochen wird geprüft, ob eine neue Version verfügbar ist.",
        "update_available_prompt": "Eine neue Version ist verfügbar.\n\nInstallierte Version: {current}\nNeue Version: {remote}\n\nSoll der Installer jetzt geöffnet werden?",
        "update_current": "Es ist keine neuere Version verfügbar.\n\nInstallierte Version: {current}\nWebseite: {remote}",
        "update_failed": "Die Update-Prüfung ist fehlgeschlagen:\n{error}",
    },
    "en": {
        "report_issue": "Report issue",
        "report_issue_confirm": "An issue report will now be prepared.\n\nThe registry entries, the internal log file and the current configuration will be collected into a ZIP file. The path will be copied to the clipboard and a prepared email draft will be opened.\n\nContinue now?",
        "report_issue_created": "The issue report was created:\n{path}\n\nThe path was copied to the clipboard.",
        "report_issue_failed": "The issue report could not be created:\n{error}",
        "report_mail_body": "Hello,\n\nThe automatically created issue report is attached.\nPlease briefly describe what happened.\n\nNote: The ZIP file can contain local settings and log entries.",
        "online_help": "Online help",
        "pdf_help": "PDF help",
        "versions": "Versions",
        "website": "Website",
        "installer_link": "Installer",
        "codex_developed": "This program was developed completely in Codex.",
        "copy_log_path": "Copy log path",
        "log_path_copied": "The log path was copied to the clipboard:\n{path}",
        "check_update": "Check for updates",
        "update_check_policy": "At startup and then at least every four weeks the app checks whether a new version is available.",
        "update_available_prompt": "A new version is available.\n\nInstalled version: {current}\nNew version: {remote}\n\nOpen the installer now?",
        "update_current": "No newer version is available.\n\nInstalled version: {current}\nWebsite: {remote}",
        "update_failed": "The update check failed:\n{error}",
    },
    "es": {
        "report_issue": "Informar error",
        "report_issue_confirm": "Ahora se preparará un informe de error.\n\nLas entradas del registro, el archivo de registro interno y la configuración actual se reunirán en un archivo ZIP. La ruta se copiará al portapapeles y se abrirá un borrador de correo preparado.\n\n¿Continuar ahora?",
        "report_issue_created": "Se ha creado el informe de error:\n{path}\n\nLa ruta se ha copiado al portapapeles.",
        "report_issue_failed": "No se pudo crear el informe de error:\n{error}",
        "report_mail_body": "Hola:\n\nSe adjunta el informe de error creado automáticamente.\nDescriba brevemente qué ocurrió.\n\nNota: El archivo ZIP puede contener ajustes locales y entradas de registro.",
        "online_help": "Ayuda en línea",
        "pdf_help": "Ayuda PDF",
        "versions": "Versiones",
        "website": "Sitio web",
        "installer_link": "Instalador",
        "codex_developed": "Este programa se desarrolló completamente en Codex.",
        "copy_log_path": "Copiar ruta del registro",
        "log_path_copied": "La ruta del registro se copió al portapapeles:\n{path}",
        "check_update": "Buscar actualizaciones",
        "update_check_policy": "Al iniciar y luego al menos cada cuatro semanas, la app comprueba si hay una nueva versión.",
        "update_available_prompt": "Hay una nueva versión disponible.\n\nVersión instalada: {current}\nNueva versión: {remote}\n\n¿Abrir ahora el instalador?",
        "update_current": "No hay una versión más reciente.\n\nVersión instalada: {current}\nSitio web: {remote}",
        "update_failed": "La comprobación de actualización falló:\n{error}",
    },
    "fr": {
        "report_issue": "Signaler une erreur",
        "report_issue_confirm": "Un rapport d'erreur va être préparé.\n\nLes entrées du registre, le journal interne et la configuration actuelle seront regroupés dans un fichier ZIP. Le chemin sera copié dans le presse-papiers et un brouillon d'e-mail préparé sera ouvert.\n\nContinuer ?",
        "report_issue_created": "Le rapport d'erreur a été créé :\n{path}\n\nLe chemin a été copié dans le presse-papiers.",
        "report_issue_failed": "Le rapport d'erreur n'a pas pu être créé :\n{error}",
        "report_mail_body": "Bonjour,\n\nLe rapport d'erreur créé automatiquement est joint.\nVeuillez décrire brièvement ce qui s'est passé.\n\nRemarque : le fichier ZIP peut contenir des paramètres locaux et des entrées de journal.",
        "online_help": "Aide en ligne",
        "pdf_help": "Aide PDF",
        "versions": "Versions",
        "website": "Site web",
        "installer_link": "Installateur",
        "codex_developed": "Ce programme a été entièrement développé dans Codex.",
        "copy_log_path": "Copier le chemin du journal",
        "log_path_copied": "Le chemin du journal a été copié dans le presse-papiers :\n{path}",
        "check_update": "Chercher une mise à jour",
        "update_check_policy": "Au démarrage puis au moins toutes les quatre semaines, l'application vérifie si une nouvelle version est disponible.",
        "update_available_prompt": "Une nouvelle version est disponible.\n\nVersion installée : {current}\nNouvelle version : {remote}\n\nOuvrir l'installateur maintenant ?",
        "update_current": "Aucune version plus récente n'est disponible.\n\nVersion installée : {current}\nSite web : {remote}",
        "update_failed": "La vérification de mise à jour a échoué :\n{error}",
    },
    "it": {
        "report_issue": "Segnala errore",
        "report_issue_confirm": "Verrà preparata una segnalazione di errore.\n\nLe voci del registro, il file di registro interno e la configurazione attuale saranno raccolti in un file ZIP. Il percorso sarà copiato negli appunti e verrà aperta una bozza e-mail preparata.\n\nContinuare?",
        "report_issue_created": "La segnalazione di errore è stata creata:\n{path}\n\nIl percorso è stato copiato negli appunti.",
        "report_issue_failed": "La segnalazione di errore non è stata creata:\n{error}",
        "report_mail_body": "Buongiorno,\n\nin allegato si trova la segnalazione di errore creata automaticamente.\nDescriva brevemente cosa è successo.\n\nNota: il file ZIP può contenere impostazioni locali e voci del registro.",
        "online_help": "Aiuto online",
        "pdf_help": "Aiuto PDF",
        "versions": "Versioni",
        "website": "Sito web",
        "installer_link": "Installer",
        "codex_developed": "Questo programma è stato sviluppato completamente in Codex.",
        "copy_log_path": "Copia percorso log",
        "log_path_copied": "Il percorso del log è stato copiato negli appunti:\n{path}",
        "check_update": "Controlla aggiornamenti",
        "update_check_policy": "All'avvio e poi almeno ogni quattro settimane l'app controlla se è disponibile una nuova versione.",
        "update_available_prompt": "È disponibile una nuova versione.\n\nVersione installata: {current}\nNuova versione: {remote}\n\nAprire ora l'installer?",
        "update_current": "Non è disponibile una versione più recente.\n\nVersione installata: {current}\nSito web: {remote}",
        "update_failed": "Il controllo aggiornamenti non è riuscito:\n{error}",
    },
    "pt": {
        "report_issue": "Relatar erro",
        "report_issue_confirm": "Será preparado um relatório de erro.\n\nAs entradas do registro, o arquivo de log interno e a configuração atual serão reunidos em um arquivo ZIP. O caminho será copiado para a área de transferência e um rascunho de e-mail preparado será aberto.\n\nContinuar agora?",
        "report_issue_created": "O relatório de erro foi criado:\n{path}\n\nO caminho foi copiado para a área de transferência.",
        "report_issue_failed": "Não foi possível criar o relatório de erro:\n{error}",
        "report_mail_body": "Olá,\n\nO relatório de erro criado automaticamente está em anexo.\nDescreva brevemente o que aconteceu.\n\nObservação: o arquivo ZIP pode conter configurações locais e entradas de log.",
        "online_help": "Ajuda online",
        "pdf_help": "Ajuda PDF",
        "versions": "Versões",
        "website": "Site",
        "installer_link": "Instalador",
        "codex_developed": "Este programa foi desenvolvido completamente no Codex.",
        "copy_log_path": "Copiar caminho do log",
        "log_path_copied": "O caminho do log foi copiado para a área de transferência:\n{path}",
        "check_update": "Verificar atualizações",
        "update_check_policy": "Ao iniciar e depois pelo menos a cada quatro semanas, o app verifica se há uma nova versão.",
        "update_available_prompt": "Há uma nova versão disponível.\n\nVersão instalada: {current}\nNova versão: {remote}\n\nAbrir o instalador agora?",
        "update_current": "Não há versão mais recente disponível.\n\nVersão instalada: {current}\nSite: {remote}",
        "update_failed": "A verificação de atualização falhou:\n{error}",
    },
    "ru": {
        "report_issue": "Сообщить об ошибке",
        "report_issue_confirm": "Сейчас будет подготовлен отчет об ошибке.\n\nЗаписи реестра, внутренний файл журнала и текущая конфигурация будут собраны в ZIP-файл. Путь будет скопирован в буфер обмена, затем откроется подготовленный черновик письма.\n\nПродолжить?",
        "report_issue_created": "Отчет об ошибке создан:\n{path}\n\nПуть скопирован в буфер обмена.",
        "report_issue_failed": "Не удалось создать отчет об ошибке:\n{error}",
        "report_mail_body": "Здравствуйте,\n\nво вложении находится автоматически созданный отчет об ошибке.\nКратко опишите, что произошло.\n\nПримечание: ZIP-файл может содержать локальные настройки и записи журнала.",
        "online_help": "Онлайн-справка",
        "pdf_help": "PDF-справка",
        "versions": "Версии",
        "website": "Веб-сайт",
        "installer_link": "Установщик",
        "codex_developed": "Эта программа полностью разработана в Codex.",
        "copy_log_path": "Копировать путь журнала",
        "log_path_copied": "Путь журнала скопирован в буфер обмена:\n{path}",
        "check_update": "Проверить обновления",
        "update_check_policy": "При запуске и затем не реже одного раза в четыре недели приложение проверяет наличие новой версии.",
        "update_available_prompt": "Доступна новая версия.\n\nУстановленная версия: {current}\nНовая версия: {remote}\n\nОткрыть установщик сейчас?",
        "update_current": "Более новая версия недоступна.\n\nУстановленная версия: {current}\nСайт: {remote}",
        "update_failed": "Проверка обновления не удалась:\n{error}",
    },
}
for _lang, _items in EXTRA_I18N.items():
    I18N[_lang].update(_items)


def detect_language() -> str:
    candidates = []
    if os.name == "nt":
        try:
            import ctypes
            buf = ctypes.create_unicode_buffer(85)
            if ctypes.windll.kernel32.GetUserDefaultLocaleName(buf, 85):
                candidates.append(buf.value)
        except Exception:
            pass
    try:
        candidates.append(locale.getlocale()[0] or "")
    except Exception:
        pass
    for candidate in candidates:
        code = (candidate or "").split("_")[0].split("-")[0].lower()
        if code in SUPPORTED_LANGUAGES:
            return code
    return "en"


def load_config() -> dict:
    try:
        return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) if CONFIG_FILE.exists() else {}
    except Exception:
        return {}


config = load_config()
current_language = config.get("language") if config.get("language") in SUPPORTED_LANGUAGES else detect_language()
interval_seconds = int(config.get("interval_seconds", DEFAULT_INTERVAL_SECONDS))
running = bool(config.get("running", True))
sound_alarm_enabled = bool(config.get("sound_alarm_enabled", True))
last_top_key = str(config.get("last_top_key", ""))
unread_change = bool(config.get("unread_change", False))
last_update_check_iso = str(config.get("last_update_check_iso", ""))
last_update_notice_version = str(config.get("last_update_notice_version", ""))
last_rows: list[dict] = []
last_error = ""
offline_state = False
sound_alarm_active = False
sound_alarm_stop_event = threading.Event()
sound_alarm_lock = threading.Lock()
sound_alarm_thread: threading.Thread | None = None
sound_alarm_repeats = 0
update_check_running = False
update_check_lock = threading.Lock()
log_entries = deque(maxlen=300)
last_icon_signature: tuple[str, str] | None = None
last_performance_snapshot_at = 0.0


def t(key: str, **kwargs) -> str:
    text = I18N.get(current_language, I18N["en"]).get(key, I18N["en"].get(key, key))
    return text.format(**kwargs) if kwargs else text


def _hide_file(path: Path) -> None:
    if os.name != "nt":
        return
    try:
        import ctypes

        ctypes.windll.kernel32.SetFileAttributesW(str(path), 0x02)
    except Exception:
        pass


def ensure_internal_log_file() -> None:
    try:
        LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
        if not LOG_FILE.exists():
            LOG_FILE.write_text("", encoding="utf-8")
        _hide_file(LOG_FILE)
    except Exception:
        pass


def _registry_write_values(values: dict[str, object]) -> None:
    if os.name != "nt":
        return
    try:
        import winreg

        with winreg.CreateKey(winreg.HKEY_CURRENT_USER, REGISTRY_APP_KEY) as key:
            for name, value in values.items():
                if isinstance(value, bool):
                    winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, int(value))
                elif isinstance(value, int):
                    winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, value)
                else:
                    winreg.SetValueEx(key, name, 0, winreg.REG_SZ, str(value))
    except Exception as exc:
        write_startup_log(f"Registry sync failed: {exc}")


def sync_app_registry(status: str = "") -> None:
    try:
        autostart_state = get_autostart_state()
    except Exception as exc:
        autostart_state = {"last_error": str(exc)}
    _registry_write_values({
        "AppName": APP_NAME,
        "Version": APP_VERSION,
        "BuildTime": BUILD_TIME,
        "Author": APP_AUTHOR,
        "SupportEmail": SUPPORT_EMAIL,
        "WebsiteUrl": WEB_URL,
        "HelpUrl": HELP_URL,
        "InfoUrl": INFO_URL,
        "VersionsUrl": VERSIONS_URL,
        "VersionsTextUrl": VERSIONS_TXT_URL,
        "SetupUrl": setup_download_url(APP_VERSION),
        "UpdateCheckIntervalDays": UPDATE_CHECK_INTERVAL_DAYS,
        "OriginalMpcUrl": URL,
        "ExecutablePath": exe_path(),
        "ConfigDir": CONFIG_DIR,
        "ConfigFile": CONFIG_FILE,
        "LogFile": LOG_FILE,
        "HistoryFile": HISTORY_FILE,
        "CsvFile": CSV_FILE,
        "RuntimeStateFile": RUNTIME_STATE_FILE,
        "ErrorReportDir": REPORT_DIR,
        "Language": current_language,
        "IntervalSeconds": int(interval_seconds),
        "IsRunning": bool(running),
        "SoundAlarmEnabled": bool(sound_alarm_enabled),
        "UnreadChange": bool(unread_change),
        "LastTopKey": last_top_key,
        "LastUpdateCheckIso": last_update_check_iso,
        "LastUpdateNoticeVersion": last_update_notice_version,
        "AutostartEnabled": bool(autostart_state.get("enabled", False)),
        "AutostartCompleteEnabled": bool(autostart_state.get("complete_enabled", False)),
        "AutostartEffectiveEnabled": bool(autostart_state.get("effective_enabled", False)),
        "AutostartRegistryEnabled": bool(autostart_state.get("registry_enabled", False)),
        "AutostartRegistryConfigured": bool(autostart_state.get("registry_configured", False)),
        "AutostartRunValue": autostart_state.get("run_value", ""),
        "AutostartRunMatches": bool(autostart_state.get("run_matches", False)),
        "AutostartRunHasAutostartArgument": bool(autostart_state.get("run_has_autostart_arg", False)),
        "AutostartStartupApproved": autostart_state.get("startup_approved", ""),
        "AutostartStartupApprovedStatus": autostart_state.get("startup_approved_status", ""),
        "AutostartTaskName": autostart_state.get("task_name", AUTOSTART_TASK_NAME),
        "AutostartTaskExists": bool(autostart_state.get("task_exists", False)),
        "AutostartTaskMatches": bool(autostart_state.get("task_matches", False)),
        "AutostartTaskHasAutostartArgument": bool(autostart_state.get("task_has_autostart_arg", False)),
        "AutostartTaskConfigured": bool(autostart_state.get("task_configured", False)),
        "AutostartTaskEnabled": bool(autostart_state.get("task_enabled", False)),
        "AutostartTaskStatus": autostart_state.get("task_status", ""),
        "AutostartStartupFolderShortcut": autostart_state.get("startup_folder_shortcut", ""),
        "AutostartStartupFolderExists": bool(autostart_state.get("startup_folder_exists", False)),
        "AutostartStartupFolderMatches": bool(autostart_state.get("startup_folder_matches", False)),
        "AutostartStartupFolderHasAutostartArgument": bool(autostart_state.get("startup_folder_has_autostart_arg", False)),
        "AutostartStartupFolderConfigured": bool(autostart_state.get("startup_folder_configured", False)),
        "AutostartStartupFolderTarget": autostart_state.get("startup_folder_target", ""),
        "AutostartStartupFolderArguments": autostart_state.get("startup_folder_arguments", ""),
        "AutostartStartupFolderEnabled": bool(autostart_state.get("startup_folder_enabled", False)),
        "AutostartStartupFolderApproved": autostart_state.get("startup_folder_approved", ""),
        "AutostartStartupFolderApprovedStatus": autostart_state.get("startup_folder_approved_status", ""),
        "AutostartExpectedPath": autostart_state.get("expected_path", ""),
        "AutostartLastError": autostart_state.get("last_error", ""),
        "LastStatus": status,
        "StartedAt": STARTED_AT,
        "UpdatedAt": _now_text(),
    })


def save_config() -> None:
    config.update({
        "language": current_language,
        "interval_seconds": interval_seconds,
        "running": running,
        "sound_alarm_enabled": sound_alarm_enabled,
        "last_top_key": last_top_key,
        "unread_change": unread_change,
        "last_update_check_iso": last_update_check_iso,
        "last_update_notice_version": last_update_notice_version,
    })
    CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
    sync_app_registry("config_saved")


def timestamp() -> str:
    return time.strftime("%Y-%m-%d %H:%M:%S")


def load_log_entries() -> None:
    try:
        ensure_internal_log_file()
        if LOG_FILE.exists():
            for line in reversed(LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-300:]):
                if line.strip():
                    log_entries.append(line)
    except Exception:
        pass


def rotate_log_file() -> None:
    """Rotate log file when it grows beyond roughly 1 MB."""
    try:
        if LOG_FILE.exists() and LOG_FILE.stat().st_size > 1024 * 1024:
            old = LOG_FILE.with_suffix(".1.txt")
            if old.exists():
                old.unlink()
            LOG_FILE.rename(old)
    except Exception:
        pass


def add_log(message: str) -> None:
    line = f"{timestamp()}  {message}"
    log_entries.appendleft(line)
    try:
        ensure_internal_log_file()
        if LOG_FILE.exists() and LOG_FILE.stat().st_size > 1_000_000:
            backup = LOG_FILE.with_suffix(".old.txt")
            if backup.exists():
                backup.unlink()
            LOG_FILE.rename(backup)
        with LOG_FILE.open("a", encoding="utf-8") as f:
            f.write(line + "\n")
        _hide_file(LOG_FILE)
    except Exception:
        pass


def load_history() -> list[str]:
    try:
        return json.loads(HISTORY_FILE.read_text(encoding="utf-8"))[-50:] if HISTORY_FILE.exists() else []
    except Exception:
        return []


def save_history_entry(key: str) -> None:
    if not key:
        return
    hist = load_history()
    if key not in hist:
        hist.append(key)
        HISTORY_FILE.write_text(json.dumps(hist[-50:], indent=2, ensure_ascii=False), encoding="utf-8")


def count_new_entries(rows: list[dict]) -> int:
    hist = set(load_history())
    if not hist:
        return 0
    count = 0
    for row in rows:
        if row["key"] in hist:
            break
        count += 1
    return count


def clean(value: str) -> str:
    value = re.sub(r"(?is)<[^>]+>", " ", value)
    return re.sub(r"\s+", " ", unescape(value)).strip()


def parse_links(cell_html: str) -> list[dict]:
    links = []
    for href, label_html in LINK_RE.findall(cell_html):
        label = clean(label_html)
        if label:
            links.append({"label": label, "url": urljoin(URL, unescape(href))})
    if links:
        return links
    text = clean(cell_html)
    return [{"label": text, "url": URL}] if text else []


def row_fingerprint(row: dict) -> str:
    parts = [row.get("date", "")]
    for key in ("mpc", "mps", "mpo"):
        for item in row.get(key, []):
            parts.extend([item.get("label", ""), item.get("url", "")])
    return " | ".join(parts)


def parse_rows(html_text: str) -> list[dict]:
    rows = []
    for row_html in ROW_RE.findall(html_text):
        cells = CELL_RE.findall(row_html)
        date_match = DATE_RE.search(clean(row_html))
        if not date_match or len(cells) < 4:
            continue
        date = date_match.group(0)
        first_is_date = DATE_RE.search(clean(cells[0])) is not None
        data_cells = cells[1:] if first_is_date else cells
        if len(data_cells) < 3:
            continue
        row = {"date": date, "mpc": parse_links(data_cells[0]), "mps": parse_links(data_cells[1]), "mpo": parse_links(data_cells[2])}
        row["key"] = row_fingerprint(row)
        rows.append(row)
    return rows


def fetch_rows() -> list[dict]:
    global last_error, offline_state
    last_exc = None
    for attempt in range(1, 4):
        try:
            with requests.get(URL, timeout=20, headers={"User-Agent": "MPCTrayWatcher/1.0"}) as r:
                r.raise_for_status()
                rows = parse_rows(r.text)
            last_error = ""
            offline_state = False
            add_log(f"Check OK: {len(rows)} rows; top={rows[0]['key'] if rows else '-'}")
            return rows
        except Exception as exc:
            last_exc = exc
            time.sleep(min(attempt * 2, 6))
    last_error = str(last_exc)
    offline_state = True
    add_log("Check ERROR: " + last_error)
    return []


def version_tuple(value: str) -> tuple[int, ...]:
    parts: list[int] = []
    for chunk in str(value or "").strip().split("."):
        if not chunk:
            continue
        try:
            parts.append(int(chunk))
        except ValueError:
            return ()
    return tuple(parts)


def latest_version_from_text(text: str) -> str:
    latest = ""
    latest_key: tuple[int, ...] = ()
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        version = line.split("|", 1)[0].strip()
        key = version_tuple(version)
        if key and (not latest_key or key > latest_key):
            latest = version
            latest_key = key
    return latest


def iso_now() -> str:
    return datetime.now().isoformat(timespec="seconds")


def is_update_check_due() -> bool:
    if not last_update_check_iso:
        return True
    try:
        last_check = datetime.fromisoformat(last_update_check_iso)
    except ValueError:
        return True
    return datetime.now() - last_check >= timedelta(days=UPDATE_CHECK_INTERVAL_DAYS)


def messagebox_with_parent(kind: str, message: str) -> bool:
    root = None
    try:
        root = tk.Tk()
        root.withdraw()
        root.attributes("-topmost", True)
        if kind == "askyesno":
            return bool(messagebox.askyesno(APP_NAME, message, parent=root))
        if kind == "error":
            messagebox.showerror(APP_NAME, message, parent=root)
        else:
            messagebox.showinfo(APP_NAME, message, parent=root)
    except Exception as exc:
        add_log(f"Dialog ERROR: {exc}")
    finally:
        if root is not None:
            try:
                root.destroy()
            except Exception:
                pass
    return False


def show_update_notice(remote_version: str, force: bool = False) -> None:
    global last_update_notice_version
    if not force and last_update_notice_version == remote_version:
        return
    last_update_notice_version = remote_version
    save_config()
    add_log(f"Update available: local={APP_VERSION} remote={remote_version}")
    if messagebox_with_parent(
        "askyesno",
        t("update_available_prompt", current=APP_VERSION, remote=remote_version),
    ):
        webbrowser.open(setup_download_url(remote_version))


def update_check_worker(force: bool = False, notify_current: bool = False) -> None:
    global last_update_check_iso, update_check_running
    try:
        try:
            with requests.get(
                VERSIONS_TXT_URL,
                timeout=UPDATE_CHECK_TIMEOUT_SECONDS,
                headers={"User-Agent": f"{APP_NAME}/{APP_VERSION}"},
            ) as r:
                r.raise_for_status()
                remote_version = latest_version_from_text(r.text)
            if not remote_version:
                raise ValueError("No valid version line found")
            newer_available = version_tuple(remote_version) > version_tuple(APP_VERSION)
            last_update_check_iso = iso_now()
            save_config()
            add_log(f"Update check OK: local={APP_VERSION} remote={remote_version} newer={newer_available}")
            if newer_available:
                show_update_notice(remote_version, force=force)
            elif notify_current:
                messagebox_with_parent("info", t("update_current", current=APP_VERSION, remote=remote_version))
        except Exception as exc:
            last_update_check_iso = iso_now()
            save_config()
            add_log(f"Update check ERROR: {exc}")
            if notify_current:
                messagebox_with_parent("error", t("update_failed", error=str(exc)))
    finally:
        with update_check_lock:
            update_check_running = False


def queue_update_check(reason: str, force: bool = False, notify_current: bool = False) -> None:
    global update_check_running
    with update_check_lock:
        if update_check_running:
            return
        if not force and not is_update_check_due():
            return
        update_check_running = True
    add_log(f"Update check queued: {reason}")
    threading.Thread(
        target=update_check_worker,
        args=(force, notify_current),
        daemon=True,
        name="update-check",
    ).start()


def check_for_updates_now(icon=None, item=None) -> None:
    queue_update_check("manual", force=True, notify_current=True)


def make_icon(color: str) -> Image.Image:
    img = Image.new("RGB", (64, 64), color)
    draw = ImageDraw.Draw(img)
    draw.rectangle((0, 0, 63, 63), fill=color)
    try:
        from PIL import ImageFont
        font = ImageFont.truetype("arialbd.ttf", 44)
    except Exception:
        font = None
    text = "M"
    if font:
        bbox = draw.textbbox((0, 0), text, font=font)
        w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
    else:
        w, h = 24, 32
    draw.text(((64 - w) // 2, (64 - h) // 2 - 3), text, fill="white", font=font)
    return img


ICON_NORMAL = make_icon("blue")
ICON_ALERT = make_icon("red")
ICON_PAUSED = make_icon("gray")
ICON_OFFLINE = make_icon("black")
ICON_ERROR = make_icon("orange")


def exe_path() -> str:
    return sys.executable if getattr(sys, "frozen", False) else str(Path(__file__).resolve())


def _format_registry_data(value: object | None) -> str:
    if value is None:
        return "(missing)"
    if isinstance(value, bytes):
        return value.hex(" ")
    return str(value)


def _read_hkcu_value(subkey: str, name: str) -> tuple[object | None, str]:
    if os.name != "nt":
        return None, "registry unavailable"
    try:
        import winreg

        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, subkey) as key:
            value, _ = winreg.QueryValueEx(key, name)
        return value, ""
    except FileNotFoundError:
        return None, ""
    except Exception as exc:
        return None, str(exc)


def _startup_approved_status(value: object | None) -> str:
    if value is None:
        return "missing"
    if not isinstance(value, bytes) or not value:
        return "unexpected"
    if value[0] == AUTOSTART_APPROVED_ENABLED[0]:
        return "enabled"
    if value[0] == AUTOSTART_APPROVED_DISABLED[0]:
        return "disabled_by_windows"
    return f"unknown_{value[0]}"


def _run_schtasks(args: list[str]) -> tuple[int, str]:
    if os.name != "nt":
        return 1, "Task Scheduler unavailable"
    try:
        completed = subprocess.run(
            ["schtasks.exe", *args],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=20,
            creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
        )
    except Exception as exc:
        return 1, str(exc)
    output = "\n".join(part for part in (completed.stdout.strip(), completed.stderr.strip()) if part)
    return completed.returncode, output


def _run_powershell(args: list[str]) -> tuple[int, str]:
    if os.name != "nt":
        return 1, "PowerShell unavailable"
    try:
        completed = subprocess.run(
            ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", *args],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=20,
            creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
        )
    except Exception as exc:
        return 1, str(exc)
    output = "\n".join(part for part in (completed.stdout.strip(), completed.stderr.strip()) if part)
    return completed.returncode, output


def _ps_quoted(value: object) -> str:
    return "'" + str(value).replace("'", "''") + "'"


def _scheduled_task_status(output: str) -> str:
    for line in output.splitlines():
        if ":" not in line:
            continue
        label, value = line.split(":", 1)
        label = " ".join(label.strip().lower().split())
        value = value.strip().lower()
        if label in ("scheduled task state", "status der geplanten aufgabe"):
            if any(marker in value for marker in ("disabled", "deaktiviert")):
                return "disabled"
            if any(marker in value for marker in ("enabled", "aktiviert")):
                return "present"
    for pattern in (r"(?im)^\s*scheduled task state\s*:\s*disabled\b", r"(?im)^\s*status der geplanten aufgabe\s*:\s*deaktiviert\b"):
        if re.search(pattern, output):
            return "disabled"
    if output:
        return "present"
    return "missing"


def get_scheduled_autostart_task_state(expected_path: str) -> dict[str, object]:
    code, output = _run_schtasks(["/Query", "/TN", AUTOSTART_TASK_NAME, "/FO", "LIST", "/V"])
    lowered = output.lower()
    not_found = any(marker in lowered for marker in ("cannot find", "nicht gefunden", "nicht vorhanden"))
    if code != 0 and not_found:
        return {
            "task_name": AUTOSTART_TASK_NAME,
            "task_exists": False,
            "task_matches": False,
            "task_has_autostart_arg": False,
            "task_configured": False,
            "task_enabled": False,
            "task_status": "missing",
            "task_error": "",
        }
    if code != 0:
        return {
            "task_name": AUTOSTART_TASK_NAME,
            "task_exists": False,
            "task_matches": False,
            "task_has_autostart_arg": False,
            "task_configured": False,
            "task_enabled": False,
            "task_status": "error",
            "task_error": output,
        }
    status = _scheduled_task_status(output)
    task_matches = expected_path.lower() in output.lower()
    task_has_autostart_arg = AUTOSTART_ARGUMENT.lower() in output.lower()
    return {
        "task_name": AUTOSTART_TASK_NAME,
        "task_exists": True,
        "task_matches": task_matches,
        "task_has_autostart_arg": task_has_autostart_arg,
        "task_configured": task_matches and task_has_autostart_arg,
        "task_enabled": task_matches and status != "disabled",
        "task_status": status,
        "task_error": "",
    }


def startup_folder_shortcut_path() -> Path:
    appdata = os.environ.get("APPDATA")
    if appdata:
        base = Path(appdata)
    else:
        base = Path.home() / "AppData" / "Roaming"
    return base / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / AUTOSTART_STARTUP_SHORTCUT_NAME


def read_startup_shortcut(shortcut: Path) -> dict[str, str]:
    if not shortcut.exists():
        return {"target": "", "arguments": "", "working_directory": "", "error": ""}
    script = (
        "$ErrorActionPreference='Stop';"
        f"$shortcut={_ps_quoted(shortcut)};"
        "$wsh=New-Object -ComObject WScript.Shell;"
        "$link=$wsh.CreateShortcut($shortcut);"
        "[pscustomobject]@{"
        "TargetPath=$link.TargetPath;"
        "Arguments=$link.Arguments;"
        "WorkingDirectory=$link.WorkingDirectory"
        "} | ConvertTo-Json -Compress"
    )
    code, output = _run_powershell(["-Command", script])
    if code != 0:
        return {"target": "", "arguments": "", "working_directory": "", "error": output}
    try:
        data = json.loads(output)
    except Exception as exc:
        return {"target": "", "arguments": "", "working_directory": "", "error": f"{exc}: {output}"}
    if not isinstance(data, dict):
        return {"target": "", "arguments": "", "working_directory": "", "error": f"unexpected shortcut data: {output}"}
    return {
        "target": str(data.get("TargetPath") or ""),
        "arguments": str(data.get("Arguments") or ""),
        "working_directory": str(data.get("WorkingDirectory") or ""),
        "error": "",
    }


def get_startup_folder_autostart_state(expected_path: str) -> dict[str, object]:
    shortcut = startup_folder_shortcut_path()
    approved_value, approved_error = _read_hkcu_value(AUTOSTART_APPROVED_STARTUP_FOLDER_KEY, AUTOSTART_STARTUP_SHORTCUT_NAME)
    approved_status = _startup_approved_status(approved_value)
    exists = shortcut.exists()
    shortcut_state = read_startup_shortcut(shortcut)
    shortcut_target = shortcut_state["target"]
    shortcut_arguments = shortcut_state["arguments"]
    target_matches = bool(shortcut_target and shortcut_target.lower() == expected_path.lower())
    has_autostart_arg = AUTOSTART_ARGUMENT.lower() in shortcut_arguments.lower().split()
    enabled = exists and target_matches and approved_status != "disabled_by_windows"
    return {
        "startup_folder_shortcut": str(shortcut),
        "startup_folder_exists": exists,
        "startup_folder_matches": target_matches,
        "startup_folder_target": shortcut_target,
        "startup_folder_arguments": shortcut_arguments,
        "startup_folder_has_autostart_arg": has_autostart_arg,
        "startup_folder_configured": enabled and has_autostart_arg,
        "startup_folder_enabled": enabled,
        "startup_folder_approved": _format_registry_data(approved_value),
        "startup_folder_approved_status": approved_status,
        "startup_folder_error": "; ".join(item for item in (approved_error, shortcut_state["error"]) if item),
    }


def set_windows_startup_folder_approved(enabled: bool) -> None:
    if os.name != "nt":
        return
    import winreg

    with winreg.CreateKey(winreg.HKEY_CURRENT_USER, AUTOSTART_APPROVED_STARTUP_FOLDER_KEY) as key:
        if enabled:
            winreg.SetValueEx(key, AUTOSTART_STARTUP_SHORTCUT_NAME, 0, winreg.REG_BINARY, AUTOSTART_APPROVED_ENABLED)
        else:
            try:
                winreg.DeleteValue(key, AUTOSTART_STARTUP_SHORTCUT_NAME)
            except FileNotFoundError:
                pass


def set_startup_folder_autostart(enabled: bool) -> None:
    if os.name != "nt":
        return
    shortcut = startup_folder_shortcut_path()
    if enabled:
        expected = exe_path()
        script = (
            "$ErrorActionPreference='Stop';"
            f"$shortcut={_ps_quoted(shortcut)};"
            f"$target={_ps_quoted(expected)};"
            f"$working={_ps_quoted(Path(expected).parent)};"
            f"$icon={_ps_quoted(f'{expected},0')};"
            f"$arguments={_ps_quoted(AUTOSTART_ARGUMENT)};"
            "New-Item -ItemType Directory -Path (Split-Path -Parent $shortcut) -Force | Out-Null;"
            "$wsh=New-Object -ComObject WScript.Shell;"
            "$link=$wsh.CreateShortcut($shortcut);"
            "$link.TargetPath=$target;"
            "$link.Arguments=$arguments;"
            "$link.WorkingDirectory=$working;"
            "$link.IconLocation=$icon;"
            "$link.Description='MPC Tray Watcher Autostart';"
            "$link.Save()"
        )
        code, output = _run_powershell(["-Command", script])
        write_startup_log(f"Autostart startup-folder create rc={code}: {output or '-'}")
        set_windows_startup_folder_approved(True)
    else:
        try:
            shortcut.unlink(missing_ok=True)
            write_startup_log(f"Autostart startup-folder shortcut deleted: {shortcut}")
        except Exception as exc:
            write_startup_log(f"Autostart startup-folder delete failed: {exc}")
        set_windows_startup_folder_approved(False)


def set_scheduled_autostart_task(enabled: bool) -> None:
    if os.name != "nt":
        return
    if enabled:
        command = f'"{exe_path()}" {AUTOSTART_ARGUMENT}'
        code, output = _run_schtasks([
            "/Create",
            "/TN",
            AUTOSTART_TASK_NAME,
            "/TR",
            command,
            "/SC",
            "ONLOGON",
            "/RL",
            "LIMITED",
            "/F",
        ])
        write_startup_log(f"Autostart task create rc={code}: {output or '-'}")
    else:
        code, output = _run_schtasks(["/Delete", "/TN", AUTOSTART_TASK_NAME, "/F"])
        write_startup_log(f"Autostart task delete rc={code}: {output or '-'}")


def get_autostart_state() -> dict[str, object]:
    expected = exe_path()
    run_value, run_error = _read_hkcu_value(AUTOSTART_RUN_KEY, APP_NAME)
    approved_value, approved_error = _read_hkcu_value(AUTOSTART_APPROVED_RUN_KEY, APP_NAME)
    task_state = get_scheduled_autostart_task_state(expected)
    startup_folder_state = get_startup_folder_autostart_state(expected)
    run_text = str(run_value or "")
    run_matches = bool(run_text and expected.lower() in run_text.lower())
    run_has_autostart_arg = AUTOSTART_ARGUMENT.lower() in run_text.lower().split()
    approved_status = _startup_approved_status(approved_value)
    registry_enabled = run_matches and approved_status != "disabled_by_windows"
    registry_configured = registry_enabled and run_has_autostart_arg
    task_enabled = bool(task_state.get("task_enabled", False))
    task_configured = bool(task_state.get("task_configured", False))
    startup_folder_enabled = bool(startup_folder_state.get("startup_folder_enabled", False))
    startup_folder_configured = bool(startup_folder_state.get("startup_folder_configured", False))
    task_ok_for_complete = not task_state.get("task_exists", False) or task_configured
    errors = "; ".join(item for item in (
        run_error,
        approved_error,
        str(task_state.get("task_error", "")),
        str(startup_folder_state.get("startup_folder_error", "")),
    ) if item)
    return {
        "expected_path": expected,
        "run_value": run_text,
        "run_matches": run_matches,
        "run_has_autostart_arg": run_has_autostart_arg,
        "registry_enabled": registry_enabled,
        "registry_configured": registry_configured,
        "startup_approved": _format_registry_data(approved_value),
        "startup_approved_status": approved_status,
        "blocked_by_windows": approved_status == "disabled_by_windows",
        "complete_enabled": registry_configured and startup_folder_configured and task_ok_for_complete,
        "effective_enabled": registry_enabled or task_enabled or startup_folder_enabled,
        "enabled": registry_enabled or task_enabled or startup_folder_enabled,
        "last_error": errors,
        **task_state,
        **startup_folder_state,
    }


def log_autostart_state(context: str) -> dict[str, object]:
    state = get_autostart_state()
    write_startup_log(
        "Autostart "
        f"{context}: enabled={state['enabled']} "
        f"complete={state['complete_enabled']} "
        f"effective={state['effective_enabled']} "
        f"run_matches={state['run_matches']} "
        f"run_autostart_arg={state['run_has_autostart_arg']} "
        f"startup_approved={state['startup_approved_status']} "
        f"task_enabled={state['task_enabled']} "
        f"task_autostart_arg={state.get('task_has_autostart_arg', False)} "
        f"task_status={state['task_status']} "
        f"startup_folder_enabled={state['startup_folder_enabled']} "
        f"startup_folder_matches={state.get('startup_folder_matches', False)} "
        f"startup_folder_autostart_arg={state.get('startup_folder_has_autostart_arg', False)} "
        f"startup_folder={state['startup_folder_shortcut']} "
        f"startup_folder_target={state.get('startup_folder_target', '') or '(missing)'} "
        f"expected={state['expected_path']} "
        f"run={state['run_value'] or '(missing)'} "
        f"errors={state['last_error'] or '-'}"
    )
    return state


def is_autostart_blocked_by_windows() -> bool:
    return bool(get_autostart_state().get("blocked_by_windows", False))


def set_windows_autostart_approved(enabled: bool) -> None:
    if os.name != "nt":
        return
    import winreg

    with winreg.CreateKey(winreg.HKEY_CURRENT_USER, AUTOSTART_APPROVED_RUN_KEY) as key:
        if enabled:
            winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_BINARY, AUTOSTART_APPROVED_ENABLED)
        else:
            try:
                winreg.DeleteValue(key, APP_NAME)
            except FileNotFoundError:
                pass


def is_autostart_enabled() -> bool:
    try:
        return bool(get_autostart_state().get("enabled", False))
    except Exception as exc:
        write_startup_log(f"Autostart state read failed: {exc}")
        return False


def set_autostart(enabled: bool) -> None:
    if os.name != "nt":
        return
    import winreg

    log_autostart_state("before set")
    with winreg.CreateKey(winreg.HKEY_CURRENT_USER, AUTOSTART_RUN_KEY) as key:
        if enabled:
            winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, f'"{exe_path()}" {AUTOSTART_ARGUMENT}')
        else:
            try:
                winreg.DeleteValue(key, APP_NAME)
            except FileNotFoundError:
                pass
    set_windows_autostart_approved(enabled)
    set_scheduled_autostart_task(enabled)
    set_startup_folder_autostart(enabled)
    state = log_autostart_state("after set")
    if enabled and not state.get("effective_enabled", False):
        raise RuntimeError(f"Autostart konnte nicht aktiviert werden: {state.get('last_error') or state}")
    sync_app_registry("autostart_changed")


def ensure_default_autostart() -> None:
    if os.name == "nt":
        try:
            state = log_autostart_state("startup before ensure")
            if not state.get("complete_enabled", False):
                set_autostart(True)
            else:
                sync_app_registry("autostart_ok")
        except Exception as exc:
            write_startup_log(f"Autostart ensure failed: {exc}")
            try:
                sync_app_registry("autostart_failed")
            except Exception:
                pass


def label_list(links: list[dict]) -> str:
    return ", ".join(item["label"] for item in links)


def update_icon_state(icon: Icon, force: bool = False) -> None:
    global last_icon_signature
    if offline_state:
        state_name = "offline"
        image = ICON_OFFLINE
        title = f"{APP_NAME} {APP_VERSION} - " + t("offline")
    elif not running:
        state_name = "paused"
        image = ICON_PAUSED
        title = f"{APP_NAME} {APP_VERSION} - " + t("pause")
    elif unread_change:
        state_name = "alert"
        image = ICON_ALERT
        title = f"{APP_NAME} {APP_VERSION} - " + t("mark_read")
    else:
        state_name = "normal"
        image = ICON_NORMAL
        title = f"{APP_NAME} {APP_VERSION} - OK"
    signature = (state_name, title)
    if not force and signature == last_icon_signature:
        return
    icon.icon = image
    icon.title = title
    last_icon_signature = signature


def speak_once(message: str) -> None:
    if os.name == "nt":
        try:
            safe = message.replace("'", "''")
            cmd = "Add-Type -AssemblyName System.Speech; $s=New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Speak('" + safe + "')"
            subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=25, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
            return
        except Exception:
            pass
    try:
        root = tk.Tk(); root.bell(); root.destroy()
    except Exception:
        pass


def sound_alarm_loop() -> None:
    global sound_alarm_active, sound_alarm_repeats
    with sound_alarm_lock:
        sound_alarm_active = True
        sound_alarm_repeats = 0
    try:
        while not sound_alarm_stop_event.is_set() and sound_alarm_repeats < SOUND_ALARM_MAX_REPEATS:
            speak_once(t("sound_message"))
            with sound_alarm_lock:
                sound_alarm_repeats += 1
            if sound_alarm_repeats >= SOUND_ALARM_MAX_REPEATS:
                break
            sound_alarm_stop_event.wait(SOUND_ALARM_REPEAT_SECONDS)
    finally:
        with sound_alarm_lock:
            sound_alarm_active = False
        if not sound_alarm_stop_event.is_set() and unread_change:
            add_log(f"Voice alarm stopped after {sound_alarm_repeats} repeats; unread marker remains active")


def start_sound_alarm() -> None:
    global sound_alarm_thread
    if not sound_alarm_enabled:
        return
    with sound_alarm_lock:
        if sound_alarm_active or (sound_alarm_thread is not None and sound_alarm_thread.is_alive()):
            return
        sound_alarm_stop_event.clear()
        sound_alarm_thread = threading.Thread(target=sound_alarm_loop, daemon=True, name="sound-alarm")
        sound_alarm_thread.start()


def stop_sound_alarm(icon: Icon | None = None) -> None:
    global sound_alarm_active
    sound_alarm_stop_event.set()
    with sound_alarm_lock:
        sound_alarm_active = False
    if icon:
        icon.menu = create_menu()
        icon.update_menu()
        update_icon_state(icon)


def show_message(title: str, text: str, kind: str = "info") -> None:
    def worker():
        root = tk.Tk()
        root.withdraw()
        if kind == "error":
            messagebox.showerror(title, text)
        else:
            messagebox.showinfo(title, text)
        root.destroy()
    threading.Thread(target=worker, daemon=True).start()


def show_link_dialog(title: str, lines: list[str], links: list[tuple[str, str]]) -> None:
    def worker():
        root = tk.Tk()
        root.title(title)
        root.resizable(False, False)
        root.attributes("-topmost", True)
        frame = tk.Frame(root, padx=16, pady=14)
        frame.grid(row=0, column=0, sticky="nsew")
        for row, line in enumerate(lines):
            tk.Label(frame, text=line, justify="left", anchor="w").grid(row=row, column=0, sticky="w", pady=(0, 3))
        start_row = len(lines)
        for offset, (label, url) in enumerate(links):
            link = tk.Label(frame, text=label, fg="blue", cursor="hand2", anchor="w", font=("Segoe UI", 9, "underline"))
            link.grid(row=start_row + offset, column=0, sticky="w", pady=(5 if offset == 0 else 2, 2))
            link.bind("<Button-1>", lambda event, target=url: webbrowser.open(target))
        tk.Button(frame, text=t("close"), command=root.destroy).grid(row=start_row + len(links), column=0, sticky="e", pady=(12, 0))
        root.update_idletasks()
        width = root.winfo_width()
        height = root.winfo_height()
        x = max(0, (root.winfo_screenwidth() - width) // 2)
        y = max(0, (root.winfo_screenheight() - height) // 3)
        root.geometry(f"+{x}+{y}")
        root.mainloop()

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


def show_help_dialog(icon=None, item=None) -> None:
    show_link_dialog(
        t("help"),
        [
            "MPC Tray Watcher",
            f"Version {APP_VERSION}",
            "",
            t("help_text").split("\n\n", 1)[0],
            t("update_check_policy"),
            t("codex_developed"),
        ],
        [
            (t("online_help"), HELP_URL),
            (t("pdf_help"), WEB_BASE_URL + "downloads/MPCTrayWatcher-Hilfe.pdf"),
            (t("versions"), VERSIONS_URL),
        ],
    )


def show_info_dialog(icon=None, item=None) -> None:
    show_link_dialog(
        t("info"),
        [
            "MPC Tray Watcher",
            f"Version {APP_VERSION}",
            f"Build {BUILD_TIME}",
            "",
            f"{APP_AUTHOR}",
            f"{APP_EMAIL}",
            t("codex_developed"),
            t("update_check_policy"),
        ],
        [
            (t("website"), WEB_URL),
            ("Info", INFO_URL),
            (t("versions"), VERSIONS_URL),
            (t("installer_link"), setup_download_url(APP_VERSION)),
        ],
    )


def export_csv(icon=None, item=None) -> None:
    global last_rows

    rows = fetch_rows()
    if rows:
        last_rows = rows

    try:
        root = tk.Tk()
        root.withdraw()
        target = filedialog.asksaveasfilename(
            parent=root,
            title=t("export_table"),
            defaultextension=".csv",
            initialfile="mpc_current_table.csv",
            filetypes=[("CSV", "*.csv"), ("All files", "*.*")]
        )
        root.destroy()

        if not target:
            return

        csv_path = Path(target)
        with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
            writer = csv.writer(f, delimiter=";")
            writer.writerow([t("date"), t("mpc"), t("mps"), t("mpo")])
            for row in last_rows:
                writer.writerow([row["date"], label_list(row["mpc"]), label_list(row["mps"]), label_list(row["mpo"])])

        # Merke die letzte Exportdatei, damit "Export öffnen" sie öffnen kann.
        config["last_csv_path"] = str(csv_path)
        save_config()

        add_log("CSV exported: " + str(csv_path))
        show_message(APP_NAME, t("csv_done", path=str(csv_path)))
    except Exception as exc:
        show_message(APP_NAME, t("csv_error", error=str(exc)), "error")



def open_last_csv(icon=None, item=None) -> None:
    csv_path = Path(config.get("last_csv_path", str(CSV_FILE)))
    if not csv_path.exists():
        show_message(APP_NAME, t("csv_missing"), "error")
        return
    try:
        os.startfile(str(csv_path))
    except Exception as exc:
        show_message(APP_NAME, str(exc), "error")


def queue_start_counter_ping() -> None:
    def worker() -> None:
        try:
            with requests.get(COUNTER_URL, timeout=COUNTER_TIMEOUT_SECONDS, headers={"User-Agent": f"{APP_NAME}/{APP_VERSION}"}):
                pass
        except Exception:
            pass

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


def copy_text_to_clipboard(text: str) -> None:
    root = tk.Tk()
    root.withdraw()
    root.clipboard_clear()
    root.clipboard_append(text)
    root.update()
    root.destroy()


def registry_snapshot_text() -> str:
    lines = [
        f"Program: {APP_NAME}",
        f"Version: {APP_VERSION}",
        f"Time: {timestamp()}",
        f"Registry path: HKCU\\{REGISTRY_APP_KEY}",
        "",
    ]
    if os.name == "nt":
        try:
            import winreg

            values: list[tuple[str, object]] = []
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REGISTRY_APP_KEY) as key:
                index = 0
                while True:
                    try:
                        name, value, _ = winreg.EnumValue(key, index)
                        values.append((name, value))
                        index += 1
                    except OSError:
                        break
            for name, value in sorted(values, key=lambda item: item[0].lower()):
                lines.append(f"{name} = {value}")
        except FileNotFoundError:
            lines.append("(registry key missing)")
        except Exception as exc:
            lines.append(f"(registry read failed: {exc})")
        lines.append("")
        lines.append("Autostart:")
        state = get_autostart_state()
        lines.append(f"Expected executable = {state.get('expected_path', '')}")
        lines.append(f"Enabled = {state.get('enabled', False)}")
        lines.append(f"Enabled complete = {state.get('complete_enabled', False)}")
        lines.append(f"Effective enabled = {state.get('effective_enabled', False)}")
        lines.append(f"Registry enabled = {state.get('registry_enabled', False)}")
        lines.append(f"Registry configured = {state.get('registry_configured', False)}")
        lines.append(f"Run matches expected = {state.get('run_matches', False)}")
        lines.append(f"Run has autostart argument = {state.get('run_has_autostart_arg', False)}")
        lines.append(f"StartupApproved status = {state.get('startup_approved_status', '')}")
        lines.append(f"Task name = {state.get('task_name', '')}")
        lines.append(f"Task exists = {state.get('task_exists', False)}")
        lines.append(f"Task matches expected = {state.get('task_matches', False)}")
        lines.append(f"Task has autostart argument = {state.get('task_has_autostart_arg', False)}")
        lines.append(f"Task configured = {state.get('task_configured', False)}")
        lines.append(f"Task enabled = {state.get('task_enabled', False)}")
        lines.append(f"Task status = {state.get('task_status', '')}")
        lines.append(f"Startup folder shortcut = {state.get('startup_folder_shortcut', '')}")
        lines.append(f"Startup folder exists = {state.get('startup_folder_exists', False)}")
        lines.append(f"Startup folder target = {state.get('startup_folder_target', '')}")
        lines.append(f"Startup folder arguments = {state.get('startup_folder_arguments', '')}")
        lines.append(f"Startup folder matches expected = {state.get('startup_folder_matches', False)}")
        lines.append(f"Startup folder has autostart argument = {state.get('startup_folder_has_autostart_arg', False)}")
        lines.append(f"Startup folder configured = {state.get('startup_folder_configured', False)}")
        lines.append(f"Startup folder enabled = {state.get('startup_folder_enabled', False)}")
        lines.append(f"Startup folder approved status = {state.get('startup_folder_approved_status', '')}")
        if state.get("last_error"):
            lines.append(f"Autostart read error = {state.get('last_error')}")
        for subkey, value_name in (
            (AUTOSTART_RUN_KEY, APP_NAME),
            (AUTOSTART_APPROVED_RUN_KEY, APP_NAME),
            (AUTOSTART_APPROVED_STARTUP_FOLDER_KEY, AUTOSTART_STARTUP_SHORTCUT_NAME),
        ):
            try:
                with winreg.OpenKey(winreg.HKEY_CURRENT_USER, subkey) as key:
                    value, _ = winreg.QueryValueEx(key, value_name)
                if isinstance(value, bytes):
                    value = value.hex(" ")
                lines.append(f"HKCU\\{subkey}\\{value_name} = {value}")
            except FileNotFoundError:
                lines.append(f"HKCU\\{subkey}\\{value_name} = (missing)")
            except Exception as exc:
                lines.append(f"HKCU\\{subkey}\\{value_name} = (read failed: {exc})")
    else:
        lines.append("(registry is only available on Windows)")
    return "\n".join(lines) + "\n"


def diagnostics_text() -> str:
    lines = [
        registry_snapshot_text().rstrip(),
        "",
        "Runtime:",
        f"Current PID = {os.getpid()}",
        f"Current executable = {sys.executable}",
        f"Current cwd = {Path.cwd()}",
        f"Config file = {CONFIG_FILE}",
        f"Log file = {LOG_FILE}",
        f"Runtime state file = {RUNTIME_STATE_FILE}",
    ]
    lines.extend(["", "Process metrics:"])
    for key, value in sorted(runtime_metrics().items()):
        lines.append(f"{key} = {value}")
    if RUNTIME_STATE_FILE.exists():
        try:
            state = json.loads(RUNTIME_STATE_FILE.read_text(encoding="utf-8"))
            for key in ("status", "pid", "executable", "started_at", "updated_at", "tray_icon_visible", "tray_icon_last_error"):
                lines.append(f"Runtime {key} = {state.get(key, '')}")
        except Exception as exc:
            lines.append(f"Runtime state read error = {exc}")
    else:
        lines.append("Runtime state file exists = False")
    if STARTUP_LOG_FILE.exists():
        try:
            log_tail = STARTUP_LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-40:]
            lines.extend(["", "Startup log tail:", *log_tail])
        except Exception as exc:
            lines.append(f"Startup log read error = {exc}")
    return "\n".join(lines) + "\n"


def save_diagnostics_file(text: str | None = None) -> Path:
    REPORT_DIR.mkdir(parents=True, exist_ok=True)
    out = REPORT_DIR / f"mpc-tray-watcher-diagnose-{time.strftime('%Y%m%d-%H%M%S')}.txt"
    out.write_text(text if text is not None else diagnostics_text(), encoding="utf-8")
    return out


def create_error_report_zip() -> Path:
    REPORT_DIR.mkdir(parents=True, exist_ok=True)
    report_path = REPORT_DIR / f"mpc-tray-watcher-fehlerbericht-{time.strftime('%Y%m%d-%H%M%S')}.zip"
    sync_app_registry("error_report")
    with zipfile.ZipFile(report_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        archive.writestr("registry.txt", diagnostics_text())
        for path in (LOG_FILE, CONFIG_FILE, HISTORY_FILE, RUNTIME_STATE_FILE, STARTUP_LOG_FILE):
            if path.exists():
                archive.write(path, arcname=path.name)
    return report_path


def create_error_mail_draft(report_path: Path) -> Path:
    draft_path = REPORT_DIR / f"mpc-tray-watcher-fehlerbericht-{time.strftime('%Y%m%d-%H%M%S')}.eml"
    message = EmailMessage()
    message["To"] = SUPPORT_EMAIL
    message["Subject"] = f"Fehlerbericht {APP_NAME}"
    message["X-Unsent"] = "1"
    message.set_content(t("report_mail_body"))
    with report_path.open("rb") as attachment:
        message.add_attachment(attachment.read(), maintype="application", subtype="zip", filename=report_path.name)
    draft_path.write_bytes(message.as_bytes(policy=SMTP))
    return draft_path


def report_issue(icon=None, item=None) -> None:
    def worker() -> None:
        root = tk.Tk()
        root.withdraw()
        if not messagebox.askyesno(APP_NAME, t("report_issue_confirm"), parent=root):
            root.destroy()
            return
        try:
            report_path = create_error_report_zip()
            copy_text_to_clipboard(str(report_path))
            draft_path = create_error_mail_draft(report_path)
            try:
                os.startfile(str(draft_path))  # type: ignore[attr-defined]
            except Exception:
                body = t("report_mail_body") + "\n\n" + str(report_path)
                webbrowser.open(f"mailto:{SUPPORT_EMAIL}?subject={quote('Fehlerbericht ' + APP_NAME)}&body={quote(body)}")
            messagebox.showinfo(APP_NAME, t("report_issue_created", path=str(report_path)), parent=root)
        except Exception as exc:
            messagebox.showerror(APP_NAME, t("report_issue_failed", error=str(exc)), parent=root)
        finally:
            root.destroy()

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


def show_diagnostics_window(icon=None, item=None) -> None:
    def worker() -> None:
        sync_app_registry("diagnostics_opened")
        text = diagnostics_text()
        root = tk.Tk()
        root.title(t("diagnostics_title"))
        root.geometry("980x620")
        root.minsize(760, 360)
        root.grid_rowconfigure(0, weight=1)
        root.grid_rowconfigure(1, weight=0)
        root.grid_columnconfigure(0, weight=1)

        frame = tk.Frame(root)
        frame.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
        frame.grid_rowconfigure(0, weight=1)
        frame.grid_columnconfigure(0, weight=1)

        txt = tk.Text(frame, wrap="none", font=("Consolas", 10))
        sy = ttk.Scrollbar(frame, orient="vertical", command=txt.yview)
        sx = ttk.Scrollbar(frame, orient="horizontal", command=txt.xview)
        txt.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
        txt.grid(row=0, column=0, sticky="nsew")
        sy.grid(row=0, column=1, sticky="ns")
        sx.grid(row=1, column=0, sticky="ew")
        txt.insert("1.0", text)
        txt.configure(state="disabled")

        def copy_all() -> None:
            root.clipboard_clear()
            root.clipboard_append(text)
            root.update()
            messagebox.showinfo(APP_NAME, t("diagnostics_copied"), parent=root)

        def save_all() -> None:
            try:
                path = save_diagnostics_file(text)
                root.clipboard_clear()
                root.clipboard_append(str(path))
                root.update()
                messagebox.showinfo(APP_NAME, t("diagnostics_saved", path=str(path)), parent=root)
            except Exception as exc:
                messagebox.showerror(APP_NAME, str(exc), parent=root)

        footer = tk.Frame(root)
        footer.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8))
        footer.grid_columnconfigure(2, weight=1)
        tk.Button(footer, text=t("copy_diagnostics"), command=copy_all).grid(row=0, column=0, sticky="w")
        tk.Button(footer, text=t("save_diagnostics"), command=save_all).grid(row=0, column=1, sticky="w", padx=(8, 0))
        tk.Button(footer, text=t("close"), command=root.destroy).grid(row=0, column=3, sticky="e")

        root.mainloop()

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



def export_log_file(icon=None, item=None) -> None:
    try:
        out = CONFIG_DIR / "mpc_tray_watcher_log_export.txt"
        out.write_text(LOG_FILE.read_text(encoding="utf-8", errors="replace") if LOG_FILE.exists() else "", encoding="utf-8")
        show_message(APP_NAME, t("log_export_done", path=str(out)))
    except Exception as exc:
        show_message(APP_NAME, str(exc), "error")


def open_log_file(icon=None, item=None) -> None:
    try:
        ensure_internal_log_file()
        copy_text_to_clipboard(str(LOG_FILE))
        show_message(APP_NAME, t("log_path_copied", path=str(LOG_FILE)))
    except Exception as exc:
        show_message(APP_NAME, str(exc), "error")


def clear_log(icon=None, item=None) -> None:
    log_entries.clear()
    try:
        LOG_FILE.write_text("", encoding="utf-8")
    except Exception:
        pass
    show_message(APP_NAME, t("log_cleared"))


def show_log_window() -> None:
    root = tk.Tk()
    root.title(t("log_title"))
    root.geometry("950x520")
    root.minsize(760, 260)

    root.grid_rowconfigure(0, weight=1)
    root.grid_rowconfigure(1, weight=0)
    root.grid_columnconfigure(0, weight=1)

    frame = tk.Frame(root)
    frame.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
    frame.grid_rowconfigure(0, weight=1)
    frame.grid_columnconfigure(0, weight=1)

    txt = tk.Text(frame, wrap="none", font=("Consolas", 10))
    sy = ttk.Scrollbar(frame, orient="vertical", command=txt.yview)
    sx = ttk.Scrollbar(frame, orient="horizontal", command=txt.xview)
    txt.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)

    txt.grid(row=0, column=0, sticky="nsew")
    sy.grid(row=0, column=1, sticky="ns")
    sx.grid(row=1, column=0, sticky="ew")

    txt.insert("1.0", "\n".join(log_entries) if log_entries else t("log_empty"))
    txt.configure(state="disabled")

    footer = tk.Frame(root)
    footer.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8))
    footer.grid_columnconfigure(3, weight=1)

    tk.Button(footer, text=t("copy_log_path"), command=open_log_file).grid(row=0, column=0, sticky="w")
    tk.Button(footer, text=t("log_export"), command=export_log_file).grid(row=0, column=1, sticky="w", padx=(8, 0))
    tk.Button(footer, text=t("clear_log"), command=clear_log).grid(row=0, column=2, sticky="w", padx=(8, 0))
    tk.Button(footer, text=t("close"), command=root.destroy).grid(row=0, column=4, sticky="e")

    root.mainloop()



def sort_tree(tree, col, reverse):
    items = [(tree.set(item, col), item) for item in tree.get_children("") if item not in ("ellipsis", "none")]
    items.sort(reverse=reverse)
    for idx, (_, item) in enumerate(items):
        tree.move(item, "", idx)
    tree.heading(col, command=lambda: sort_tree(tree, col, not reverse))


def show_data_window() -> None:
    global last_rows, last_top_key
    rows = fetch_rows()
    if rows:
        last_rows = rows
        last_top_key = rows[0]["key"]
        save_history_entry(last_top_key)
        save_config()

    root = tk.Tk()
    root.title(t("title"))
    root.geometry("1220x720")
    root.minsize(900, 360)
    root.grid_rowconfigure(1, weight=1); root.grid_columnconfigure(0, weight=1)
    tk.Label(root, text=t("top", line=(last_top_key or "-")), anchor="w", font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="ew", padx=8, pady=(8,4))

    frame = tk.Frame(root); frame.grid(row=1, column=0, sticky="nsew", padx=8, pady=8)
    frame.grid_rowconfigure(0, weight=1); frame.grid_columnconfigure(0, weight=1)
    tree = ttk.Treeview(frame, columns=("date", "mpc", "mps", "mpo"), show="headings", height=18)
    for col, width, stretch in [("date",110,False),("mpc",420,True),("mps",260,True),("mpo",260,True)]:
        tree.heading(col, text=t(col), command=lambda c=col: sort_tree(tree, c, False))
        tree.column(col, width=width, stretch=stretch, anchor="w")
    tree.tag_configure("top", background="#fff59d")
    sy = ttk.Scrollbar(frame, orient="vertical", command=tree.yview); sx = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview)
    tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
    tree.grid(row=0,column=0,sticky="nsew"); sy.grid(row=0,column=1,sticky="ns"); sx.grid(row=1,column=0,sticky="ew")

    max_rows = 18
    display_count = max_rows - 1 if len(last_rows) > max_rows else max_rows
    for i, row in enumerate(last_rows[:display_count]):
        tree.insert("", "end", iid=str(i), values=(row["date"], label_list(row["mpc"]), label_list(row["mps"]), label_list(row["mpo"])), tags=("top",) if i == 0 else ())
    if len(last_rows) > display_count:
        tree.insert("", "end", iid="ellipsis", values=("", t("ellipsis"), "", ""))
    if not last_rows:
        tree.insert("", "end", iid="none", values=("", t("no_rows"), "", ""))

    def open_first(links):
        if links:
            webbrowser.open(links[0]["url"]); root.destroy()
        else:
            messagebox.showinfo(t("open_issue_title"), t("no_link"), parent=root)

    def context(event):
        iid = tree.identify_row(event.y)
        if iid in ("", "ellipsis", "none"):
            return
        row = last_rows[int(iid)]
        menu = tk.Menu(root, tearoff=0)
        menu.add_command(label=t("open_mpc"), command=lambda: open_first(row["mpc"]))
        menu.add_command(label=t("open_mps"), command=lambda: open_first(row["mps"]))
        menu.add_command(label=t("open_mpo"), command=lambda: open_first(row["mpo"]))
        menu.tk_popup(event.x_root, event.y_root)

    def click(event):
        if tree.identify("region", event.x, event.y) != "cell":
            return
        iid = tree.identify_row(event.y)
        if iid in ("", "ellipsis", "none"):
            return
        row = last_rows[int(iid)]
        col = tree.identify_column(event.x)
        key = {"#1":"date","#2":"mpc","#3":"mps","#4":"mpo"}.get(col)
        if key == "date":
            context(event)
        elif key in ("mpc","mps","mpo"):
            open_first(row[key])

    tree.bind("<ButtonRelease-1>", click)
    tree.bind("<Button-3>", context)

    footer = tk.Frame(root); footer.grid(row=2, column=0, sticky="ew", padx=8, pady=(0,8)); footer.grid_columnconfigure(4, weight=1)
    def open_main_close(event=None):
        webbrowser.open(URL); root.destroy()
    link = tk.Label(footer, text=URL, fg="blue", cursor="hand2", anchor="w", font=("Segoe UI", 9, "underline"))
    link.grid(row=0, column=0, columnspan=5, sticky="ew", pady=(0,8)); link.bind("<Button-1>", open_main_close)
    tk.Button(footer, text=t("open_page_close"), command=open_main_close).grid(row=1,column=0,sticky="w")
    tk.Button(footer, text=t("export_table"), command=export_csv).grid(row=1,column=1,sticky="w", padx=(8,0))
    tk.Button(footer, text=t("open_export"), command=open_last_csv).grid(row=1,column=2,sticky="w", padx=(8,0))
    tk.Button(footer, text=t("close"), command=root.destroy).grid(row=1,column=4,sticky="e")
    root.mainloop()


def blink(icon: Icon) -> None:
    global last_icon_signature
    for _ in range(8):
        if not running:
            update_icon_state(icon, force=True); return
        last_icon_signature = None
        icon.icon = ICON_ALERT; time.sleep(0.4)
        icon.icon = ICON_NORMAL; time.sleep(0.4)
    update_icon_state(icon, force=True)


def check_loop(icon: Icon) -> None:
    global last_rows, last_top_key, unread_change
    while True:
        if running:
            rows = fetch_rows()
            if rows:
                last_rows = rows
                new_key = rows[0]["key"]
                if last_top_key and new_key != last_top_key:
                    new_count = count_new_entries(rows)
                    unread_change = True
                    add_log("CHANGE detected: " + new_key)
                    if new_count:
                        add_log(t("new_entries", count=new_count))
                    last_top_key = new_key
                    save_history_entry(new_key)
                    save_config()
                    start_sound_alarm()
                    icon.menu = create_menu(); icon.update_menu()
                    blink(icon)
                elif not last_top_key:
                    last_top_key = new_key
                    save_history_entry(new_key)
                    save_config()
                    add_log("Initial top row stored: " + new_key)
                else:
                    update_icon_state(icon)
        update_icon_state(icon)
        maybe_write_performance_snapshot()
        time.sleep(max(30, int(interval_seconds)))


def show_data(icon=None, item=None):
    threading.Thread(target=show_data_window, daemon=True).start()


def pause(icon, item):
    global running
    running = False; save_config(); update_icon_state(icon); icon.update_menu()


def resume(icon, item):
    global running
    running = True; save_config(); update_icon_state(icon); icon.update_menu()


def set_interval(icon, item):
    def worker():
        global interval_seconds
        root = tk.Tk(); root.withdraw()
        val = simpledialog.askinteger(t("interval_title"), t("interval_prompt"), initialvalue=max(1, interval_seconds // 60), minvalue=1, maxvalue=1440)
        if val:
            interval_seconds = int(val) * 60; save_config(); icon.menu = create_menu(); icon.update_menu()
        root.destroy()
    threading.Thread(target=worker, daemon=True).start()


def reset_known_state(icon=None, item=None):
    global last_rows, last_top_key, unread_change
    rows = fetch_rows()
    if rows:
        last_rows = rows
        last_top_key = rows[0]["key"]
        unread_change = False
        save_history_entry(last_top_key)
        save_config()
        stop_sound_alarm(icon)
        add_log("Reset: " + last_top_key)
        show_message(APP_NAME, t("reset_done"))
    else:
        show_message(APP_NAME, t("reset_no_data"), "error")
    if icon:
        icon.menu = create_menu(); icon.update_menu(); update_icon_state(icon)


def mark_change_read(icon=None, item=None):
    global unread_change
    unread_change = False
    stop_sound_alarm(icon)
    if last_top_key:
        save_history_entry(last_top_key)
    save_config()
    add_log("Change marked as read")
    if icon:
        icon.menu = create_menu(); icon.update_menu(); update_icon_state(icon)


def toggle_autostart(icon, item):
    try:
        set_autostart(not is_autostart_enabled())
        icon.menu = create_menu(); icon.update_menu()
    except Exception as exc:
        show_message(APP_NAME, str(exc), "error")


def autostart_state_summary(state: dict[str, object]) -> str:
    return "\n".join([
        f"Run: {'OK' if state.get('registry_configured') else 'fehlt'}",
        f"StartupApproved: {state.get('startup_approved_status', '')}",
        f"Task Scheduler: {'OK' if state.get('task_configured') else state.get('task_status', '')}",
        f"Startup-Ordner: {'OK' if state.get('startup_folder_configured') else 'fehlt'}",
        f"EXE: {state.get('expected_path', '')}",
    ])


def repair_autostart(icon, item):
    try:
        set_autostart(True)
        state = get_autostart_state()
        sync_app_registry("autostart_repaired")
        if icon:
            icon.menu = create_menu(); icon.update_menu()
        show_message(APP_NAME, t("autostart_repair_ok") + "\n\n" + autostart_state_summary(state))
    except Exception as exc:
        show_message(APP_NAME, t("autostart_repair_failed", error=str(exc)), "error")


def toggle_sound_alarm(icon, item):
    global sound_alarm_enabled
    if sound_alarm_active:
        stop_sound_alarm(icon); return
    sound_alarm_enabled = not sound_alarm_enabled
    save_config()
    icon.menu = create_menu(); icon.update_menu(); update_icon_state(icon)


def test_sound_alarm(icon, item):
    threading.Thread(target=lambda: speak_once(t("sound_message")), daemon=True, name="sound-test").start()
    icon.menu = create_menu(); icon.update_menu()


def set_language(lang):
    def worker(icon, item):
        global current_language
        current_language = lang
        save_config()
        icon.menu = create_menu(); icon.update_menu(); update_icon_state(icon)
    return worker


def create_menu():
    minutes = max(1, interval_seconds // 60)
    return Menu(
        MenuItem(lambda item: t("show_data"), show_data, default=True),
        MenuItem(lambda item: t("pause"), pause, visible=lambda item: running),
        MenuItem(lambda item: t("resume"), resume, visible=lambda item: not running),
        MenuItem(lambda item: t("interval", minutes=minutes), set_interval),
        MenuItem(lambda item: t("reset_known"), reset_known_state),
        MenuItem(lambda item: t("mark_read"), mark_change_read, visible=lambda item: unread_change),
        MenuItem(lambda item: t("log"), lambda icon, item: threading.Thread(target=show_log_window, daemon=True).start()),
        MenuItem(lambda item: t("show_diagnostics"), show_diagnostics_window),
        MenuItem(lambda item: t("report_issue"), report_issue),
        MenuItem(lambda item: t("check_update"), check_for_updates_now),
        MenuItem(lambda item: t("autostart"), toggle_autostart, checked=lambda item: is_autostart_enabled()),
        MenuItem(lambda item: t("repair_autostart"), repair_autostart),
        MenuItem(lambda item: t("sound_alarm"), toggle_sound_alarm, checked=lambda item: sound_alarm_enabled or sound_alarm_active),
        MenuItem(lambda item: t("stop_sound"), lambda icon, item: stop_sound_alarm(icon), visible=lambda item: sound_alarm_active),
        MenuItem(lambda item: t("test_sound"), test_sound_alarm),
        MenuItem(lambda item: t("open_site"), lambda icon, item: webbrowser.open(URL)),
        MenuItem(lambda item: t("language"), Menu(
            MenuItem("Deutsch", set_language("de"), checked=lambda item: current_language == "de", radio=True),
            MenuItem("English", set_language("en"), checked=lambda item: current_language == "en", radio=True),
            MenuItem("Español", set_language("es"), checked=lambda item: current_language == "es", radio=True),
            MenuItem("Français", set_language("fr"), checked=lambda item: current_language == "fr", radio=True),
            MenuItem("Italiano", set_language("it"), checked=lambda item: current_language == "it", radio=True),
            MenuItem("Português", set_language("pt"), checked=lambda item: current_language == "pt", radio=True),
            MenuItem("Русский", set_language("ru"), checked=lambda item: current_language == "ru", radio=True),
        )),
        MenuItem(lambda item: t("help"), show_help_dialog),
        MenuItem(lambda item: t("info"), show_info_dialog),
        MenuItem(lambda item: t("exit"), lambda icon, item: (stop_sound_alarm(icon), save_config(), icon.stop())),
    )


def main():
    queue_start_counter_ping()
    write_startup_log(f"main entered: pid={os.getpid()} exe={sys.executable}")
    write_runtime_state("starting")
    ensure_internal_log_file()
    load_log_entries()
    add_log(f"Program start v{APP_VERSION} build {BUILD_TIME}")
    ensure_default_autostart()
    icon = Icon(APP_NAME, ICON_NORMAL if running else ICON_PAUSED, APP_NAME, menu=create_menu())
    write_runtime_state("running", tray_icon_registered=True, tray_icon_visible=True)
    update_icon_state(icon)
    maybe_write_performance_snapshot(force=True)
    queue_update_check("startup")
    threading.Thread(target=check_loop, args=(icon,), daemon=True).start()
    try:
        icon.run()
    finally:
        write_runtime_state("exited", tray_icon_registered=False, tray_icon_visible=False)


if __name__ == "__main__":
    exit_if_already_running_v41()
    main()
