#!/bin/bash

# omarchy:summary=Display Hyprland keybindings defined in your configuration using an interactive search menu.

# Hyprland's Lua config provider currently reports Lua binds as dispatcher
# __lua in `hyprctl binds`. Keep a lightweight source-derived cache so the
# menu can still show and dispatch those bindings.
declare -A LUA_BIND_KEY_MAP
declare -A LUA_BIND_DISPATCHER_MAP
declare -A LUA_BIND_ARG_MAP

# Hyprland reports XKB keycodes for code: bindings. Resolve them to symbols
# via the compiled keymap, with a small fallback for common keys so the menu
# remains readable if xkbcli cannot resolve a symbol.
parse_keycodes() {
  awk '
    BEGIN {
      split("10=1 11=2 12=3 13=4 14=5 15=6 16=7 17=8 18=9 19=0 20=MINUS 21=EQUAL 59=COMMA 60=PERIOD 61=SLASH", fallbacks, " ")
      for (i in fallbacks) {
        separator = index(fallbacks[i], "=")
        keycode_symbol[substr(fallbacks[i], 1, separator - 1)] = substr(fallbacks[i], separator + 1)
      }

      # </dev/null keeps xkbcli from consuming the binding records on stdin
      keymap_cmd = "xkbcli compile-keymap </dev/null"
      section = ""
      while ((keymap_cmd | getline line) > 0) {
        if (line ~ /xkb_keycodes/) { section = "codes"; continue }
        if (line ~ /xkb_symbols/)  { section = "syms";  continue }
        if (section == "codes" && match(line, /<([A-Za-z0-9_]+)>\s*=\s*([0-9]+)\s*;/, m)) code_by_name[m[1]] = m[2]
        if (section == "syms" && match(line, /key\s*<([A-Za-z0-9_]+)>\s*\{\s*\[\s*([^, \]]+)/, m)) sym_by_name[m[1]] = m[2]
      }
      close(keymap_cmd)

      for (name in code_by_name) {
        code = code_by_name[name]
        symbol = sym_by_name[name]
        if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol)
      }

      # Read off the keysym rather than the keycode, so the key left of 1 reads
      # as the symbol printed on it whichever key the active layout puts it on.
      for (code in keycode_symbol) {
        if (keycode_symbol[code] == "GRAVE") keycode_symbol[code] = "~"
      }

      mouse_symbol["272"] = "LEFT MOUSE BUTTON"
      mouse_symbol["273"] = "RIGHT MOUSE BUTTON"
      mouse_symbol["274"] = "MIDDLE MOUSE BUTTON"
    }
    {
      if (match($0, /code:([0-9]+)/, match_parts)) {
        code = match_parts[1]
        symbol = keycode_symbol[code]
        if (symbol == "") symbol = "code:" code
        sub("code:" code, symbol)
      } else if (match($0, /mouse:([0-9]+)/, match_parts)) {
        code = match_parts[1]
        symbol = mouse_symbol[code]
        if (symbol == "") symbol = "mouse:" code
        sub("mouse:" code, symbol)
      }

      print
    }
  '
}

# Supplement `hyprctl binds` for Lua-only binds that Hyprland currently
# reports as dispatcher __lua and without their original key for code: binds.
build_lua_bind_cache() {
  local modmask description key dispatcher arg cache_key

  omarchy-cmd-present lua || return 0

  while IFS=$'\t' read -r modmask description key dispatcher arg; do
    [[ -z $modmask || -z $description || -z $key ]] && continue

    LUA_BIND_KEY_MAP["$modmask,$description"]="$key"
    cache_key="$modmask,$description,$key"
    LUA_BIND_DISPATCHER_MAP["$cache_key"]="$dispatcher"
    LUA_BIND_ARG_MAP["$cache_key"]="$arg"
  done < <(
    lua <<'LUA'
local modifiers = { SHIFT = 1, CTRL = 4, CONTROL = 4, ALT = 8, SUPER = 64 }

local function split_keys(keys)
  local modmask = 0
  local key = ""

  for part in string.gmatch(tostring(keys or ""), "[^+]+") do
    local value = part:gsub("^%s+", ""):gsub("%s+$", "")
    local modifier = modifiers[string.upper(value)]

    if modifier then
      modmask = modmask + modifier
    else
      key = value
    end
  end

  return modmask, key
end

local function lua_literal(value)
  local value_type = type(value)

  if value_type == "string" then
    return string.format("%q", value)
  elseif value_type == "number" or value_type == "boolean" then
    return tostring(value)
  elseif value_type == "table" then
    local parts = {}
    local keys = {}
    local array_length = #value

    for index = 1, array_length do
      parts[#parts + 1] = lua_literal(value[index])
    end

    for key in pairs(value) do
      if not (type(key) == "number" and key >= 1 and key <= array_length and math.floor(key) == key) then
        keys[#keys + 1] = key
      end
    end

    table.sort(keys, function(left, right)
      return tostring(left) < tostring(right)
    end)

    for _, key in ipairs(keys) do
      local key_prefix
      if type(key) == "string" and key:match("^[%a_][%w_]*$") then
        key_prefix = key .. " = "
      else
        key_prefix = "[" .. lua_literal(key) .. "] = "
      end

      parts[#parts + 1] = key_prefix .. lua_literal(value[key])
    end

    return "{ " .. table.concat(parts, ", ") .. " }"
  elseif value_type == "nil" then
    return "nil"
  else
    return "nil"
  end
end

local function call_expression(path, ...)
  local args = {}

  for index = 1, select("#", ...) do
    args[index] = lua_literal(select(index, ...))
  end

  return path .. "(" .. table.concat(args, ", ") .. ")"
end

local function dispatcher(kind, arg, expr)
  return {
    __omarchy_dispatcher = true,
    kind = kind or "",
    arg = arg or "",
    expr = expr or "",
  }
end

local function dsp_proxy(path)
  return setmetatable({ path = path }, {
    __index = function(self, key)
      return dsp_proxy(self.path .. "." .. tostring(key))
    end,
    __call = function(self, ...)
      local first_arg = ...
      local expr = call_expression(self.path, ...)

      if self.path == "hl.dsp.exec_cmd" and type(first_arg) == "string" then
        return dispatcher("exec", first_arg, expr)
      end

      return dispatcher("lua", expr, expr)
    end,
  })
end

local noop
noop = setmetatable({}, {
  __index = function()
    return noop
  end,
  __call = function()
    return noop
  end,
})

hl = setmetatable({
  dsp = dsp_proxy("hl.dsp"),
  bind = function(keys, bind_dispatcher, opts)
    opts = opts or {}

    if opts.description and opts.description ~= "" then
      local modmask, key = split_keys(keys)
      local kind = ""
      local arg = ""

      if type(bind_dispatcher) == "table" and bind_dispatcher.__omarchy_dispatcher then
        kind = bind_dispatcher.kind or ""
        arg = bind_dispatcher.arg or bind_dispatcher.expr or ""
      elseif type(bind_dispatcher) == "string" and bind_dispatcher ~= "" then
        kind = "exec"
        arg = bind_dispatcher
      end

      print(table.concat({ tostring(modmask), opts.description, key, kind, arg }, "\t"))
    end

    return noop
  end,
  get_config = function()
    return nil
  end,
}, {
  __index = function()
    return noop
  end,
})

local config = os.getenv("HOME") .. "/.config/hypr/hyprland.lua"
local file = io.open(config, "r")

if file then
  file:close()
  local ok, err = pcall(dofile, config)
  if not ok and os.getenv("DEBUG") == "1" then
    io.stderr:write("[DEBUG] lua bind scan failed: " .. tostring(err) .. "\n")
  end
end
LUA
  )
}

modmask_to_text() {
  case "$1" in
    0)  printf '' ;;
    1)  printf 'SHIFT' ;;
    4)  printf 'CTRL' ;;
    5)  printf 'SHIFT CTRL' ;;
    8)  printf 'ALT' ;;
    9)  printf 'SHIFT ALT' ;;
    12) printf 'CTRL ALT' ;;
    13) printf 'SHIFT CTRL ALT' ;;
    64) printf 'SUPER' ;;
    65) printf 'SUPER SHIFT' ;;
    68) printf 'SUPER CTRL' ;;
    69) printf 'SUPER SHIFT CTRL' ;;
    72) printf 'SUPER ALT' ;;
    73) printf 'SUPER SHIFT ALT' ;;
    76) printf 'SUPER CTRL ALT' ;;
    77) printf 'SUPER SHIFT CTRL ALT' ;;
    *)  printf '%s' "$1" ;;
  esac
}

# Fetch dynamic keybindings from Hyprland.
#
# Also do some pre-processing:
# - Fill missing Lua code:... keys and __lua dispatch metadata from the Lua source cache
# - Remove standard Omarchy bin path prefix
# - Map numeric modifier key mask to a textual rendition
# - Output comma-separated values that the parser can understand
dynamic_bindings() {
  local modmask key keycode description dispatcher arg modifiers cache_key

  # Parse the plain `hyprctl binds` output rather than `hyprctl -j binds`:
  # Hyprland 0.56.0 emits invalid JSON for binds (misaligned fields in
  # bindsRequest), and older versions broke on quotes in bind args.
  hyprctl binds | awk '
    function emit() {
      if (!seen) return
      seen = 0
      printf "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n", f["modmask"], f["key"], f["keycode"], f["description"], f["dispatcher"], f["arg"]
    }
    /^bind/ { emit(); seen = 1; delete f; next }
    seen && match($0, /^\t[a-z]+: /) { f[substr($0, 2, RLENGTH - 3)] = substr($0, RLENGTH + 1) }
    END { emit() }
  ' |
    while IFS=$'\x1f' read -r modmask key keycode description dispatcher arg; do
      # Lua binds report their full display key ("SUPER + code:20"); the
      # modifiers are already carried separately in modmask.
      key="${key##* + }"

      if [[ -z $key && $keycode != "0" ]]; then
        key="code:$keycode"
      fi

      if [[ -z $key && -n $description ]]; then
        key="${LUA_BIND_KEY_MAP["$modmask,$description"]}"
      fi

      if [[ $dispatcher == "__lua" && -n $description && -n $key ]]; then
        cache_key="$modmask,$description,$key"
        dispatcher="${LUA_BIND_DISPATCHER_MAP["$cache_key"]}"
        arg="${LUA_BIND_ARG_MAP["$cache_key"]}"
      fi

      [[ -z $description && $dispatcher == "__lua" ]] && continue

      # The Copilot key just duplicates an existing binding, so keep it hidden
      [[ $key == "code:201" ]] && continue

      case "$key" in
        comma) key="COMMA" ;;
        grave) key="~" ;;
        period) key="PERIOD" ;;
        minus) key="MINUS" ;;
        equal) key="EQUAL" ;;
        slash) key="SLASH" ;;
      esac

      modifiers=$(modmask_to_text "$modmask")
      arg="${arg//~\/.local\/share\/omarchy\/bin\//}"

      printf '%s,%s,%s,%s,%s\n' "$modifiers" "$key" "$description" "$dispatcher" "$arg"
    done
}

# Hardcoded bindings, like the copy-url extension and such
static_bindings() {
  echo "SHIFT ALT,L,Copy URL from Web App,sendshortcut,SHIFT ALT,L,"
  echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D,"
}

# Actions Omarchy binds to a second chord meant as an alternative, named one at
# a time. A rule would be wrong here: Alt + Tab and Shift + Alt + Tab both say
# "Reveal active window on top" while cycling opposite ways, and a media key is
# nobody's idea of an alternative to a Super chord. Only chords a user reaches
# for interchangeably belong on one row.
alternative_chord_actions() {
  cat <<'ACTIONS'
Close window
Calculator
Toggle scratchpad
Move window to scratchpad
ACTIONS
}

# Parse and format keybindings
#
# `awk` does the heavy lifting:
# - Set the field separator to a comma ','.
# - Joins the key combination (e.g., "SUPER + Q").
# - Joins the command that the key executes.
# - Puts an action's alternative chord on the row with the first one.
# - Prints display text and dispatch metadata as tab-separated fields.
parse_binding_records() {
  awk -F, -v alternatives="$(alternative_chord_actions)" '
BEGIN {
    # The column every row pads its chords to. Nothing is allowed past it: the
    # menu renders in monospace, and a row that overruns pushes its arrow out
    # of a column the eye reads straight down.
    column = 35;

    split(alternatives, named, "\n");
    for (i in named) {
        if (named[i] != "") shares_a_row[named[i]] = 1;
    }
}
{
    # Combine the modifier and key (first two fields)
    key_combo = $1 " + " $2;

    # Clean up: strip leading "+" if present, trim spaces
    gsub(/^[ \t]*\+?[ \t]*/, "", key_combo);
    gsub(/[ \t]+$/, "", key_combo);

    # Use description, if set
    action = $3;
    dispatcher = $4;

    # Reconstruct the dispatcher arg from the remaining fields
    arg = "";
    for (i = 5; i <= NF; i++) {
        arg = arg $i (i < NF ? "," : "");
    }

    if (action == "") {
        # Reconstruct the command from the remaining fields
        for (i = 4; i <= NF; i++) {
            action = action $i (i < NF ? "," : "");
        }

        # Clean up trailing commas, remove leading "exec, ", and trim
        sub(/,$/, "", action);
        gsub(/(^|,)[[:space:]]*exec[[:space:]]*,?/, "", action);
        gsub(/(^|[[:space:]])uwsm(-app| app)[[:space:]]+--[[:space:]]+/, "", action);
        gsub(/^[ \t]+|[ \t]+$/, "", action);
        gsub(/[ \t]+/, " ", key_combo);  # Collapse multiple spaces to one
    }

    # An alternative chord joins the row the first one opened: "SUPER + W /
    # SUPER + Q -> Close window". Both halves have to agree on what they run,
    # since a label is only what a chord is called; an unresolved dispatcher
    # says nothing at all, so it never counts as agreement.
    if (action != "") {
        action_key = action SUBSEP dispatcher SUBSEP arg;
        together = "";

        if (action in shares_a_row && dispatcher != "" && action_key in leads) {
            together = chords[leads[action_key]] " / " key_combo;
        }

        if (together != "" && length(together) <= column) {
            chords[leads[action_key]] = together;
        } else {
            # Also the path a pair too wide for the column takes: two rows in
            # line beat one that juts out of it.
            entries++;
            chords[entries] = key_combo;
            actions[entries] = action;
            dispatchers[entries] = dispatcher;
            args[entries] = arg;
            leads[action_key] = entries;
        }
    }
}
END {
    for (entry = 1; entry <= entries; entry++) {
        printf "%-*s → %s\t%s\t%s\n", column, chords[entry], actions[entry], dispatchers[entry], args[entry];
    }
}'
}

prioritize_entries() {
  awk -F '\t' '
  {
    # Alternative chords are display only. Classifying on them would read
    # "SUPER SHIFT + RETURN / SUPER SHIFT + B" as the alternate browser chord
    # and drop the browser 20 rows down its own list, so match the chord that
    # leads the row and ignore the rest.
    line = $1
    sub(/ \/ [^→]*→/, " →", line)

    prio = 50
    if (match(line, /Keybindings/)) prio = 0
    if (match(line, /Omarchy menu/))  prio = 1
    if (match(line, /Terminal/)) prio = 2
    if (match(line, /Browser/) && !match(line, /Browser[[:space:]]*\(/) && !match(line, /SUPER SHIFT.*\+.*B.*→.*Browser/)) prio = 3
    if (match(line, /File manager/) && !match(line, /File manager \(cwd\)/))  prio = 4
    if (match(line, /Launch apps/))  prio = 5
    if (match(line, /System menu/))  prio = 6
    if (match(line, /Theme menu/))  prio = 7
    if (match(line, /Full screen/))  prio = 8
    if (match(line, /Full width/))  prio = 9
    if (match(line, /Close window/))  prio = 10
    if (match(line, /Close all windows/))  prio = 11
    if (match(line, /Lock system/))  prio = 12
    if (match(line, /Toggle window floating/))  prio = 13
    if (match(line, /Toggle window split/))  prio = 14
    if (match(line, /Pop window/))  prio = 15
    if (match(line, /Universal/))  prio = 16
    if (match(line, /Clipboard/))  prio = 17
    if (match(line, /Audio controls/))  prio = 18
    if (match(line, /Bluetooth controls/))  prio = 19
    if (match(line, /Wifi controls/))  prio = 20
    if (match(line, /Emojis/))  prio = 21
    if (match(line, /Color picker/))  prio = 22
    if (match(line, /Screenshot/))  prio = 23
    if (match(line, /Screenrecording/))  prio = 24
    if (match(line, /Tmux/)) prio = 25
    if (match(line, /Herdr/)) prio = 26
    if (match(line, /SUPER SHIFT.*\+.*B.*→.*Browser/)) prio = 27
    if (match(line, /File manager \(cwd\)/))  prio = 28
    if (match(line, /(Switch|Next|Former|Previous).*workspace/))  prio = 29
    if (match(line, /Move window to workspace/))  prio = 30
    if (match(line, /Move window silently to workspace/))  prio = 31
    if (match(line, /Swap window/))  prio = 32
    if (match(line, /Focus/))  prio = 33
    if (match(line, /Move window$/))  prio = 34
    if (match(line, /Resize window/))  prio = 35
    if (match(line, /Expand window/))  prio = 36
    if (match(line, /Shrink window/))  prio = 37
    if (match(line, /scratchpad/))  prio = 38
    if (match(line, /notification/))  prio = 39
    if (match(line, /Toggle window transparency/))  prio = 40
    if (match(line, /Toggle workspace gaps/))  prio = 41
    if (match(line, /Toggle nightlight/))  prio = 42
    if (match(line, /Toggle locking/))  prio = 43
    if (match(line, /group/))  prio = 94
    if (match(line, /Scroll active workspace/))  prio = 95
    if (match(line, /Cycle to/))  prio = 96
    if (match(line, /Reveal active/))  prio = 97
    if (match(line, /Apple Display/))  prio = 98
    if (match(line, /XF86/))  prio = 99
    if (match(line, /Tmux keybindings/)) prio = 100
    if (match(line, /Herdr keybindings/)) prio = 101

    # print "priority<TAB>record"
    printf "%d\t%s\n", prio, $0
  }' |
  sort -k1,1n -k2,2 |
  cut -f2-
}

# Drop repeated records while keeping the order Hyprland reported them in, so
# the chord a user declared first is the one that leads a merged entry.
dedupe_binding_records() {
  awk '!seen[$0]++'
}

output_binding_records_uncached() {
  local dynamic

  build_lua_bind_cache
  dynamic=$(dynamic_bindings)

  {
    [[ -n $dynamic ]] && printf '%s\n' "$dynamic"
    static_bindings
  } |
    dedupe_binding_records |
    parse_keycodes |
    parse_binding_records |
    prioritize_entries

  # Fail when Hyprland reported no binds so the fallout of a broken hyprctl
  # is never cached.
  [[ -n $dynamic ]]
}

keybindings_cache_key() {
  {
    printf 'v13\n'
    hyprctl devices 2>/dev/null | grep -F 'active keymap:'
    hyprctl binds 2>/dev/null
  } | sha256sum | awk '{ print $1 }'
}

refresh_keybindings_cache() {
  local cache_dir="$1"
  local cache_file="$2"
  local cache_name="$3"
  local tmp_file

  tmp_file=$(mktemp "$cache_dir/keybindings.XXXXXX") || return 1

  if output_binding_records_uncached >"$tmp_file"; then
    mv "$tmp_file" "$cache_file"
    find "$cache_dir" -maxdepth 1 -type f -name 'keybindings-*.records' ! -name "$cache_name" -delete 2>/dev/null || true
  else
    rm -f "$tmp_file"
    return 1
  fi
}

output_binding_records() {
  local cache_dir cache_file cache_name

  cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy"
  cache_name="keybindings-$(keybindings_cache_key).records"
  cache_file="$cache_dir/$cache_name"

  if [[ -s $cache_file ]]; then
    cat "$cache_file"
  elif mkdir -p "$cache_dir" 2>/dev/null && refresh_keybindings_cache "$cache_dir" "$cache_file" "$cache_name"; then
    cat "$cache_file"
  else
    output_binding_records_uncached
  fi
}

output_keybindings() {
  output_binding_records | cut -f1
}

trim() {
  local value="$1"

  value="${value#"${value%%[![:space:]]*}"}"
  value="${value%"${value##*[![:space:]]}"}"

  printf '%s\n' "$value"
}

lua_string() {
  jq -Rnr --arg value "$1" '$value | @json'
}

dispatch_lua_expression() {
  local expression="$1"
  local output status

  output=$(hyprctl dispatch "$expression" 2>&1)
  status=$?

  if (( status == 0 )) && [[ -z $output || $output == "ok" ]]; then
    [[ -n $output ]] && printf '%s\n' "$output"
    return 0
  fi

  return 1
}

dispatch_exec_binding() {
  local command="$1"

  dispatch_lua_expression "hl.dsp.exec_cmd($(lua_string "$command"))" || hyprctl dispatch exec "$command"
}

dispatch_sendshortcut_binding() {
  local arg="$1"
  local mods key window rest

  IFS=, read -r mods key window rest <<<"$arg"
  mods=$(trim "$mods")
  key=$(trim "$key")
  window=$(trim "$window")

  [[ -z $window ]] && window="activewindow"

  if [[ -n $key ]] && dispatch_lua_expression "hl.dsp.send_key_state({ mods = $(lua_string "$mods"), key = $(lua_string "$key"), state = \"down\", window = $(lua_string "$window") })"; then
    sleep 0.05
    dispatch_lua_expression "hl.dsp.send_key_state({ mods = $(lua_string "$mods"), key = $(lua_string "$key"), state = \"up\", window = $(lua_string "$window") })"
    return
  fi

  hyprctl dispatch sendshortcut "$arg"
}

dispatch_binding() {
  local dispatcher="$1"
  local arg="$2"

  case "$dispatcher" in
    exec)
      [[ -n $arg ]] && dispatch_exec_binding "$arg"
      ;;
    sendshortcut)
      [[ -n $arg ]] && dispatch_sendshortcut_binding "$arg"
      ;;
    lua)
      [[ -n $arg ]] && hyprctl dispatch "$arg"
      ;;
    "") return 1 ;;
    *)
      if [[ -n $arg ]]; then
        hyprctl dispatch "$dispatcher" "$arg"
      else
        hyprctl dispatch "$dispatcher"
      fi
      ;;
  esac
}

if [[ $1 == "--print" || $1 == "-p" ]]; then
  output_keybindings
else
  records=$(output_binding_records)
  selection=$(cut -f1 <<<"$records" |
    omarchy-menu-select 'Keybindings' -- --width 800 --height 500)

  if [[ -n $selection ]]; then
    record=$(awk -F '\t' -v selection="$selection" '$1 == selection { print; exit }' <<<"$records")
    dispatcher=$(cut -f2 <<<"$record")
    arg=$(cut -f3- <<<"$record")

    dispatch_binding "$dispatcher" "$arg"
  fi
fi
