#!/usr/bin/env python3
"""Focus/move-window to the monitor spatially adjacent to the currently focused one,
in a literal screen direction (left/right/up/down) — unlike Hyprland's native
`focusmonitor -1/+1` / `movewindow mon:-1/+1`, which cycle through Hyprland's internal
monitor list *order* (roughly connection/config order), not actual position.

That distinction matters once a monitor is rotated: a 90°/270° `transform` swaps a
monitor's *logical* width/height (see monitor-manager's `logical_width`/`logical_height`,
the same math reused here), but doesn't reorder Hyprland's monitor list. On a 3-wide
desk (e.g. a portrait-rotated monitor sitting to the right of a large landscape main
one), that can make "-1/+1" jump correctly one way and land on the wrong monitor (or a
no-op) the other way, because the list order and the physical left-to-right order have
silently diverged. This instead sorts monitors by their actual logical bounding-box
center each time, so "next"/"prev" always means the next monitor to the right/left (or
below/above) on screen, regardless of transform or connection order.

Usage:
    monitor-adjacent focus l|r|u|d
    monitor-adjacent move  l|r|u|d

Silently does nothing if there's only one monitor, no monitor is focused, or there's no
monitor further in that direction (no wrap-around at the ends — see the note in
_pick_target for why).
"""

from __future__ import annotations

import json
import subprocess
import sys

AXIS = {"l": ("x", -1), "r": ("x", 1), "u": ("y", -1), "d": ("y", 1)}


def hypr_json(*args):
    try:
        return json.loads(subprocess.check_output(["hyprctl", *args, "-j"]))
    except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
        return None


def _logical_size(m: dict) -> tuple[float, float]:
    scale = m.get("scale") or 1.0
    w, h = m.get("width", 0), m.get("height", 0)
    if (m.get("transform", 0) & 3) in (1, 3):
        w, h = h, w
    return w / scale, h / scale


def _center(m: dict) -> tuple[float, float]:
    w, h = _logical_size(m)
    return m.get("x", 0) + w / 2, m.get("y", 0) + h / 2


def _pick_target(monitors: list[dict], current: dict, axis: str, sign: int) -> dict | None:
    cur_c = _center(current)
    idx_c = 0 if axis == "x" else 1
    # Candidates strictly on the requested side of the current monitor's center.
    candidates = []
    for m in monitors:
        if m is current:
            continue
        c = _center(m)
        delta = (c[idx_c] - cur_c[idx_c]) * sign
        if delta > 1:   # >1 logical px: ignore near-exact ties/float noise
            candidates.append((delta, m))
    if not candidates:
        # No wrap-around: at the physical edge of the desk, hjkl-style directional
        # movement should just stop, the same way it does within a single monitor's
        # windows. (Unlike Hyprland's index-based -1/+1, which does wrap — if you want
        # that cyclic behaviour, that's what the explicit Super+,/. binds are for.)
        return None
    candidates.sort(key=lambda t: t[0])   # closest first
    return candidates[0][1]


def main() -> int:
    if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move") or sys.argv[2] not in AXIS:
        print(__doc__)
        return 2
    kind, key = sys.argv[1], sys.argv[2]
    axis, sign = AXIS[key]

    monitors = hypr_json("monitors")
    if not monitors or len(monitors) < 2:
        return 0
    current = next((m for m in monitors if m.get("focused")), None)
    if current is None:
        return 0

    target = _pick_target(monitors, current, axis, sign)
    if target is None:
        return 0

    name = target["name"]
    if kind == "focus":
        subprocess.run(["hyprctl", "dispatch", "focusmonitor", name],
                        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    else:
        subprocess.run(["hyprctl", "dispatch", "movewindow", f"mon:{name}"],
                        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
