"""Shared logic used by both the plain console watchdog (restarter.py) and
the GUI (restarter_gui.py): status polling, per-OS browser detection/launch,
and per-OS autostart-on-login registration.

Stdlib only. `winreg` (Windows-only) is imported lazily inside the Windows-
specific functions so this module still imports cleanly on macOS/Linux.
"""

import json
import os
import platform
import shutil
import subprocess
import sys
import zipfile
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen

DEFAULT_BASE_URL = "https://cortaly.com"
REQUEST_TIMEOUT_SECONDS = 10
APP_NAME = "CortalyRestarter"
# Bumped by hand alongside restarter-version.php whenever a real build ships —
# see check_for_update() below.
APP_VERSION = "1.2.0"

SYSTEM = platform.system()  # 'Windows', 'Darwin', or 'Linux'

# ---------------------------------------------------------------- status


def check_status(base_url, username, timeout=REQUEST_TIMEOUT_SECONDS):
    """Calls surf-status.php and returns its decoded JSON body.

    surf-status.php returns a real HTTP 404 (with a JSON body) for an
    unknown username — caught here so {"ok": false, "error": ...} reaches
    the caller instead of an exception. A genuine network failure still
    raises whatever urllib raises (URLError etc.); the caller handles that.
    """
    url = f"{base_url}/api/surf-status.php?username={quote(username)}"
    request = Request(url, headers={"User-Agent": "CortalyRestarter/1.0"})
    try:
        with urlopen(request, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8"))
    except HTTPError as error:
        return json.loads(error.read().decode("utf-8"))


# ------------------------------------------------------------ browser list

if SYSTEM == "Linux":
    BROWSER_CHOICES = ["System Default", "Google Chrome", "Firefox", "Chromium"]
elif SYSTEM == "Darwin":
    BROWSER_CHOICES = ["System Default", "Google Chrome", "Firefox", "Microsoft Edge", "Safari"]
else:
    BROWSER_CHOICES = ["System Default", "Google Chrome", "Firefox", "Microsoft Edge"]

_MACOS_APP_NAMES = {
    "Google Chrome": "Google Chrome",
    "Firefox": "Firefox",
    "Microsoft Edge": "Microsoft Edge",
    "Safari": "Safari",
}
_WINDOWS_EXE_NAMES = {
    "Google Chrome": "chrome.exe",
    "Firefox": "firefox.exe",
    "Microsoft Edge": "msedge.exe",
}
_LINUX_BINARY_NAMES = {
    "Google Chrome": ["google-chrome", "google-chrome-stable"],
    "Firefox": ["firefox"],
    "Chromium": ["chromium", "chromium-browser"],
}


def detect_installed_browsers():
    """Best-effort subset of BROWSER_CHOICES that looks actually installed
    on this machine — drives which options the GUI shows. "System Default"
    is always included since webbrowser.open() always works."""
    available = ["System Default"]
    if SYSTEM == "Darwin":
        for label, app in _MACOS_APP_NAMES.items():
            for base in ("/Applications", os.path.expanduser("~/Applications")):
                if os.path.isdir(os.path.join(base, f"{app}.app")):
                    available.append(label)
                    break
    elif SYSTEM == "Windows":
        for label in _WINDOWS_EXE_NAMES:
            if _windows_browser_path(label):
                available.append(label)
    elif SYSTEM == "Linux":
        for label, names in _LINUX_BINARY_NAMES.items():
            if any(shutil.which(name) for name in names):
                available.append(label)
    return available


# --------------------------------------------------------- Windows-specific


def _windows_browser_path(label):
    """Resolves a browser's full exe path via the Windows "App Paths"
    registry convention, falling back to common hardcoded install
    locations and finally a bare PATH lookup. Returns None if not found."""
    exe_name = _WINDOWS_EXE_NAMES.get(label)
    if not exe_name:
        return None
    try:
        import winreg
    except ImportError:
        return None

    app_paths_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths"
    subkey = f"{app_paths_key}\\{exe_name}"
    for hive, view_flag in (
        (winreg.HKEY_CURRENT_USER, 0),
        (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY),
        (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY),
    ):
        try:
            with winreg.OpenKey(hive, subkey, 0, winreg.KEY_READ | view_flag) as key:
                path, _value_type = winreg.QueryValueEx(key, "")
                if path and os.path.isfile(path):
                    return path
        except OSError:
            continue

    fallback_paths = {
        "Google Chrome": [
            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
            os.path.expandvars(r"%LocalAppData%\Google\Chrome\Application\chrome.exe"),
        ],
        "Firefox": [
            r"C:\Program Files\Mozilla Firefox\firefox.exe",
            r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe",
        ],
        "Microsoft Edge": [
            r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
            r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
        ],
    }
    for candidate in fallback_paths.get(label, []):
        if os.path.isfile(candidate):
            return candidate
    return shutil.which(exe_name)


# ------------------------------------------------------------ launching


def open_url(url, browser_choice="System Default"):
    """Opens url in the requested browser. Falls through to the system
    default if the specific browser can't be found/launched, so a stale or
    uninstalled choice never silently does nothing."""
    if browser_choice and browser_choice != "System Default":
        if SYSTEM == "Darwin":
            app = _MACOS_APP_NAMES.get(browser_choice)
            if app:
                try:
                    subprocess.Popen(["open", "-a", app, url])
                    return
                except OSError:
                    pass
        elif SYSTEM == "Windows":
            path = _windows_browser_path(browser_choice)
            if path:
                try:
                    subprocess.Popen([path, url])
                    return
                except OSError:
                    pass
        elif SYSTEM == "Linux":
            for name in _LINUX_BINARY_NAMES.get(browser_choice, []):
                path = shutil.which(name)
                if path:
                    try:
                        subprocess.Popen(
                            [path, url],
                            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
                            start_new_session=True,
                        )
                        return
                    except OSError:
                        continue

    if SYSTEM == "Linux":
        xdg_open = shutil.which("xdg-open")
        if xdg_open:
            try:
                subprocess.Popen(
                    [xdg_open, url],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
                    start_new_session=True,
                )
                return
            except OSError:
                pass

    import webbrowser
    webbrowser.open(url, new=2)


# ------------------------------------------------------------- autostart


def _startup_command_args():
    """The argv this app should be relaunched with at login. When frozen
    (a PyInstaller build), sys.executable IS the bundled app itself; in
    dev mode it's the interpreter + this script's path."""
    if getattr(sys, "frozen", False):
        return [sys.executable]
    return [sys.executable, os.path.abspath(sys.argv[0])]


def is_autostart_enabled():
    if SYSTEM == "Windows":
        try:
            import winreg
            key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_READ) as key:
                winreg.QueryValueEx(key, APP_NAME)
                return True
        except (ImportError, OSError):
            return False
    elif SYSTEM == "Darwin":
        return os.path.isfile(_macos_launch_agent_path())
    elif SYSTEM == "Linux":
        return os.path.isfile(_linux_autostart_path())
    return False


def set_autostart(enabled):
    if SYSTEM == "Windows":
        _set_windows_autostart(enabled)
    elif SYSTEM == "Darwin":
        _set_macos_autostart(enabled)
    elif SYSTEM == "Linux":
        _set_linux_autostart(enabled)


def _set_windows_autostart(enabled):
    import winreg
    run_key = r"Software\Microsoft\Windows\CurrentVersion\Run"
    if enabled:
        # Each argv element individually quoted — correct even though in
        # practice there's only ever one or two elements here.
        command = " ".join(f'"{arg}"' for arg in _startup_command_args())
        with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, run_key, 0, winreg.KEY_SET_VALUE) as key:
            winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, command)
    else:
        try:
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, run_key, 0, winreg.KEY_SET_VALUE) as key:
                winreg.DeleteValue(key, APP_NAME)
        except FileNotFoundError:
            pass


def _macos_launch_agent_path():
    return os.path.expanduser("~/Library/LaunchAgents/com.cortaly.restarter.plist")


def _set_macos_autostart(enabled):
    plist_path = _macos_launch_agent_path()
    label = "com.cortaly.restarter"
    uid = os.getuid()
    # Idempotent: bootout first regardless (harmless if nothing was
    # loaded) so toggling on twice in a row never errors as "already
    # loaded".
    subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True)
    if enabled:
        args_xml = "\n".join(f"        <string>{arg}</string>" for arg in _startup_command_args())
        plist = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
            '<plist version="1.0">\n'
            '<dict>\n'
            '    <key>Label</key>\n'
            f'    <string>{label}</string>\n'
            '    <key>ProgramArguments</key>\n'
            '    <array>\n'
            f'{args_xml}\n'
            '    </array>\n'
            '    <key>RunAtLoad</key>\n'
            '    <true/>\n'
            '</dict>\n'
            '</plist>\n'
        )
        os.makedirs(os.path.dirname(plist_path), exist_ok=True)
        with open(plist_path, "w", encoding="utf-8") as f:
            f.write(plist)
        subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", plist_path], capture_output=True)
    else:
        if os.path.isfile(plist_path):
            os.remove(plist_path)


# ------------------------------------------------------- browser extension


def _script_dir():
    """Directory this script itself lives in — works whether run straight
    from the repo, from the flat Windows portable bundle (restarter_gui.py
    sits right next to python.exe there), or frozen inside a PyInstaller
    macOS .app (sys._MEIPASS points at the bundled Resources dir)."""
    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
        return sys._MEIPASS
    return os.path.dirname(os.path.abspath(__file__))


def bundled_extension_source_dir():
    """Where the raw, unpacked browser-extension/ ships alongside this
    script in every build variant (see build-macos.sh,
    build-windows-portable.sh, CortalyRestarter.spec) — None if it isn't
    there, e.g. running restarter_gui.py straight from a repo checkout that
    never copied browser-extension/ next to it."""
    candidate = os.path.join(_script_dir(), "browser-extension")
    return candidate if os.path.isdir(candidate) else None


EXTENSION_INSTALL_DIR = os.path.join(os.path.expanduser("~"), ".cortaly-restarter", "extension")


def install_extension_files():
    """Copies the bundled extension into a fixed, user-writable folder as
    two ready-to-load variants — Chrome/Edge (manifest.json exactly as
    shipped) and Firefox (manifest.firefox.json swapped in as manifest.json,
    the same manual process browser-extension/README.md documents by hand)
    — so "Load unpacked" / "Load Temporary Add-on" can point straight at a
    folder with no renaming on the user's part. Re-copies every call
    (idempotent, overwrites stale copies) so a Restarter update always
    refreshes the extension too. Returns {"chrome": path, "firefox": path},
    or None if no bundled source is found next to this script.
    """
    source = bundled_extension_source_dir()
    if not source:
        return None

    chrome_dir = os.path.join(EXTENSION_INSTALL_DIR, "chrome")
    firefox_dir = os.path.join(EXTENSION_INSTALL_DIR, "firefox")

    for target in (chrome_dir, firefox_dir):
        if os.path.isdir(target):
            shutil.rmtree(target)
    shutil.copytree(source, chrome_dir)
    shutil.copytree(source, firefox_dir)

    firefox_manifest_src = os.path.join(firefox_dir, "manifest.firefox.json")
    manifest_path = os.path.join(firefox_dir, "manifest.json")
    if os.path.isfile(firefox_manifest_src):
        os.replace(manifest_path, os.path.join(firefox_dir, "manifest.chrome.json"))
        os.replace(firefox_manifest_src, manifest_path)

    return {"chrome": chrome_dir, "firefox": firefox_dir}


def extension_dir_for_browser(install_paths, browser_choice):
    """Picks the right one of install_extension_files()'s two variants for
    a given browser choice. None for browsers this flow doesn't support."""
    if not install_paths or browser_choice not in EXTENSION_SUPPORTED_BROWSERS:
        return None
    return install_paths["firefox"] if browser_choice == "Firefox" else install_paths["chrome"]


# Safari has no "load an unpacked folder" mechanism at all (App Extensions
# require converting through Xcode) and "System Default" is ambiguous about
# which browser it even resolves to — both are deliberately excluded so the
# GUI can ask the user to pick a real, specific browser instead.
EXTENSION_SUPPORTED_BROWSERS = ["Google Chrome", "Microsoft Edge", "Chromium", "Firefox"]

_EXTENSIONS_PAGE_URLS = {
    "Google Chrome": "chrome://extensions/",
    "Microsoft Edge": "edge://extensions/",
    "Chromium": "chrome://extensions/",
    "Firefox": "about:debugging#/runtime/this-firefox",
}


def extensions_page_url(browser_choice):
    return _EXTENSIONS_PAGE_URLS.get(browser_choice)


def _linux_autostart_path():
    config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
    return os.path.join(config_home, "autostart", "com.cortaly.restarter.desktop")


def _set_linux_autostart(enabled):
    path = _linux_autostart_path()
    if enabled:
        args = _startup_command_args()
        exec_line = " ".join(f'"{a}"' if " " in a else a for a in args)
        content = (
            "[Desktop Entry]\n"
            "Type=Application\n"
            "Version=1.0\n"
            "Name=Cortaly Restarter\n"
            "Comment=Automatically restart your Cortaly surf session at login\n"
            f"Exec={exec_line}\n"
            "Terminal=false\n"
            "Hidden=false\n"
            "X-GNOME-Autostart-enabled=true\n"
        )
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
    else:
        if os.path.isfile(path):
            os.remove(path)


# ------------------------------------------------------------- self-update


def _version_tuple(version_string):
    parts = []
    for piece in str(version_string).split("."):
        try:
            parts.append(int(piece))
        except ValueError:
            parts.append(0)
    return tuple(parts)


def check_for_update(base_url=DEFAULT_BASE_URL, timeout=REQUEST_TIMEOUT_SECONDS):
    """Best-effort — returns the version manifest dict (see
    restarter-version.php) if a newer version than APP_VERSION is
    published, else None. Never raises: a transient network hiccup on this
    purely-cosmetic startup check should never surface as an error, so any
    failure (network, malformed JSON, missing keys) is treated the same as
    "nothing to update"."""
    try:
        request = Request(
            f"{base_url}/api/restarter-version.php",
            headers={"User-Agent": f"{APP_NAME}/{APP_VERSION}"},
        )
        with urlopen(request, timeout=timeout) as response:
            manifest = json.loads(response.read().decode("utf-8"))
        if _version_tuple(manifest["version"]) > _version_tuple(APP_VERSION):
            return manifest
    except Exception:
        pass
    return None


def download_file(url, dest_path, timeout=60):
    """Streams url to dest_path. Validates the download against the
    server's own Content-Length when present (a corrupt/truncated
    download is a much worse failure mode here than a normal HTTP error —
    it would go on to feed a broken .app/installer into apply_update_*) —
    raises IOError on any size mismatch or transport error rather than
    silently applying a partial file."""
    request = Request(url, headers={"User-Agent": f"{APP_NAME}/{APP_VERSION}"})
    with urlopen(request, timeout=timeout) as response:
        expected = response.headers.get("Content-Length")
        written = 0
        with open(dest_path, "wb") as f:
            while True:
                chunk = response.read(262144)
                if not chunk:
                    break
                f.write(chunk)
                written += len(chunk)
    if expected is not None and written != int(expected):
        raise IOError(f"Download incomplete: got {written} bytes, expected {expected}")
    return dest_path


def _unzip_preserving_permissions(zip_path, dest_dir):
    """Plain zipfile.extractall()/shutil.unpack_archive() do NOT restore
    Unix permission bits from the archive — every file lands as whatever
    the umask gives it, which strips the executable bit off
    Contents/MacOS/CortalyRestarter and leaves a .app that LaunchServices
    refuses to spawn ("Launchd job spawn failed"). Confirmed by actually
    launching a real extracted+swapped-in bundle before shipping this —
    the failure is silent otherwise (extraction itself reports success).
    ZIP stores each entry's Unix mode in the upper 16 bits of
    external_attr (the lower 16 are DOS attributes); restoring it after
    extraction is the fix.
    """
    with zipfile.ZipFile(zip_path) as zf:
        zf.extractall(dest_dir)
        for info in zf.infolist():
            mode = (info.external_attr >> 16) & 0xFFFF
            if mode:
                os.chmod(os.path.join(dest_dir, info.filename), mode)


def _macos_app_bundle_path():
    """Walks up from the running executable to find the enclosing
    Foo.app — .../Foo.app/Contents/MacOS/Foo — None if not actually
    running from inside a real .app bundle (e.g. dev mode)."""
    if not (getattr(sys, "frozen", False) and SYSTEM == "Darwin"):
        return None
    path = os.path.abspath(sys.executable)
    for _ in range(4):
        path = os.path.dirname(path)
        if path.endswith(".app"):
            return path
    return None


def can_self_update():
    """Whether perform_update() has an actual replacement mechanism for
    this platform/run-mode. False for Linux (no single artifact to swap —
    users build their own from source, see restarter.php) and for a macOS
    dev-mode run not inside a real .app bundle."""
    if SYSTEM == "Windows":
        return True
    if SYSTEM == "Darwin":
        return _macos_app_bundle_path() is not None
    return False


def download_update(manifest, tmp_dir):
    """Step 1 of 2 — downloads this platform's update artifact into
    tmp_dir and returns its local path, WITHOUT touching the running
    install yet. Split from apply_update() so the GUI can download
    automatically in the background the moment an update is found, then
    only ask for confirmation (and only then touch anything) once the
    download has already safely finished. Raises on failure (network
    error, size mismatch — see download_file()); the caller should treat
    that as "no update available this time," not show a scary dialog for
    what's likely a transient network hiccup.
    """
    if SYSTEM == "Windows":
        installer_path = os.path.join(tmp_dir, "CortalyRestarterSetup.exe")
        download_file(manifest["downloads"]["windows"], installer_path)
        return installer_path

    if SYSTEM == "Darwin":
        zip_path = os.path.join(tmp_dir, "cortaly-restarter-macos.zip")
        download_file(manifest["downloads"]["macos"], zip_path)
        return zip_path

    raise RuntimeError("Self-update isn't supported on this platform/run mode.")


def apply_update(downloaded_path, tmp_dir):
    """Step 2 of 2 — actually replaces the running install and launches
    the new version. Only call this once the user has confirmed (it's the
    point of no return: quit right after this returns successfully, since
    both platforms' mechanisms assume the old process is about to exit).
    Raises on failure — the caller must NOT quit in that case, leaving the
    still-working old version running rather than stranding the user
    mid-update.
    """
    if SYSTEM == "Windows":
        # Detached, silent (/S) install — NSIS overwrites the same per-user
        # $LOCALAPPDATA\CortalyRestarter install this exact exe most likely
        # already runs from (see installer.nsi's InstallDir). A portable
        # (unzipped-elsewhere) install instead ends up with a second, proper
        # managed copy alongside the old portable folder — an acceptable,
        # documented rough edge rather than a second self-update mechanism
        # just for that one path (see project memory / README).
        subprocess.Popen(
            [downloaded_path, "/S"],
            creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
            close_fds=True,
        )
        return

    if SYSTEM == "Darwin":
        app_path = _macos_app_bundle_path()
        if not app_path:
            raise RuntimeError("Not running from an installed .app bundle.")
        extract_dir = os.path.join(tmp_dir, "extracted")
        _unzip_preserving_permissions(downloaded_path, extract_dir)
        new_app_path = os.path.join(extract_dir, "CortalyRestarter.app")
        if not os.path.isdir(new_app_path):
            raise RuntimeError("Downloaded archive didn't contain CortalyRestarter.app.")
        subprocess.run(["xattr", "-cr", new_app_path], capture_output=True)
        # Deleting/replacing the bundle this process is currently executing
        # out of is safe on macOS/Unix: the running process keeps its
        # already-open file handles into the old (now unlinked) bundle, so
        # nothing crashes — only a NEW launch (the one below) sees the
        # replacement. Same principle self-updaters like Sparkle rely on.
        shutil.rmtree(app_path)
        shutil.move(new_app_path, app_path)
        subprocess.Popen(["open", app_path])
        return

    raise RuntimeError("Self-update isn't supported on this platform/run mode.")
