from __future__ import annotations

import glob
import logging
import os
import stat

import gi

gi.require_version("GdkX11", "4.0")
from gi.repository import GdkX11  # noqa: E402

from Xlib import X, Xatom, display, error, xauth
from Xlib.ext import randr, shape
from Xlib.support import connect as xlib_connect

from .config import OverlayConfig

logger = logging.getLogger(__name__)

# X11-auth-cookie filename patterns seen in the wild, checked before the
# generic fallback below - fastest path when one of these actually matches.
_KNOWN_AUTH_GLOBS = (
    ".mutter-Xwaylandauth*",  # GNOME/Mutter
    "xauth_*",  # some KDE/Plasma, sddm setups
    ".Xauthority*",
    "*Xauthority*",
)


def _candidate_auth_files() -> list[str]:
    """Every file under $XDG_RUNTIME_DIR that could plausibly be an X11
    auth cookie, most-likely-first.

    Known naming conventions (Mutter, KDE, ...) are tried first, but the
    exact name varies enough across desktop environments/distros that this
    also falls back to trying every small regular file directly in the
    runtime dir - a wrong guess there just fails to connect (cheap, no
    other effect), so being permissive here is safe.
    """
    runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
    seen: set[str] = set()
    candidates: list[str] = []

    for pattern in _KNOWN_AUTH_GLOBS:
        for path in glob.glob(os.path.join(runtime_dir, pattern)):
            if path not in seen:
                seen.add(path)
                candidates.append(path)

    try:
        entries = os.listdir(runtime_dir)
    except OSError:
        entries = []
    for name in entries:
        path = os.path.join(runtime_dir, name)
        if path in seen:
            continue
        try:
            st = os.stat(path)
        except OSError:
            continue
        # X11 auth files are small regular files (a handful of cookie
        # entries, typically well under 1 KiB) - this also naturally
        # excludes the many sockets/dirs living alongside them in a
        # typical $XDG_RUNTIME_DIR (pipewire, dbus, gvfs, systemd, ...).
        if stat.S_ISREG(st.st_mode) and st.st_size <= 4096:
            seen.add(path)
            candidates.append(path)

    return candidates


def _connect_with_raw_auth(auth_name: bytes, auth_data: bytes) -> display.Display:
    """Connect using an explicit MIT-MAGIC-COOKIE-1 value, bypassing
    python-xlib's own Xauthority.get_best_auth() lookup entirely (there's
    no public API for this - Display() always does its own file lookup
    internally - so this swaps out the lookup function for the duration of
    the call).
    """
    original_get_auth = xlib_connect.get_auth
    xlib_connect.get_auth = lambda *a, **k: (auth_name, auth_data)
    try:
        return display.Display()
    finally:
        xlib_connect.get_auth = original_get_auth


def _raw_cookie_from_file(path: str) -> tuple[bytes, bytes] | None:
    """First MIT-MAGIC-COOKIE-1 entry in an Xauthority-format file, if any -
    read directly, ignoring the family/hostname/display-number matching
    Xauthority.get_best_auth() normally does.

    That matching was observed to fail ("no xauthority details available")
    against a real, valid, Mutter-written auth file on a Bazzite/GNOME
    machine - the same file and code that work fine on a NixOS/GNOME
    machine - most likely a hostname-format mismatch specific to that
    system. For a personal single-user desktop session there's essentially
    always exactly one relevant entry in the file anyway, so skipping the
    match and using whichever cookie is there is safe.
    """
    try:
        au = xauth.Xauthority(path)
    except error.XauthError:
        return None
    for _family, _addr, _num, name, data in au.entries:
        if name == b"MIT-MAGIC-COOKIE-1":
            return name, data
    return None


def connect() -> display.Display:
    """Open a connection to the X server (XWayland), robust to setups where
    $XAUTHORITY isn't exported in the process environment even though a
    valid auth cookie exists.

    Observed on GNOME/Mutter (both a NixOS and a Bazzite machine): whether
    the auth cookie's path ends up in $XAUTHORITY depends on how the
    session launched the process - not guaranteed, e.g. when starting the
    AppImage by double-click from a file manager instead of a terminal.
    python-xlib's plain Display() only tries $XAUTHORITY/~/.Xauthority and
    gives up, so this tries a broad set of candidate auth files afterwards
    (see _candidate_auth_files) - deliberately not tied to one desktop
    environment's naming convention, since this needs to work across
    distros/DEs the app was never specifically tested on. For each
    candidate, both python-xlib's own lookup (via $XAUTHORITY) and a raw
    read of the file's first cookie (see _raw_cookie_from_file) are tried,
    since the former alone was observed to fail against a real, valid auth
    file on one machine (Bazzite) while working fine on another (NixOS).
    """
    try:
        return display.Display()
    except error.DisplayConnectionError:
        pass

    tried = []
    for path in _candidate_auth_files():
        tried.append(path)
        os.environ["XAUTHORITY"] = path
        try:
            return display.Display()
        except error.DisplayConnectionError:
            pass

        cookie = _raw_cookie_from_file(path)
        if cookie is not None:
            try:
                return _connect_with_raw_auth(*cookie)
            except error.DisplayConnectionError:
                continue

    display_name = os.environ.get("DISPLAY", "<unset>")
    runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
    logger.error(
        "Could not connect to X server (DISPLAY=%s). Tried %d candidate "
        "auth file(s) under %s: %s",
        display_name, len(tried), runtime_dir, tried,
    )
    raise RuntimeError(
        f"Could not connect to the X server on DISPLAY={display_name}. "
        f"Tried {len(tried)} candidate auth file(s) under {runtime_dir} "
        "without success. If none of those was the right one, set "
        "XAUTHORITY explicitly before starting the AppImage."
    )


def _corner_position(
    anchor: str, margin_x: int, margin_y: int, win_w: int, win_h: int,
    mon_x: int, mon_y: int, mon_w: int, mon_h: int,
) -> tuple[int, int]:
    if anchor == "top-left":
        return mon_x + margin_x, mon_y + margin_y
    if anchor == "top-right":
        return mon_x + mon_w - win_w - margin_x, mon_y + margin_y
    if anchor == "bottom-left":
        return mon_x + margin_x, mon_y + mon_h - win_h - margin_y
    if anchor == "bottom-right":
        return mon_x + mon_w - win_w - margin_x, mon_y + mon_h - win_h - margin_y
    raise ValueError(f"unknown anchor: {anchor}")


def _primary_monitor_geometry(d) -> tuple[int, int, int, int]:
    """Geometry (in root-window/X11-screen pixel coordinates) of the RandR
    primary output - i.e. what `xrandr --query` marks "primary".

    Multi-monitor setups with an offset secondary display (e.g. a monitor
    placed lower than the primary one) mean the combined X11 screen size is
    NOT the same rectangle as any single monitor - anchoring against the
    combined screen can place the window in the dead strip between two
    offset monitors, where nothing is actually displayed. GDK4 also dropped
    the "primary monitor" concept from GdkMonitor entirely, so this goes
    straight through RandR via python-xlib instead.
    """
    root = d.screen().root
    resources = root.xrandr_get_screen_resources()
    primary_output = root.xrandr_get_output_primary().output

    candidates = [primary_output] + [o for o in resources.outputs if o != primary_output]
    for output in candidates:
        info = d.xrandr_get_output_info(output, resources.config_timestamp)
        if info.crtc:
            crtc = d.xrandr_get_crtc_info(info.crtc, resources.config_timestamp)
            return crtc.x, crtc.y, crtc.width, crtc.height
    raise RuntimeError("no active RandR output found")


def set_click_through(d, xid: int, click_through: bool, width: int, height: int) -> None:
    """Toggle the window's XShape input region between empty (click-through,
    the normal state) and the full window rect (so it can receive real GDK
    button/motion events for dragging).

    Earlier this toggled based on a query_pointer() hit-test instead, but
    under this XWayland setup query_pointer() was observed to return a
    frozen, never-updating position - XWayland only seems to track the core
    pointer position while it's over a surface actually receiving pointer
    events, which a click-through (empty input region) window never does.
    Toggling the input region itself sidesteps that: while temporarily
    "solid", the window gets real events from GDK/GTK directly, no polling
    needed.
    """
    window = d.create_resource_object("window", xid)
    rects = [] if click_through else [(0, 0, width, height)]
    window.shape_rectangles(shape.SO.Set, shape.SK.Input, X.Unsorted, 0, 0, rects)
    d.flush()


def resize_window(d, xid: int, width: int, height: int) -> None:
    """Resize an already-mapped overlay window - e.g. after a live config
    change adds/removes titles or changes the scale, without restarting.
    """
    window = d.create_resource_object("window", xid)
    window.configure(width=width, height=height)
    d.flush()


def move_window(d, xid: int, x: int, y: int) -> None:
    """Move an already-mapped overlay window to an absolute root-window
    position - drives Ctrl+Alt+drag ourselves.

    This used to hand the move off to the window manager via
    _NET_WM_MOVERESIZE instead (the same protocol path normal titlebar
    dragging uses) because tracking the drag client-side here - repeated
    ConfigureWindow calls, even coalesced to one per motion event - was
    visibly laggy: the window trailed behind the pointer and never quite
    caught up, most likely because Mutter smooths/animates position changes
    it didn't itself initiate as an interactive move.

    Now that the window is override-redirect (see make_overlay), that
    smoothing is moot - an override-redirect window bypasses the WM
    entirely, so _NET_WM_MOVERESIZE has no manager to hand off to anymore
    (confirmed: window kept getting shoved back behind other windows on
    COSMIC's cosmic-comp even with periodic restack_above() calls, most
    likely because it only honors raise requests from the currently
    focused/managed window - this overlay must never take focus). With no
    WM smoothing left to fight, a plain per-motion-event ConfigureWindow
    tracks the pointer directly.
    """
    window = d.create_resource_object("window", xid)
    window.configure(x=x, y=y)
    d.flush()


def restack_above(d, xid: int) -> None:
    """Raise this window to the top of the stacking order.

    Only reliable because the window is override-redirect (see
    make_overlay): a ConfigureWindow stack_mode request against a normal,
    WM-managed window is subject to SubstructureRedirect - the WM decides
    whether/where it actually lands, and on COSMIC's cosmic-comp it kept
    landing behind the game again regardless (even via the EWMH
    _NET_WM_STATE_ABOVE ClientMessage, and even when re-sent every second -
    both approaches tried and abandoned here). An override-redirect window
    is by definition exempt from that redirection, so the X server applies
    this immediately and unconditionally - no WM policy can second-guess
    it. Called once at map time and then periodically (see overlay.py's
    reraise timer) as a defensive measure, since it's cheap and this is the
    property the whole always-on-top guarantee rests on.
    """
    window = d.create_resource_object("window", xid)
    window.configure(stack_mode=X.Above)
    d.flush()


def get_xid(gtk_window) -> int:
    """X11 window id of a realized Gtk.Window - call from a "realize"
    handler or later, never before (get_surface() has nothing to return
    until then).
    """
    surface = gtk_window.get_surface()
    if not isinstance(surface, GdkX11.X11Surface):
        raise RuntimeError(
            "Window is not backed by X11. Set GDK_BACKEND=x11 "
            "(forces routing through XWayland) before starting the app."
        )
    return surface.get_xid()


def set_wm_icon(d, xid: int, icon_path: str) -> None:
    """Set _NET_WM_ICON directly instead of relying on desktop-file +
    icon-theme lookup: GTK4 removed gtk_window_set_icon*() entirely, and an
    AppImage run standalone (no "Install"/appimaged integration) has no
    .desktop file placed anywhere a WM could resolve titletimer.desktop's
    Icon=titletimer from in the first place - observed live: the taskbar
    fell back to a generic gear icon instead of the app's own. _NET_WM_ICON
    is the older, lower-level EWMH property every tested WM still reads
    directly off the window itself, no icon theme or desktop file involved.

    Loads one bundled source image and derives several sizes from it at
    runtime (rather than bundling each size as a separate file) - the WM
    picks whichever representation fits best. Capped at 128x128 and written
    as one ChangeProperty call per size (PropModeAppend after the first)
    rather than one call for the whole lot - a single call for everything
    up to 256x256 came within a hair of (and on a first attempt, exceeded)
    the X server's default max-request-length and silently broke the
    connection (process died with no Python traceback, not even a segfault
    faulthandler could catch - the fault is in the X protocol layer, below
    what a signal handler sees).
    """
    from gi.repository import GdkPixbuf

    window = d.create_resource_object("window", xid)
    net_wm_icon = d.intern_atom("_NET_WM_ICON")
    base = GdkPixbuf.Pixbuf.new_from_file(icon_path)

    for i, size in enumerate((16, 32, 48, 64, 128)):
        pix = base.scale_simple(size, size, GdkPixbuf.InterpType.BILINEAR)
        pixels = pix.get_pixels()
        stride = pix.get_rowstride()
        channels = pix.get_n_channels()
        chunk = [size, size]
        for row in range(size):
            row_start = row * stride
            for col in range(size):
                offset = row_start + col * channels
                r, g, b = pixels[offset], pixels[offset + 1], pixels[offset + 2]
                a = pixels[offset + 3] if channels == 4 else 255
                chunk.append((a << 24) | (r << 16) | (g << 8) | b)
        mode = X.PropModeReplace if i == 0 else X.PropModeAppend
        window.change_property(net_wm_icon, Xatom.CARDINAL, 32, chunk, mode=mode)
    d.flush()


def make_overlay(
    gtk_window, overlay: OverlayConfig, width: int, height: int, click_through: bool = True
) -> tuple[int, int, int]:
    """Turn a realized Gtk.Window (X11 backend, i.e. running under XWayland)
    into an always-on-top, taskbar-skipping, click-through overlay positioned
    in a screen corner.

    This uses raw X11/EWMH via python-xlib instead of wlr-layer-shell,
    because GNOME's Mutter (unlike Sway/Hyprland) does not implement the
    Layer Shell Wayland protocol. XWayland + EWMH hints work on GNOME, KDE
    and wlroots compositors alike, since XWayland is present almost
    everywhere.
    """
    xid = get_xid(gtk_window)
    d = connect()
    window = d.create_resource_object("window", xid)

    # Make the window override-redirect: the WM never manages it (no
    # decorations, no reparenting, no say over its stacking order at all).
    # Reported on COSMIC (cosmic-comp): the overlay kept getting pushed
    # behind the game despite the always-on-top hints below plus a
    # once-a-second re-raise (both the _NET_WM_STATE_ABOVE ClientMessage and
    # a plain restack request) - most plausible explanation is a
    # focus-stealing guard that only lets the currently focused/managed
    # window win a restack, which this overlay must never be (it can't take
    # focus without breaking the game underneath). Override-redirect
    # sidesteps that guesswork entirely: restack_above()'s ConfigureWindow
    # is exempt from SubstructureRedirect by definition, so the X server
    # applies it unconditionally regardless of what any given WM's stacking
    # policy would otherwise decide. Must be set before mapping - a WM
    # decides whether to manage a window at MapRequest time, and this runs
    # from the "realize" handler, which fires before GTK maps the window.
    # Trade-off: _NET_WM_MOVERESIZE no longer has a manager to hand
    # dragging off to, so Ctrl+Alt+drag is now tracked client-side instead
    # (see move_window()).
    window.change_attributes(override_redirect=1)

    # A real WM ignores all of this for an override-redirect window - these
    # are set anyway on the off chance some other X11-aware tool (a pager, a
    # task switcher) inspects unmanaged windows too; costs nothing either way.
    net_wm_window_type = d.intern_atom("_NET_WM_WINDOW_TYPE")
    utility = d.intern_atom("_NET_WM_WINDOW_TYPE_UTILITY")
    window.change_property(net_wm_window_type, Xatom.ATOM, 32, [utility])

    net_wm_state = d.intern_atom("_NET_WM_STATE")
    above = d.intern_atom("_NET_WM_STATE_ABOVE")
    skip_taskbar = d.intern_atom("_NET_WM_STATE_SKIP_TASKBAR")
    skip_pager = d.intern_atom("_NET_WM_STATE_SKIP_PAGER")
    sticky = d.intern_atom("_NET_WM_STATE_STICKY")
    window.change_property(
        net_wm_state, Xatom.ATOM, 32, [above, skip_taskbar, skip_pager, sticky]
    )

    # Pin to every virtual desktop/workspace, not just whichever one happens
    # to be active when the window is created - otherwise the overlay can
    # silently end up on a workspace the user isn't currently looking at
    # (observed: window correctly placed and mapped, but on desktop 1 while
    # the user was on desktop 0). STICKY above is the modern hint for this;
    # _NET_WM_DESKTOP = -1 (all-ones) is the older, more broadly honored one
    # - setting both covers more window managers.
    net_wm_desktop = d.intern_atom("_NET_WM_DESKTOP")
    window.change_property(net_wm_desktop, Xatom.CARDINAL, 32, [0xFFFFFFFF])

    if overlay.x is not None and overlay.y is not None:
        x, y = overlay.x, overlay.y
    else:
        mon_x, mon_y, mon_w, mon_h = _primary_monitor_geometry(d)
        x, y = _corner_position(
            overlay.anchor, overlay.margin_x, overlay.margin_y,
            width, height, mon_x, mon_y, mon_w, mon_h,
        )
    window.configure(x=x, y=y, stack_mode=X.Above)

    if click_through:
        window.shape_rectangles(shape.SO.Set, shape.SK.Input, X.Unsorted, 0, 0, [])

    d.sync()
    return xid, x, y
