//go:build windows

package platform

import (
	"fmt"
	"log"
	"os"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"time"
	"unsafe"

	"github.com/stevec/fancywin/internal/config"
	"github.com/stevec/fancywin/internal/layout"
	"golang.org/x/sys/windows"
)

const (
	eventSystemMoveSizeStart = 0x000A
	eventSystemMoveSizeEnd   = 0x000B
	wineventOutOfContext     = 0x0000
	wineventSkipOwnProcess   = 0x0002
	objectIDWindow           = 0
	wmHotkey                 = 0x0312
	wmTimer                  = 0x0113
	wmQuit                   = 0x0012
	monitorDefaultToNearest  = 2
	monitorDefaultToNull     = 0
	modAlt                   = 0x0001
	modControl               = 0x0002
	modShift                 = 0x0004
	modWin                   = 0x0008
	modNoRepeat              = 0x4000
	vkLButton                = 0x01
	vkShift                  = 0x10
	gaRoot                   = 2
	gwOwner                  = 4
	wsChild                  = 0x40000000
	swRestore                = 9
	swpNoZOrder              = 0x0004
	swpNoActivate            = 0x0010
	swpNoOwnerZOrder         = 0x0200
)

var (
	user32                       = windows.NewLazySystemDLL("user32.dll")
	kernel32                     = windows.NewLazySystemDLL("kernel32.dll")
	dwmapi                       = windows.NewLazySystemDLL("dwmapi.dll")
	procSetWinEventHook          = user32.NewProc("SetWinEventHook")
	procUnhookWinEvent           = user32.NewProc("UnhookWinEvent")
	procGetMessage               = user32.NewProc("GetMessageW")
	procTranslateMessage         = user32.NewProc("TranslateMessage")
	procDispatchMessage          = user32.NewProc("DispatchMessageW")
	procRegisterHotKey           = user32.NewProc("RegisterHotKey")
	procUnregisterHotKey         = user32.NewProc("UnregisterHotKey")
	procGetForegroundWindow      = user32.NewProc("GetForegroundWindow")
	procGetCursorPos             = user32.NewProc("GetCursorPos")
	procWindowFromPoint          = user32.NewProc("WindowFromPoint")
	procMonitorFromPoint         = user32.NewProc("MonitorFromPoint")
	procMonitorFromWindow        = user32.NewProc("MonitorFromWindow")
	procGetMonitorInfo           = user32.NewProc("GetMonitorInfoW")
	procGetAsyncKeyState         = user32.NewProc("GetAsyncKeyState")
	procSetWindowPos             = user32.NewProc("SetWindowPos")
	procShowWindow               = user32.NewProc("ShowWindow")
	procIsWindowVisible          = user32.NewProc("IsWindowVisible")
	procGetAncestor              = user32.NewProc("GetAncestor")
	procGetWindow                = user32.NewProc("GetWindow")
	procGetWindowLongPtr         = user32.NewProc("GetWindowLongPtrW")
	procGetWindowRect            = user32.NewProc("GetWindowRect")
	procGetWindowThreadProcessID = user32.NewProc("GetWindowThreadProcessId")
	procSetTimer                 = user32.NewProc("SetTimer")
	procKillTimer                = user32.NewProc("KillTimer")
	procSetProcessDPIAwareV2     = user32.NewProc("SetProcessDpiAwarenessContext")
	procEnumDisplayMonitors      = user32.NewProc("EnumDisplayMonitors")
	procCreateMutex              = kernel32.NewProc("CreateMutexW")
	procFreeConsole              = kernel32.NewProc("FreeConsole")
	procDwmGetWindowAttribute    = dwmapi.NewProc("DwmGetWindowAttribute")
	procMessageBox               = user32.NewProc("MessageBoxW")
)

type point struct{ X, Y int32 }
type rect struct{ Left, Top, Right, Bottom int32 }
type message struct {
	HWnd    windows.Handle
	Message uint32
	_       uint32
	WParam  uintptr
	LParam  uintptr
	Time    uint32
	Pt      point
	Private uint32
}
type monitorInfoEx struct {
	Size    uint32
	Monitor rect
	Work    rect
	Flags   uint32
	Device  [32]uint16
}
type hotkeyBinding struct {
	id     int
	action string
	keys   string
}
type engine struct {
	mu              sync.RWMutex
	configPath      string
	cfg             config.Config
	configTime      time.Time
	dragging        windows.Handle
	dragShift       bool
	mouseDown       bool
	mouseWindow     windows.Handle
	mouseStart      rect
	mouseMoved      bool
	mouseShift      bool
	fallbackHandled windows.Handle
	hook            windows.Handle
	hotkeys         map[int]hotkeyBinding
	debug           bool
	overlay         *overlayManager
	overlayFailed   bool
	tray            *trayIcon
}

var activeEngine *engine

func PrepareBackground() { procFreeConsole.Call() }

func ShowError(title, message string) {
	t, _ := windows.UTF16PtrFromString(title)
	m, _ := windows.UTF16PtrFromString(message)
	const mbOKIconErrorSetForeground = 0x00000000 | 0x00000010 | 0x00010000
	procMessageBox.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), mbOKIconErrorSetForeground)
}

func Run(configPath string, cfg config.Config, debug bool) error {
	runtime.LockOSThread()
	defer runtime.UnlockOSThread()
	procSetProcessDPIAwareV2.Call(^uintptr(3)) // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (-4)

	name, _ := windows.UTF16PtrFromString("Local\\FancyWin-Portable-Window-Manager")
	mutex, _, createErr := procCreateMutex.Call(0, 0, uintptr(unsafe.Pointer(name)))
	if mutex == 0 {
		return fmt.Errorf("create single-instance mutex: %v", createErr)
	}
	defer windows.CloseHandle(windows.Handle(mutex))
	if createErr == windows.ERROR_ALREADY_EXISTS {
		return fmt.Errorf("another instance is already running")
	}

	e := &engine{configPath: configPath, cfg: cfg, hotkeys: make(map[int]hotkeyBinding), debug: debug, overlay: &overlayManager{}}
	defer e.overlay.hide()
	tray, err := newTrayIcon(cfg.ActiveLayout, cfg.LayoutNames())
	if err != nil {
		return err
	}
	e.tray = tray
	defer e.tray.close()
	e.rememberConfigTime()
	activeEngine = e
	defer func() { activeEngine = nil }()

	callback := windows.NewCallback(winEventCallback)
	hook, _, callErr := procSetWinEventHook.Call(eventSystemMoveSizeStart, eventSystemMoveSizeEnd, 0, callback, 0, 0, wineventOutOfContext|wineventSkipOwnProcess)
	if hook == 0 {
		return fmt.Errorf("install window move hook: %v", callErr)
	}
	e.hook = windows.Handle(hook)
	defer procUnhookWinEvent.Call(hook)
	if err := e.registerHotkeys(); err != nil {
		return err
	}
	defer e.unregisterHotkeys()
	configTimer, _, configTimerErr := procSetTimer.Call(0, 1, 2000, 0)
	if configTimer == 0 {
		return fmt.Errorf("create configuration timer: %v", configTimerErr)
	}
	defer procKillTimer.Call(0, configTimer)
	shiftTimer, _, shiftTimerErr := procSetTimer.Call(0, 2, 25, 0)
	if shiftTimer == 0 {
		return fmt.Errorf("create drag activation timer: %v", shiftTimerErr)
	}
	defer procKillTimer.Call(0, shiftTimer)

	log.Printf("ready: layout=%q, Shift+drag snapping=%v, overlay=%v, %d hotkeys", cfg.ActiveLayout, cfg.ShiftDrag, cfg.Overlay.Enabled, len(e.hotkeys))
	logMonitors()
	var msg message
	for {
		r, _, err := procGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
		if int32(r) == -1 {
			return fmt.Errorf("message loop: %v", err)
		}
		if r == 0 || msg.Message == wmQuit {
			return nil
		}
		switch msg.Message {
		case wmHotkey:
			e.handleHotkey(int(msg.WParam))
		case wmTimer:
			switch msg.WParam {
			case configTimer:
				e.reloadIfChanged()
			case shiftTimer:
				e.pollInput()
			}
		}
		procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
		procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
	}
}

func winEventCallback(_, event, rawHWND, idObject, idChild, _, _ uintptr) uintptr {
	e := activeEngine
	hwnd := windows.Handle(rawHWND)
	if e == nil || hwnd == 0 || idObject != objectIDWindow || idChild != 0 {
		return 0
	}
	switch event {
	case eventSystemMoveSizeStart:
		if e.canManage(hwnd) {
			e.fallbackHandled = 0
			e.dragging = hwnd
			e.dragShift = keyDown(vkShift)
			e.debugf("move started: hwnd=0x%x app=%q shift=%v", uintptr(hwnd), executableForWindow(hwnd), e.dragShift)
		} else {
			e.debugf("move ignored: hwnd=0x%x app=%q is not a manageable top-level window", uintptr(hwnd), executableForWindow(hwnd))
		}
	case eventSystemMoveSizeEnd:
		if e.dragging == hwnd {
			if e.fallbackHandled == hwnd {
				e.dragging = 0
				e.dragShift = false
				e.fallbackHandled = 0
				e.debugf("native move end arrived after mouse fallback: hwnd=0x%x", uintptr(hwnd))
				break
			}
			activated := e.dragShift || keyDown(vkShift)
			e.dragging = 0
			e.dragShift = false
			if e.mouseWindow == hwnd {
				e.mouseWindow = 0 // The native event path owns this drag.
				e.mouseMoved = false
			}
			e.mu.RLock()
			enabled := e.cfg.ShiftDrag
			e.mu.RUnlock()
			if enabled && activated {
				if ok, reason := e.snapAtCursor(hwnd); !ok {
					log.Printf("window was not snapped: %s", reason)
				} else {
					e.debugf("window snapped: hwnd=0x%x", uintptr(hwnd))
				}
			} else {
				e.debugf("move ended without snapping: hwnd=0x%x shift_drag=%v shift=%v", uintptr(hwnd), enabled, activated)
			}
		} else {
			e.debugf("move end ignored: hwnd=0x%x did not match active drag 0x%x", uintptr(hwnd), uintptr(e.dragging))
		}
	}
	return 0
}

func (e *engine) pollInput() {
	if e.dragging != 0 {
		e.dragShift = keyDown(vkShift)
	}

	down := keyDown(vkLButton)
	switch {
	case down && !e.mouseDown:
		e.beginMouseTracking()
	case down && e.mouseDown:
		e.updateMouseTracking()
	case !down && e.mouseDown:
		e.endMouseTracking()
	}
	e.mouseDown = down
	e.updateOverlay()
}

func (e *engine) updateOverlay() {
	hide := func() {
		if e.overlay.hwnd != 0 {
			e.debugf("zone overlay hidden")
		}
		e.overlay.hide()
	}
	e.mu.RLock()
	cfg := e.cfg
	e.mu.RUnlock()
	activeDrag := e.mouseDown && (e.dragging != 0 || (e.mouseWindow != 0 && e.mouseMoved))
	if !cfg.Overlay.Enabled || !activeDrag || !keyDown(vkShift) {
		hide()
		return
	}
	var p point
	if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 {
		hide()
		return
	}
	mon, _, _ := procMonitorFromPoint.Call(packPoint(p), monitorDefaultToNull)
	if mon == 0 {
		hide()
		return
	}
	mi, ok := monitorDetails(windows.Handle(mon))
	if !ok {
		hide()
		return
	}
	selected, ok := cfg.LayoutFor(mi.device())
	if !ok {
		hide()
		return
	}
	wasVisible := e.overlay.hwnd != 0
	if err := e.overlay.show(windows.Handle(mon), toLayoutRect(mi.Work), selected.Zones, cfg.Gap, cfg.Overlay); err != nil {
		hide()
		if !e.overlayFailed {
			log.Printf("cannot show zone overlay: %v", err)
			e.overlayFailed = true
		}
	} else if !wasVisible {
		e.debugf("zone overlay shown: monitor=%s zones=%d", mi.device(), len(selected.Zones))
	}
}

func (e *engine) beginMouseTracking() {
	var p point
	if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 {
		return
	}
	raw, _, _ := procWindowFromPoint.Call(packPoint(p))
	if raw == 0 {
		e.debugf("mouse fallback: no window under press at (%d,%d)", p.X, p.Y)
		return
	}
	root, _, _ := procGetAncestor.Call(raw, gaRoot)
	if root == 0 {
		root = raw
	}
	hwnd := windows.Handle(root)
	if !e.canManage(hwnd) {
		e.debugf("mouse fallback: window under press is not manageable: hwnd=0x%x app=%q", uintptr(hwnd), executableForWindow(hwnd))
		return
	}
	var start rect
	if ok, _, _ := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&start))); ok == 0 {
		e.debugf("mouse fallback: GetWindowRect failed for hwnd=0x%x", uintptr(hwnd))
		return
	}
	e.mouseWindow = hwnd
	e.mouseStart = start
	e.mouseMoved = false
	e.mouseShift = keyDown(vkShift)
	e.debugf("mouse fallback tracking started: hwnd=0x%x app=%q shift=%v", uintptr(hwnd), executableForWindow(hwnd), e.mouseShift)
}

func (e *engine) updateMouseTracking() {
	if e.mouseWindow == 0 {
		return
	}
	e.mouseShift = keyDown(vkShift)
	var current rect
	if ok, _, _ := procGetWindowRect.Call(uintptr(e.mouseWindow), uintptr(unsafe.Pointer(&current))); ok != 0 && rectMoved(e.mouseStart, current) {
		e.mouseMoved = true
	}
}

func (e *engine) endMouseTracking() {
	hwnd := e.mouseWindow
	moved := e.mouseMoved
	activated := e.mouseShift || keyDown(vkShift)
	e.mouseWindow = 0
	e.mouseMoved = false
	e.mouseShift = false
	if hwnd == 0 {
		return
	}
	if !moved {
		e.debugf("mouse fallback ignored click: hwnd=0x%x rectangle did not move", uintptr(hwnd))
		return
	}
	e.mu.RLock()
	enabled := e.cfg.ShiftDrag
	e.mu.RUnlock()
	if !enabled || !activated {
		e.debugf("mouse fallback move ended without snapping: hwnd=0x%x shift_drag=%v shift=%v", uintptr(hwnd), enabled, activated)
		return
	}
	if e.dragging == hwnd {
		e.fallbackHandled = hwnd
	}
	if ok, reason := e.snapAtCursor(hwnd); !ok {
		log.Printf("window was not snapped by mouse fallback: %s", reason)
	} else {
		e.debugf("window snapped by mouse fallback: hwnd=0x%x", uintptr(hwnd))
	}
}

func rectMoved(a, b rect) bool {
	const threshold = int32(2)
	return abs32(a.Left-b.Left) > threshold || abs32(a.Top-b.Top) > threshold ||
		abs32(a.Right-b.Right) > threshold || abs32(a.Bottom-b.Bottom) > threshold
}

func abs32(v int32) int32 {
	if v < 0 {
		return -v
	}
	return v
}

func packPoint(p point) uintptr {
	return uintptr(uint32(p.X)) | uintptr(uint64(uint32(p.Y))<<32)
}

func (e *engine) snapAtCursor(hwnd windows.Handle) (bool, string) {
	var p point
	if ok, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); ok == 0 {
		return false, "GetCursorPos failed"
	}
	mon, _, _ := procMonitorFromPoint.Call(packPoint(p), monitorDefaultToNull)
	if mon == 0 {
		return false, fmt.Sprintf("no monitor contains pointer (%d,%d)", p.X, p.Y)
	}
	mi, ok := monitorDetails(windows.Handle(mon))
	if !ok {
		return false, "GetMonitorInfo failed"
	}
	e.mu.RLock()
	cfg := e.cfg
	e.mu.RUnlock()
	l, ok := cfg.LayoutFor(mi.device())
	if !ok {
		return false, fmt.Sprintf("active layout %q has no entry for monitor %s and no wildcard fallback", cfg.ActiveLayout, mi.device())
	}
	work := toLayoutRect(mi.Work)
	zi := layout.ZoneAt(work, l.Zones, 0, layout.Point{X: p.X, Y: p.Y})
	if zi < 0 {
		return false, fmt.Sprintf("pointer (%d,%d) is outside every zone on %s", p.X, p.Y, mi.device())
	}
	e.debugf("snap target: monitor=%s zone=%d name=%q pointer=(%d,%d)", mi.device(), zi+1, l.Zones[zi].Name, p.X, p.Y)
	if !e.moveTo(hwnd, work, l.Zones[zi], cfg.Gap) {
		return false, "SetWindowPos failed (the window may be elevated or may reject resizing)"
	}
	return true, ""
}

func (e *engine) handleHotkey(id int) {
	binding, ok := e.hotkeys[id]
	if !ok {
		return
	}
	h, _, _ := procGetForegroundWindow.Call()
	hwnd := windows.Handle(h)
	if !e.canManage(hwnd) {
		return
	}
	mon, _, _ := procMonitorFromWindow.Call(uintptr(hwnd), monitorDefaultToNearest)
	if mon == 0 {
		return
	}
	mi, ok := monitorDetails(windows.Handle(mon))
	if !ok {
		return
	}
	e.mu.RLock()
	cfg := e.cfg
	e.mu.RUnlock()
	l, ok := cfg.LayoutFor(mi.device())
	if !ok || len(l.Zones) == 0 {
		return
	}
	idx := -1
	action := strings.ToLower(strings.TrimSpace(binding.action))
	if strings.HasPrefix(action, "zone_") {
		n, err := strconv.Atoi(strings.TrimPrefix(action, "zone_"))
		if err == nil {
			idx = n - 1
		}
	} else {
		var wr rect
		if r, _, _ := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&wr))); r == 0 {
			return
		}
		center := layout.Point{X: (wr.Left + wr.Right) / 2, Y: (wr.Top + wr.Bottom) / 2}
		current := layout.Closest(toLayoutRect(mi.Work), l.Zones, center)
		if action == "next_zone" {
			idx = (current + 1) % len(l.Zones)
		} else {
			idx = (current - 1 + len(l.Zones)) % len(l.Zones)
		}
	}
	if idx >= 0 && idx < len(l.Zones) {
		e.moveTo(hwnd, toLayoutRect(mi.Work), l.Zones[idx], cfg.Gap)
	}
}

func (e *engine) moveTo(hwnd windows.Handle, work layout.Rect, z config.Zone, gap int) bool {
	r := layout.Resolve(work, z, gap)
	procShowWindow.Call(uintptr(hwnd), swRestore)
	r = outerRectForVisibleFrame(hwnd, r)
	ok, _, err := procSetWindowPos.Call(uintptr(hwnd), 0, uintptr(r.Left), uintptr(r.Top), uintptr(r.Right-r.Left), uintptr(r.Bottom-r.Top), swpNoZOrder|swpNoActivate|swpNoOwnerZOrder)
	if ok == 0 {
		log.Printf("cannot move window 0x%x: %v (it may be elevated)", uintptr(hwnd), err)
		return false
	}
	return true
}

func (e *engine) debugf(format string, args ...any) {
	if e.debug {
		log.Printf("debug: "+format, args...)
	}
}

// Windows 11 windows have an invisible resize border outside the DWM-rendered
// frame. Expand the SetWindowPos rectangle so the visible frame, rather than
// that hidden border, aligns with the configured zone.
func outerRectForVisibleFrame(hwnd windows.Handle, target layout.Rect) layout.Rect {
	var outer, frame rect
	if ok, _, _ := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&outer))); ok == 0 {
		return target
	}
	const dwmwaExtendedFrameBounds = 9
	hr, _, _ := procDwmGetWindowAttribute.Call(uintptr(hwnd), dwmwaExtendedFrameBounds, uintptr(unsafe.Pointer(&frame)), unsafe.Sizeof(frame))
	if int32(hr) < 0 {
		return target
	}
	return layout.Rect{
		Left:   target.Left - (frame.Left - outer.Left),
		Top:    target.Top - (frame.Top - outer.Top),
		Right:  target.Right + (outer.Right - frame.Right),
		Bottom: target.Bottom + (outer.Bottom - frame.Bottom),
	}
}

func (e *engine) canManage(hwnd windows.Handle) bool {
	if hwnd == 0 {
		return false
	}
	visible, _, _ := procIsWindowVisible.Call(uintptr(hwnd))
	if visible == 0 {
		return false
	}
	root, _, _ := procGetAncestor.Call(uintptr(hwnd), gaRoot)
	if root != uintptr(hwnd) {
		return false
	}
	owner, _, _ := procGetWindow.Call(uintptr(hwnd), gwOwner)
	if owner != 0 {
		return false
	}
	style, _, _ := procGetWindowLongPtr.Call(uintptr(hwnd), ^uintptr(15))
	if style&wsChild != 0 {
		return false
	}
	exe := executableForWindow(hwnd)
	e.mu.RLock()
	excluded := e.cfg.IsExcluded(exe)
	e.mu.RUnlock()
	return !excluded
}

func executableForWindow(hwnd windows.Handle) string {
	var pid uint32
	procGetWindowThreadProcessID.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&pid)))
	if pid == 0 {
		return ""
	}
	h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
	if err != nil {
		return ""
	}
	defer windows.CloseHandle(h)
	buf := make([]uint16, 32768)
	n := uint32(len(buf))
	if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &n); err != nil {
		return ""
	}
	return windows.UTF16ToString(buf[:n])
}

func monitorDetails(mon windows.Handle) (monitorInfoEx, bool) {
	mi := monitorInfoEx{Size: uint32(unsafe.Sizeof(monitorInfoEx{}))}
	ok, _, _ := procGetMonitorInfo.Call(uintptr(mon), uintptr(unsafe.Pointer(&mi)))
	return mi, ok != 0
}
func logMonitors() {
	callback := windows.NewCallback(func(mon, _, _, _ uintptr) uintptr {
		if mi, ok := monitorDetails(windows.Handle(mon)); ok {
			log.Printf("monitor %s work-area=(%d,%d)-(%d,%d)", mi.device(), mi.Work.Left, mi.Work.Top, mi.Work.Right, mi.Work.Bottom)
		}
		return 1
	})
	procEnumDisplayMonitors.Call(0, 0, callback, 0)
}
func (m monitorInfoEx) device() string { return windows.UTF16ToString(m.Device[:]) }
func toLayoutRect(r rect) layout.Rect {
	return layout.Rect{Left: r.Left, Top: r.Top, Right: r.Right, Bottom: r.Bottom}
}
func keyDown(vk uintptr) bool { r, _, _ := procGetAsyncKeyState.Call(vk); return r&0x8000 != 0 }

func (e *engine) registerHotkeys() error {
	for i, h := range e.cfg.Hotkeys {
		mods, vk, err := parseHotkey(h.Keys)
		if err != nil {
			e.unregisterHotkeys()
			return fmt.Errorf("hotkey %q: %w", h.Keys, err)
		}
		id := i + 1
		ok, _, callErr := procRegisterHotKey.Call(0, uintptr(id), uintptr(mods|modNoRepeat), uintptr(vk))
		if ok == 0 {
			e.unregisterHotkeys()
			return fmt.Errorf("register hotkey %q (already in use?): %v", h.Keys, callErr)
		}
		e.hotkeys[id] = hotkeyBinding{id: id, action: h.Action, keys: h.Keys}
	}
	return nil
}
func (e *engine) unregisterHotkeys() {
	for id := range e.hotkeys {
		procUnregisterHotKey.Call(0, uintptr(id))
		delete(e.hotkeys, id)
	}
}

func parseHotkey(s string) (uint32, uint32, error) {
	parts := strings.Split(strings.ToLower(strings.ReplaceAll(s, " ", "")), "+")
	var mods, vk uint32
	regularKeys := 0
	for _, p := range parts {
		switch p {
		case "win", "windows":
			mods |= modWin
		case "alt":
			mods |= modAlt
		case "ctrl", "control":
			mods |= modControl
		case "shift":
			mods |= modShift
		case "left":
			vk = 0x25
			regularKeys++
		case "up":
			vk = 0x26
			regularKeys++
		case "right":
			vk = 0x27
			regularKeys++
		case "down":
			vk = 0x28
			regularKeys++
		case "pageup", "pgup":
			vk = 0x21
			regularKeys++
		case "pagedown", "pgdn":
			vk = 0x22
			regularKeys++
		default:
			if len(p) == 1 && ((p[0] >= 'a' && p[0] <= 'z') || (p[0] >= '0' && p[0] <= '9')) {
				vk = uint32(strings.ToUpper(p)[0])
				regularKeys++
			} else {
				return 0, 0, fmt.Errorf("unknown key %q", p)
			}
		}
	}
	if mods == 0 || vk == 0 || regularKeys != 1 {
		return 0, 0, fmt.Errorf("must contain modifiers and exactly one regular key")
	}
	return mods, vk, nil
}

func (e *engine) rememberConfigTime() {
	if s, err := os.Stat(e.configPath); err == nil {
		e.configTime = s.ModTime()
	}
}
func (e *engine) reloadIfChanged() {
	s, err := os.Stat(e.configPath)
	if err != nil || !s.ModTime().After(e.configTime) {
		return
	}
	e.configTime = s.ModTime()
	cfg, err := config.Load(e.configPath)
	if err != nil {
		log.Printf("configuration reload rejected: %v", err)
		return
	}
	e.unregisterHotkeys()
	e.overlay.hide()
	e.overlayFailed = false
	e.mu.Lock()
	old := e.cfg
	e.cfg = cfg
	e.mu.Unlock()
	if err := e.registerHotkeys(); err != nil {
		log.Printf("configuration hotkeys rejected: %v; restoring previous configuration", err)
		e.mu.Lock()
		e.cfg = old
		e.mu.Unlock()
		_ = e.registerHotkeys()
		return
	}
	e.tray.setLayouts(cfg.ActiveLayout, cfg.LayoutNames())
	log.Printf("configuration reloaded: active layout=%q", cfg.ActiveLayout)
}

func (e *engine) selectLayout(name string) {
	e.mu.RLock()
	cfg := e.cfg
	e.mu.RUnlock()
	found := false
	for _, candidate := range cfg.LayoutNames() {
		if strings.EqualFold(candidate, name) {
			name = candidate
			found = true
			break
		}
	}
	if !found || strings.EqualFold(cfg.ActiveLayout, name) {
		return
	}
	if err := config.SetActiveLayout(e.configPath, name); err != nil {
		log.Printf("cannot select layout %q: %v", name, err)
		ShowError("FancyWin", fmt.Sprintf("Could not select layout %q:\n\n%v", name, err))
		return
	}
	cfg.ActiveLayout = name
	e.mu.Lock()
	e.cfg = cfg
	e.mu.Unlock()
	e.rememberConfigTime()
	e.overlay.hide()
	e.tray.setLayouts(name, cfg.LayoutNames())
	log.Printf("active layout changed from tray: %q", name)
}
