#!/usr/bin/env bash
# focus - small window helpers for xfwm4
#   focus next|prev   move focus right/left through windows (stops at the ends)
#   focus up|down     focus the nearest window directly above/below, if any
#   focus tile        master/stack tiling: active window left, the rest stacked right
#                     (a single window is simply maximized)
# Deps: wmctrl xdotool xprop xwininfo
set -u

# ---- config -----------------------------------------------------------------
MASTER_RATIO=50   # master width as % of the usable width
GAP=0             # px between windows and around the screen edge
USE_WORKAREA=1    # 1 = ask xfwm4 for the usable area (already excludes panels)
PANEL_TOP=0       # extra px to keep free on each side; if USE_WORKAREA=0,
PANEL_BOTTOM=0    #   put your panel sizes here instead
PANEL_LEFT=0
PANEL_RIGHT=0
SETTLE=0.05       # seconds to let xfwm4 finish un-maximizing before moving
VERIFY=1          # 1 = measure each window afterwards and fix pixel offsets
VERIFY_TRIES=2    # correction passes per window

# ---- generic helpers --------------------------------------------------------
die()   { echo "${0##*/}: $*" >&2; exit 1; }
usage() { echo "usage: ${0##*/} next|prev|up|down|tile" >&2; exit 1; }

# window geometry from wmctrl, keyed by id (filled by collect_windows)
declare -A GX GY GW GH

need() {
    local c
    for c in "$@"; do command -v "$c" >/dev/null || die "missing dependency: $c"; done
}

# Active window id in wmctrl's 0x%08x format (0x00000000 if none)
active_window() {
    local d
    d=$(xdotool getactivewindow 2>/dev/null) || d=0
    printf '0x%08x' "$d"
}

# index_of ID -> prints index in WINS, or -1
index_of() {
    local i
    for i in "${!WINS[@]}"; do
        [[ ${WINS[i]} == "$1" ]] && { echo "$i"; return; }
    done
    echo -1
}

# collect_windows focus|tile -> fills WINS, ordered left to right on screen
# (then top to bottom), so "next" moves right and "prev" moves left.
#   focus: normal windows + dialogs, minimized included (focusing restores them)
#   vert:  normal windows + dialogs, minimized skipped (they have no real position)
#   tile:  normal windows only, minimized skipped
collect_windows() {
    local mode=$1 cur id desk x y w h props
    local -a found=()
    cur=$(xdotool get_desktop) || die "cannot read current desktop"
    WINS=()
    while read -r id desk x y w h _; do
        [[ $desk == "$cur" || $desk == -1 ]] || continue   # this desktop or sticky
        props=$(xprop -id "$id" _NET_WM_WINDOW_TYPE _NET_WM_STATE 2>/dev/null) || continue

        if [[ $props == *"_NET_WM_WINDOW_TYPE(ATOM)"* ]]; then
            if [[ $mode == tile ]]; then
                [[ $props == *_NET_WM_WINDOW_TYPE_NORMAL* ]] || continue
            else
                [[ $props == *_NET_WM_WINDOW_TYPE_NORMAL* || $props == *_NET_WM_WINDOW_TYPE_DIALOG* ]] || continue
            fi
        fi
        [[ $props == *_NET_WM_STATE_SKIP_TASKBAR* ]] && continue   # panels, desktop, notifyd
        [[ $mode != focus && $props == *_NET_WM_STATE_HIDDEN* ]] && continue

        GX[$id]=$x GY[$id]=$y GW[$id]=$w GH[$id]=$h

        found+=("$x $y $id")
    done < <(wmctrl -lG)

    (( ${#found[@]} )) || return 0
    while read -r _ _ id; do WINS+=("$id"); done < <(printf '%s\n' "${found[@]}" | sort -n -k1,1 -k2,2)
}

# ---- focus ------------------------------------------------------------------
focus_cycle() {
    local dir=$1 n idx
    collect_windows focus
    n=${#WINS[@]}
    (( n > 0 )) || return 0

    idx=$(index_of "$(active_window)")
    if (( idx < 0 )); then
        # nothing of ours focused (desktop/panel): enter at the matching end
        [[ $dir == next ]] && idx=0 || idx=$(( n - 1 ))
    elif [[ $dir == next ]]; then
        (( idx < n - 1 )) || return 0   # already rightmost: stop, no wrap
        (( idx += 1 ))
    else
        (( idx > 0 )) || return 0       # already leftmost: stop, no wrap
        (( idx -= 1 ))
    fi
    wmctrl -ia "${WINS[idx]}"
}

# focus_vertical up|down -- nearest window straight above/below the active one.
# "Straight above/below" = shares some horizontal span with it. No match: do nothing.
focus_vertical() {
    local dir=$1 act id ax1 ax2 acy cy dy best="" best_dy=-1
    collect_windows vert
    act=$(active_window)
    [[ -n ${GX[$act]+x} ]] || return 0

    ax1=${GX[$act]}; ax2=$(( ax1 + GW[$act] ))
    acy=$(( GY[$act] + GH[$act] / 2 ))

    for id in "${WINS[@]}"; do
        [[ $id == "$act" ]] && continue
        (( GX[$id] < ax2 && GX[$id] + GW[$id] > ax1 )) || continue   # no horizontal overlap
        cy=$(( GY[$id] + GH[$id] / 2 ))
        if [[ $dir == up ]]; then dy=$(( acy - cy )); else dy=$(( cy - acy )); fi
        (( dy > 0 )) || continue                                      # wrong side
        if (( best_dy < 0 || dy < best_dy )); then best=$id best_dy=$dy; fi
    done

    [[ -n $best ]] && wmctrl -ia "$best"
    return 0
}

# ---- geometry helpers -------------------------------------------------------
# workarea -> WA_X WA_Y WA_W WA_H (usable rectangle on this desktop)
workarea() {
    local cur raw i
    local -a a
    cur=$(xdotool get_desktop)
    raw=$(xprop -root _NET_WORKAREA 2>/dev/null)
    if (( USE_WORKAREA )) && [[ $raw == *=* ]]; then
        IFS=', ' read -r -a a <<< "${raw#*= }"
        i=$(( cur * 4 ))
        (( i + 3 < ${#a[@]} )) || i=0
        WA_X=${a[i]} WA_Y=${a[i+1]} WA_W=${a[i+2]} WA_H=${a[i+3]}
    else
        read -r WA_W WA_H < <(xdotool getdisplaygeometry)
        WA_X=0 WA_Y=0
    fi
    (( WA_X += PANEL_LEFT, WA_Y += PANEL_TOP,
       WA_W -= PANEL_LEFT + PANEL_RIGHT, WA_H -= PANEL_TOP + PANEL_BOTTOM ))
}

# frame_extents ID -> FL FR FT FB (decoration size; 0 if not reported)
frame_extents() {
    local raw
    FL=0 FR=0 FT=0 FB=0
    raw=$(xprop -id "$1" _NET_FRAME_EXTENTS 2>/dev/null) || return 0
    [[ $raw == *=* ]] && IFS=', ' read -r FL FR FT FB <<< "${raw#*= }"
}

# client_position ID -> CX CY (real on-screen top-left of the client area)
client_position() {
    read -r CX CY < <(xwininfo -id "$1" 2>/dev/null | awk '
        /Absolute upper-left X/ {x=$NF}  /Absolute upper-left Y/ {y=$NF}
        END {print x+0, y+0}')
}

# ---- window actions ---------------------------------------------------------
# Drop states that make xfwm4 ignore move/resize (maximized, xfwm4 edge-tiling, fullscreen, shaded)
reset_state() {
    wmctrl -ir "$1" -b remove,maximized_vert,maximized_horz
    wmctrl -ir "$1" -b remove,fullscreen,shaded
}

# maximize ID -- clear fullscreen/shaded, then set the WM's maximized state
maximize() {
    wmctrl -ir "$1" -b remove,fullscreen,shaded
    wmctrl -ir "$1" -b add,maximized_vert,maximized_horz
    wmctrl -ia "$1"
}

# place ID X Y W H  -- X Y W H is the OUTER rectangle including decorations
place() {
    local id=$1 x=$2 y=$3 w=$4 h=$5 cw ch
    frame_extents "$id"
    cw=$(( w - FL - FR )); (( cw < 1 )) && cw=1
    ch=$(( h - FT - FB )); (( ch < 1 )) && ch=1
    # gravity 1 (NorthWest): x,y = top-left of the frame, w,h = client size
    wmctrl -ir "$id" -e "1,$x,$y,$cw,$ch"
}

# verify ID X Y W H -- measure where the window really landed and nudge it if off
verify() {
    local id=$1 x=$2 y=$3 w=$4 h=$5 try dx dy
    for (( try = 0; try < VERIFY_TRIES; try++ )); do
        frame_extents "$id"
        client_position "$id"
        dx=$(( CX - (x + FL) ))
        dy=$(( CY - (y + FT) ))
        (( dx == 0 && dy == 0 )) && return 0
        place "$id" $(( x - dx )) $(( y - dy )) "$w" "$h"
        sleep "$SETTLE"
    done
}

# ---- layout -----------------------------------------------------------------
# layout_master_stack N -> fills RECTS with "x y w h" outer rectangles, index 0 = master
# Integer remainders are handed out so the tiles cover the area exactly, no 1px gaps.
layout_master_stack() {
    local n=$1 x0 y0 w0 h0 mw sw sx avail base rem i y hh
    RECTS=()
    x0=$(( WA_X + GAP )); y0=$(( WA_Y + GAP ))
    w0=$(( WA_W - 2 * GAP )); h0=$(( WA_H - 2 * GAP ))

    if (( n == 1 )); then
        RECTS+=("$x0 $y0 $w0 $h0")
        return
    fi

    mw=$(( (w0 - GAP) * MASTER_RATIO / 100 ))
    sw=$(( w0 - GAP - mw ))
    sx=$(( x0 + mw + GAP ))
    RECTS+=("$x0 $y0 $mw $h0")

    (( n -= 1 ))
    avail=$(( h0 - (n - 1) * GAP ))
    base=$(( avail / n )); rem=$(( avail % n ))
    y=$y0
    for (( i = 0; i < n; i++ )); do
        hh=$(( base + (i < rem ? 1 : 0) ))
        RECTS+=("$sx $y $sw $hh")
        (( y += hh + GAP ))
    done
}

# ---- tile -------------------------------------------------------------------
tile() {
    local n idx master i
    (( VERIFY )) && need xwininfo
    collect_windows tile
    n=${#WINS[@]}
    (( n > 0 )) || return 0

    # only one window: let xfwm4 maximize it (real maximized state, not fullscreen)
    if (( n == 1 )); then
        maximize "${WINS[0]}"
        return 0
    fi

    # active window becomes master, the rest keep their order in the stack
    idx=$(index_of "$(active_window)")
    if (( idx > 0 )); then
        master=${WINS[idx]}
        WINS=("$master" "${WINS[@]:0:idx}" "${WINS[@]:idx+1}")
    fi

    workarea
    layout_master_stack "$n"

    for i in "${!WINS[@]}"; do reset_state "${WINS[i]}"; done
    sleep "$SETTLE"

    for i in "${!WINS[@]}"; do
        # shellcheck disable=SC2086
        place "${WINS[i]}" ${RECTS[i]}
    done

    if (( VERIFY )); then
        sleep "$SETTLE"
        for i in "${!WINS[@]}"; do
            # shellcheck disable=SC2086
            verify "${WINS[i]}" ${RECTS[i]}
        done
    fi

    wmctrl -ia "${WINS[0]}"
}

# ---- main -------------------------------------------------------------------
need wmctrl xdotool xprop
case ${1:-next} in
    next|prev) focus_cycle "${1:-next}" ;;
    up|down)   focus_vertical "$1" ;;
    tile)      tile ;;
    *)         usage ;;
esac
