"""Desktop shell — an app window and a tray icon instead of a console.

The server runs in a daemon thread inside this process; the window is a
plain webview onto 127.0.0.1, so there is exactly ONE UI codebase and the
render engine keeps working the way it always has.

Why a tray icon rather than "just a window": this app must keep ingesting
while you play. Closing the window HIDES it — the session keeps recording
marks and chapters — and quitting is a deliberate act from the tray menu.
That is the whole reason the old console-window shape was dangerous.

Everything degrades: no GUI toolkit (headless box, Linux without WebKit)
falls back to opening the default browser, and the app is fully usable
that way — which also keeps the LAN-access story alive.
"""
import os
import socket
import sys
import threading
import time
import webbrowser
from pathlib import Path

_MODE = {"desktop": False, "frameless": False}
_window = None
_tray = None
_shutdown = threading.Event()


def is_desktop() -> bool:
    return _MODE["desktop"]


def is_frameless() -> bool:
    """True when the window has no OS frame — which is what tells the page
    it has to draw the buttons the frame used to."""
    return _MODE["desktop"] and _MODE["frameless"]


def window_action(action: str) -> bool:
    """Minimize / maximize / close, for a window with no buttons of its own.

    'close' means exactly what the frame's X meant here: hide, keep
    ingesting, and leave quitting to the tray. Losing the frame must not
    quietly turn a stray close into "stopped recording your night"."""
    if not _window:
        return False
    try:
        if action == "minimize":
            _window.minimize()
        elif action == "maximize":
            if _MODE.get("maximized"):
                _window.restore()
            else:
                _window.maximize()
            _MODE["maximized"] = not _MODE.get("maximized")
        elif action == "close":
            # No tray to bring it back from = closing must really quit,
            # otherwise the window vanishes with no way to reach it.
            if _tray is None or _shutdown.is_set():
                shutdown()
            else:
                _window.hide()
        else:
            return False
        return True
    except Exception as e:                                      # noqa: BLE001
        print(f"[desktop] window {action} failed: {e}", flush=True)
        return False


# The window's own minimum, kept here rather than only in create_window so
# a resize request cannot shrink the app below what its layouts need.
MIN_SIZE = (900, 600)


def window_resize(width: int, height: int) -> bool:
    """Set the window's size — the corner grip's other half.

    A frameless window has no resize edges: on Windows pywebview sets
    FormBorderStyle to None, which takes the OS hit-testing with it. So the
    page draws a grip and the size arrives here instead. Clamped to the same
    minimum the window was created with, because a drag that ends at 200px
    wide would leave nothing left to drag back."""
    if not _window:
        return False
    try:
        _window.resize(max(MIN_SIZE[0], int(width)), max(MIN_SIZE[1], int(height)))
        return True
    except Exception as e:                                      # noqa: BLE001
        print(f"[desktop] window resize failed: {e}", flush=True)
        return False


# ---------------------------------------------------------------- single run
def acquire_single_instance(port: int) -> bool:
    """True if we are the only instance. Two copies would fight over the
    port, the DB and the hotkeys — the second should just surface the first."""
    s = socket.socket()
    try:
        s.bind(("127.0.0.1", port))
        s.close()
        return True
    except OSError:
        return False


def _wait_for_server(base: str, timeout: float = 40.0) -> bool:
    import urllib.request
    deadline = time.time() + timeout
    while time.time() < deadline and not _shutdown.is_set():
        try:
            # plain http on loopback — no TLS to configure, and reaching for
            # the shared context here is what broke the desktop shell once
            urllib.request.urlopen(base + "/api/health/ping", timeout=2)
            return True
        except Exception:
            time.sleep(0.25)
    return False


def serve_in_thread(port: int, host: str = "127.0.0.1"):
    import uvicorn
    cfg = uvicorn.Config("clipshelf.app:app", host=host, port=port,
                         log_level="warning")
    server = uvicorn.Server(cfg)
    t = threading.Thread(target=server.run, daemon=True)
    t.start()
    return server


# ---------------------------------------------------------------- native bits
def pick_folder():
    """Native folder dialog. None when there's no window (browser mode) —
    the caller falls back to the in-app folder browser."""
    if not _window:
        return None
    try:
        import webview
        res = _window.create_file_dialog(webview.FOLDER_DIALOG)
        if not res:
            return ""
        return res[0] if isinstance(res, (list, tuple)) else str(res)
    except Exception:
        return None


def save_file_as(src, suggested: str = ""):
    """Native save dialog + copy. The window's WebView refuses downloads
    started by the page (an <a download> is inert inside the app), so the
    app has to do the saving itself. Returns the written path, "" if the
    dialog was cancelled, or None in browser mode."""
    import shutil
    from pathlib import Path
    if not _window:
        return None
    try:
        import webview
        res = _window.create_file_dialog(
            webview.SAVE_DIALOG, save_filename=suggested or Path(src).name)
        if not res:
            return ""
        dest = res[0] if isinstance(res, (list, tuple)) else str(res)
        shutil.copy2(src, dest)
        return dest
    except Exception as e:
        print(f"[desktop] save failed ({type(e).__name__}: {e})", flush=True)
        return None


def reveal(path) -> bool:
    """Open the file manager at a file. The second half of "download" on a
    desktop app: knowing where it went."""
    import subprocess
    from pathlib import Path
    p = Path(path)
    try:
        if os.name == "nt":
            subprocess.Popen(["explorer", "/select,", str(p)])
        elif sys.platform == "darwin":
            subprocess.Popen(["open", "-R", str(p)])
        else:
            subprocess.Popen(["xdg-open", str(p.parent)])
        return True
    except Exception:
        return False


ICON_ICO = Path(__file__).parent / "frontend" / "img" / "clipshelf.ico"
ICON_PNG = Path(__file__).parent / "frontend" / "img" / "clipshelf.png"


def _tray_image():
    """The real icon when it shipped; the drawn one if something ate it, so
    a missing file costs a nice icon rather than the whole tray."""
    from PIL import Image, ImageDraw
    try:
        return Image.open(ICON_PNG).convert("RGBA")
    except Exception:
        pass
    img = Image.new("RGBA", (64, 64), (8, 8, 13, 255))
    d = ImageDraw.Draw(img)
    d.ellipse((10, 10, 54, 54), outline=(255, 177, 92, 255), width=5)
    d.line((22, 42, 42, 22), fill=(255, 177, 92, 255), width=5)
    return img


def _claim_taskbar_identity():
    """Windows decides a taskbar button's icon and grouping from the process
    AppUserModelID, and ours defaulted to the interpreter's — which is why
    the button looked like IDLE. Claiming our own ID must happen before any
    window exists."""
    if os.name != "nt":
        return
    try:
        import ctypes
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Clipshelf.App")
    except Exception as e:
        print(f"[desktop] could not set the taskbar identity ({type(e).__name__})", flush=True)


def _apply_window_icon(title: str):
    """Hang the real icon on the window itself.

    The AppUserModelID fixes grouping, but the button still draws whatever
    icon the window carries, and a pywebview window carries none. Found by
    title rather than through the backend's native handle, which differs
    between WebView2, GTK and Qt."""
    if os.name != "nt" or not ICON_ICO.exists():
        return
    try:
        import ctypes
        u32 = ctypes.windll.user32
        hwnd = u32.FindWindowW(None, title)
        if not hwnd:
            return
        IMAGE_ICON, LR_LOADFROMFILE, LR_DEFAULTSIZE = 1, 0x10, 0x40
        WM_SETICON, ICON_SMALL, ICON_BIG = 0x0080, 0, 1
        for which, cx in ((ICON_SMALL, 16), (ICON_BIG, 32)):
            h = u32.LoadImageW(None, str(ICON_ICO), IMAGE_ICON, cx, cx,
                               LR_LOADFROMFILE | LR_DEFAULTSIZE)
            if h:
                u32.SendMessageW(hwnd, WM_SETICON, which, h)
    except Exception as e:
        print(f"[desktop] could not set the window icon ({type(e).__name__})", flush=True)


def _start_tray(base: str):
    """Tray icon: show the window, open in browser, quit. Best effort —
    a machine without a system tray still runs fine."""
    global _tray
    try:
        import pystray
    except Exception as e:
        print(f"[desktop] no tray ({type(e).__name__}) — window only", flush=True)
        return

    def show(_i=None, _it=None):
        try:
            if _window:
                _window.show()
        except Exception:
            pass

    def open_browser(_i=None, _it=None):
        webbrowser.open(base)

    def quit_app(icon=None, _it=None):
        _shutdown.set()
        try:
            if icon:
                icon.stop()
        except Exception:
            pass
        try:
            if _window:
                _window.destroy()
        except Exception:
            pass

    menu = pystray.Menu(
        pystray.MenuItem("Open", show, default=True),
        pystray.MenuItem("Open in browser", open_browser),
        pystray.MenuItem("Quit", quit_app))
    try:
        _tray = pystray.Icon("clipshelf", _tray_image(), "Clipshelf", menu)
        threading.Thread(target=_tray.run, daemon=True).start()
    except Exception as e:
        print(f"[desktop] tray unavailable ({type(e).__name__})", flush=True)


def _stop_tray():
    try:
        if _tray:
            _tray.stop()
    except Exception:
        pass


def relaunch(delay: float = 0.8):
    """Start a detached copy of the launcher, then quit this one. The new
    process applies any staged update before importing the app."""
    import subprocess
    from clipshelf.config import ROOT
    cmd = [sys.executable, str(ROOT / "run.py")] + [a for a in sys.argv[1:] if a != "--selftest"]
    kw = {"cwd": str(ROOT), "close_fds": True}
    if os.name == "nt":
        kw["creationflags"] = 0x00000008 | 0x08000000   # DETACHED | NO_WINDOW
    else:
        kw["start_new_session"] = True
    time.sleep(delay)                       # let the HTTP response go out first
    try:
        subprocess.Popen(cmd, **kw)
    except Exception as e:
        print(f"[desktop] could not relaunch: {e}", flush=True)
        return
    shutdown()


def shutdown():
    """Quit the app the way the tray Quit does."""
    _shutdown.set()
    _stop_tray()
    try:
        if _window:
            _window.destroy()
    except Exception:
        pass
    threading.Timer(1.5, lambda: os._exit(0)).start()


# ---------------------------------------------------------------- entry
def run(port: int, title: str = "Clipshelf") -> bool:
    """Open the app window. Returns False when no GUI backend exists, so
    the launcher can fall back to the browser."""
    global _window
    try:
        import webview
        _claim_taskbar_identity()      # before any window exists, or it does nothing
        # webview.initialize() picks and loads a platform backend (WebView2
        # on Windows, GTK/Qt elsewhere) and raises if none is usable.
        # NOTE: webview.guilib is a VARIABLE that is None until this runs —
        # calling it as a module is how this probe was wrong before.
        webview.initialize()
    except Exception as e:
        print(f"[desktop] no window toolkit ({type(e).__name__}: {e}) — using your browser",
              flush=True)
        return False

    base = f"http://127.0.0.1:{port}"
    if not _wait_for_server(base):
        print("[desktop] server didn't start", flush=True)
        return False

    _MODE["desktop"] = True
    # Borderless: the app's own top bar is the title bar, so the OS one is
    # a second, uglier copy of it. easy_drag is OFF deliberately — with it
    # on, a drag anywhere that is not an interactive element moves the
    # WINDOW, which would eat every drag this app is made of: scrubbing the
    # timeline, trimming a clip, arranging the editor. The top bar marks its
    # own drag handle with .pywebview-drag-region instead.
    from clipshelf.config import CFG
    frameless = bool(CFG.get("frameless", True))
    _window = webview.create_window(title, base, width=1400, height=900,
                                    min_size=MIN_SIZE, confirm_close=False,
                                    frameless=frameless, easy_drag=False)
    _MODE["frameless"] = frameless

    def on_closing():
        """Hide instead of exit — ingestion must survive a stray window close.
        Quit lives in the tray menu (and is unconditional if there's no tray)."""
        if _tray is None or _shutdown.is_set():
            return True
        try:
            _window.hide()
        except Exception:
            return True
        return False

    try:
        _window.events.closing += on_closing
    except Exception:
        pass
    try:
        _window.events.shown += lambda: _apply_window_icon(title)
    except Exception:
        pass

    _start_tray(base)
    try:
        webview.start()
    finally:
        _shutdown.set()
        _stop_tray()
    return True
