#!/usr/bin/env bash
#
# JetBrains IDE plugin manager for macOS, Linux (Ubuntu Desktop) and WSL2.
#
# Detects every locally installed JetBrains IDE (Toolbox + standalone) and,
# on Linux, any JetBrains Gateway remote-dev-server.sh distributions. Lets
# you multi-select IDE(s), choose install or uninstall, and then select the
# plugins to manage.
#
# Picker UX:
#   - Uses fzf with --multi when fzf is on PATH (TAB to toggle, Enter to confirm).
#   - Otherwise prints a numbered list and accepts space-separated indexes
#     (e.g. "1 3 5"), exclusions (e.g. "!1 !4" to select all except 1 and 4), 
#     or "a" for all.
#
# Usage:
#   ./install-jetbrains.sh        # interactive
#   ./install-jetbrains.sh --help

set -euo pipefail
IFS=$'\n\t'

# ------------------------------------------------------------------
# Canonical marketplace plugin list (xmlId | display label).
# Both columns separated by a literal pipe; the label is for the picker.
# ------------------------------------------------------------------
readonly PLUGIN_CATALOG=(
  "com.intellij.ml.llm|JetBrains AI Assistant"
  "izhangzhihao.rainbow.brackets|Rainbow Brackets"
  "IdeaVIM|IdeaVim"
  "org.sonarlint.idea|SonarQube for IDE (SonarLint)"
  "Key Promoter X|Key Promoter X"
  "net.ashald.envfile|EnvFile"
  "org.intellij.qodana|Qodana"
)

# Known JetBrains IDE launcher basenames (Toolbox shims + Linux .sh names).
readonly KNOWN_IDE_NAMES=(
  idea idea.sh
  pycharm pycharm.sh
  webstorm webstorm.sh
  goland goland.sh
  rubymine rubymine.sh
  clion clion.sh
  phpstorm phpstorm.sh
  datagrip datagrip.sh
  rustrover rustrover.sh
  rider rider.sh
  studio studio.sh
  fleet
)

usage() {
  cat <<EOF
Usage: $(basename "$0")

Interactive JetBrains IDE plugin manager for macOS, Linux and WSL2.

The script:
  1. Detects your OS (macOS / Linux / WSL2)
  2. Scans for installed JetBrains IDEs (Toolbox + standalone) and any
     local remote-dev-server.sh distributions
  3. Lets you choose whether to install or uninstall plugins
  4. Installs catalog plugins, or lists the selected IDEs' installed plugins
     and lets you choose which ones to remove

Use \`brew install fzf\` (or your distro's package manager) for a nicer picker.
EOF
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help) usage; exit 0 ;;
    *)         echo "Error: unknown argument '$1'." >&2; usage >&2; exit 2 ;;
  esac
done

# ------------------------------------------------------------------
# OS detection
# ------------------------------------------------------------------
OS_KIND=""
case "$(uname -s)" in
  Darwin) OS_KIND=macos ;;
  Linux)
    if grep -qi microsoft /proc/version 2>/dev/null; then
      OS_KIND=wsl2
    else
      OS_KIND=linux
    fi
    ;;
  *)
    echo "Error: unsupported OS '$(uname -s)'. Use install-jetbrains.ps1 on Windows." >&2
    exit 1
    ;;
esac

# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------

# is_known_ide_name <basename>  -> 0 if it matches a known JetBrains launcher.
is_known_ide_name() {
  local n="$1"
  local k
  for k in "${KNOWN_IDE_NAMES[@]}"; do
    [[ "$n" == "$k" ]] && return 0
  done
  return 1
}

# pretty_from_path /path/to/idea.sh  -> "IntelliJ IDEA (~/.local/share/.../idea.sh)"
# Builds a short, readable label for the picker.
pretty_label() {
  local path="$1" base
  base="$(basename "$path")"
  base="${base%.sh}"
  base="${base%.cmd}"
  base="${base%.exe}"
  case "$base" in
    idea|idea64)        printf 'IntelliJ IDEA'   ;;
    pycharm|pycharm64)  printf 'PyCharm'         ;;
    webstorm|webstorm64) printf 'WebStorm'       ;;
    goland|goland64)    printf 'GoLand'          ;;
    rubymine|rubymine64) printf 'RubyMine'       ;;
    clion|clion64)      printf 'CLion'           ;;
    phpstorm|phpstorm64) printf 'PhpStorm'       ;;
    datagrip|datagrip64) printf 'DataGrip'       ;;
    rustrover|rustrover64) printf 'RustRover'    ;;
    rider|rider64)      printf 'Rider'           ;;
    studio|studio64)    printf 'Android Studio'  ;;
    fleet)              printf 'Fleet'           ;;
    *)                  printf '%s' "$base"      ;;
  esac
  printf '  (%s)' "$path"
}

# Translate a Linux/WSL home into the Windows-side user dir under /mnt/c.
win_user_dir() {
  local winuser
  winuser="$(cmd.exe /c 'echo %USERNAME%' 2>/dev/null | tr -d '\r\n' || true)"
  [[ -z "$winuser" ]] && winuser="$USER"
  printf '/mnt/c/Users/%s' "$winuser"
}

# ------------------------------------------------------------------
# IDE candidate scan
# Each candidate is recorded as a single line:
#     <type>|<launcher>|<label>
# where <type> is one of:
#     ide        -> run "<launcher> installPlugins <id>..."
#     wincmd     -> run "cmd.exe /c <launcher> installPlugins <id>..."
#     remotedev  -> remote-dev-server.sh installPlugins ...
# ------------------------------------------------------------------
declare -a CANDIDATES=()

add_candidate() {
  local type="$1" launcher="$2"
  CANDIDATES+=("${type}|${launcher}|$(pretty_label "$launcher")")
}

scan_macos() {
  local d="$HOME/Library/Application Support/JetBrains/Toolbox/scripts"
  if [[ -d "$d" ]]; then
    local f
    while IFS= read -r -d '' f; do
      local base
      base="$(basename "$f")"
      is_known_ide_name "$base" || continue
      [[ -x "$f" ]] && add_candidate ide "$f"
    done < <(find "$d" -maxdepth 1 -type f -print0 2>/dev/null)
  fi

  local app bin
  for app in /Applications/*.app; do
    [[ -d "$app/Contents/MacOS" ]] || continue
    [[ "$(basename "$app")" =~ ^(IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|CLion|PhpStorm|DataGrip|RustRover|Rider|Android\ Studio|Fleet) ]] || continue
    for bin in "$app"/Contents/MacOS/*; do
      [[ -x "$bin" ]] || continue
      local b
      b="$(basename "$bin")"
      is_known_ide_name "$b" || continue
      add_candidate ide "$bin"
    done
  done
}

scan_linux() {
  local apps="$HOME/.local/share/JetBrains/Toolbox/apps"
  if [[ -d "$apps" ]]; then
    local f
    while IFS= read -r -d '' f; do
      local b
      b="$(basename "$f")"
      is_known_ide_name "$b" || continue
      [[ -x "$f" ]] && add_candidate ide "$f"
    done < <(find "$apps" -maxdepth 3 -type f -name '*.sh' -print0 2>/dev/null)
  fi

  # /opt/<ide>/bin/<ide>.sh — common for .deb / snap / tarball installs.
  local opt
  for opt in /opt/*/bin/*.sh; do
    [[ -x "$opt" ]] || continue
    local b
    b="$(basename "$opt")"
    is_known_ide_name "$b" || continue
    add_candidate ide "$opt"
  done

  # Remote-dev-server distributions (Orbstack/Gateway).
  local rd
  while IFS= read -r -d '' rd; do
    add_candidate remotedev "$rd"
  done < <(find "$HOME/.cache/JetBrains/RemoteDev/dist" -maxdepth 3 -type f -name 'remote-dev-server.sh' -print0 2>/dev/null)
}

scan_wsl2() {
  local winhome
  winhome="$(win_user_dir)"
  [[ -d "$winhome" ]] || return 0

  # Standalone installs under AppData\Local\Programs\<IDE>\bin\*.exe
  local exe b
  while IFS= read -r -d '' exe; do
    b="$(basename "$exe")"
    case "$b" in
      idea64.exe|pycharm64.exe|webstorm64.exe|goland64.exe|rubymine64.exe|\
      clion64.exe|phpstorm64.exe|datagrip64.exe|rustrover64.exe|rider64.exe|\
      studio64.exe|fleet.exe)
        add_candidate ide "$exe"
        ;;
    esac
  done < <(find "$winhome/AppData/Local/Programs" -maxdepth 4 -type f -name '*.exe' -print0 2>/dev/null)

  # Toolbox shell shims (.cmd) — invoke via cmd.exe.
  local scripts="$winhome/AppData/Local/JetBrains/Toolbox/scripts"
  if [[ -d "$scripts" ]]; then
    local f
    while IFS= read -r -d '' f; do
      b="$(basename "${f%.cmd}")"
      is_known_ide_name "$b" || continue
      add_candidate wincmd "$f"
    done < <(find "$scripts" -maxdepth 1 -type f -name '*.cmd' -print0 2>/dev/null)
  fi

  # Local Linux-side remote-dev-server.sh (still useful inside WSL2).
  local rd
  while IFS= read -r -d '' rd; do
    add_candidate remotedev "$rd"
  done < <(find "$HOME/.cache/JetBrains/RemoteDev/dist" -maxdepth 3 -type f -name 'remote-dev-server.sh' -print0 2>/dev/null)
}

case "$OS_KIND" in
  macos) scan_macos ;;
  linux) scan_linux ;;
  wsl2)  scan_wsl2  ;;
esac

if [[ ${#CANDIDATES[@]} -eq 0 ]]; then
  echo "No JetBrains IDEs detected on this system." >&2
  echo "Install one via the JetBrains Toolbox, then re-run." >&2
  exit 1
fi

# ------------------------------------------------------------------
# Multi-select picker (Handles Space separation & Exclusions via !)
# Reads newline-separated options on stdin, prints selected lines on stdout.
# ------------------------------------------------------------------
pick_multi() {
  local prompt="$1" header="$2"
  if command -v fzf >/dev/null 2>&1; then
    fzf --multi --reverse --prompt="$prompt " --header="$header"
    return
  fi

  # Fallback: numbered list + space-separated input with exclusion support.
  local -a items=()
  local line
  while IFS= read -r line; do items+=("$line"); done

  local total=${#items[@]} i
  while :; do
    {
      echo "$header"
      for (( i=0; i<total; i++ )); do
        printf '  %2d) %s\n' "$((i+1))" "${items[i]}"
      done
      printf '%s' "$prompt (e.g. 1 3, !2 to exclude 2, or a for all): "
    } >&2

    local input
    if ! IFS= read -r input </dev/tty; then
      echo "no tty available; install fzf or run interactively" >&2
      return 1
    fi

    if [[ "$input" == "a" || "$input" == "A" ]]; then
      printf '%s\n' "${items[@]}"
      return
    fi
    [[ -z "$input" ]] && { echo "  please enter at least one number, an exclusion, or 'a'." >&2; continue; }

    local -a toks=()
    IFS=' ' read -r -a toks <<<"$input"

    # Determine if this is an exclusion request (checks if any token starts with !)
    local is_exclusion=0
    local t
    for t in "${toks[@]}"; do
      if [[ "$t" =~ ^\! ]]; then
        is_exclusion=1
        break
      fi
    done

    local -a picked=()
    local bad=0

    if (( is_exclusion )); then
      # Track which indices are explicitly excluded using an associative array map
      local -A excluded_map=()
      
      for t in "${toks[@]}"; do
        [[ -z "$t" ]] && continue
        if [[ ! "$t" =~ ^\![0-9]+$ ]]; then
          echo "  Error: When using exclusions, all items must start with '!' (e.g., !1 !3)" >&2
          bad=1
          break
        fi
        
        local idx="${t#\!}"  # Strip the '!' character
        if (( idx < 1 || idx > total )); then bad=1; break; fi
        excluded_map[$((idx-1))]=1
      done
      
      if (( bad )); then
        echo "  invalid exclusion input; try again." >&2
        continue
      fi

      # Build final selection list by adding everything NOT in our exclusion map
      for (( i=0; i<total; i++ )); do
        if [[ -z "${excluded_map[$i]+abc}" ]]; then
          picked+=("${items[i]}")
        fi
      done
    else
      # Standard additive parsing (space-separated)
      for t in "${toks[@]}"; do
        t="${t// /}"
        [[ -z "$t" ]] && continue
        if [[ ! "$t" =~ ^[0-9]+$ ]]; then bad=1; break; fi
        if (( t < 1 || t > total )); then bad=1; break; fi
        picked+=("${items[t-1]}")
      done
    fi

    if (( bad )); then
      echo "  invalid input; try again." >&2
      continue
    fi
    if (( ${#picked[@]} == 0 )); then
      echo "  no valid selections; try again." >&2
      continue
    fi
    printf '%s\n' "${picked[@]}"
    return
  done
}

pick_action() {
  local input
  while :; do
    {
      echo
      echo "What do you want to do?"
      echo "   1) Install plugins"
      echo "   2) Uninstall plugins"
      printf 'Action (1 or 2): '
    } >&2
    if ! IFS= read -r input </dev/tty; then
      echo "no tty available; run interactively" >&2
      return 1
    fi
    case "$input" in
      1|i|I|install|Install) printf 'install\n'; return ;;
      2|u|U|uninstall|Uninstall) printf 'uninstall\n'; return ;;
      *) echo "  invalid input; enter 1 for install or 2 for uninstall." >&2 ;;
    esac
  done
}

# Locate product-info.json for a launcher and print dataDirectoryName. This
# ties uninstalling to the IDE selected above instead of scanning every
# JetBrains profile on the machine.
product_data_name() {
  local launcher="$1" probe="$1" dir info parent raw

  # Resolve a symlinked Toolbox launcher when possible.
  if [[ -L "$probe" ]]; then
    raw="$(readlink "$probe" 2>/dev/null || true)"
    if [[ -n "$raw" ]]; then
      [[ "$raw" == /* ]] || raw="$(dirname "$probe")/$raw"
      probe="$raw"
    fi
  fi

  # Toolbox .cmd shims point at the real Windows launcher. Resolve that path
  # when running under WSL so product-info.json can be found beside the IDE.
  if [[ "$launcher" == *.cmd && -f "$launcher" ]]; then
    raw="$(tr -d '\r' <"$launcher" | sed -nE 's/.*"([A-Za-z]:\\[^"]+\.exe)".*/\1/p' | head -n 1)"
    if [[ -n "$raw" ]] && command -v wslpath >/dev/null 2>&1; then
      probe="$(wslpath -u "$raw" 2>/dev/null || printf '%s' "$launcher")"
    fi
  elif [[ "$OS_KIND" == "macos" && -f "$launcher" ]]; then
    # Toolbox shell launchers usually contain the real *.app executable path.
    raw="$(sed -nE 's/.*"([^\"]+\.app\/Contents\/MacOS\/[^\"]+)".*/\1/p' "$launcher" | head -n 1)"
    raw="${raw/#\~\//$HOME/}"
    raw="${raw/#\$HOME\//$HOME/}"
    [[ -n "$raw" && -e "$raw" ]] && probe="$raw"
  fi

  dir="$probe"
  [[ -f "$dir" ]] && dir="$(dirname "$dir")"
  while [[ -n "$dir" && "$dir" != "/" ]]; do
    for info in "$dir/product-info.json" "$dir/Resources/product-info.json"; do
      [[ -f "$info" ]] || continue
      sed -nE 's/.*"dataDirectoryName"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$info" | head -n 1
      return
    done
    parent="$(dirname "$dir")"
    [[ "$parent" == "$dir" ]] && break
    dir="$parent"
  done
}

plugin_root_for_target() {
  local launcher="$1" type="$2" data_name
  data_name="$(product_data_name "$launcher")"
  [[ -n "$data_name" ]] || return 1

  if [[ "$type" == "remotedev" ]]; then
    printf '%s/.local/shared/JetBrains/%s\n' "$HOME" "$data_name"
  elif [[ "$OS_KIND" == "macos" ]]; then
    printf '%s/Library/Application Support/JetBrains/%s/plugins\n' "$HOME" "$data_name"
  elif [[ "$OS_KIND" == "wsl2" && ( "$type" == "wincmd" || "$launcher" == /mnt/* ) ]]; then
    printf '%s/AppData/Roaming/JetBrains/%s/plugins\n' "$(win_user_dir)" "$data_name"
  else
    printf '%s/.local/share/JetBrains/%s\n' "$HOME" "$data_name"
  fi
}

xml_tag_value() {
  local xml="$1" tag="$2" rest
  [[ "$xml" == *"<$tag>"* ]] || return 1
  rest="${xml#*<$tag>}"
  rest="${rest%%</$tag>*}"
  # plugin.xml values are normally plain text; trim surrounding whitespace.
  printf '%s' "$rest" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//'
}

# Print "id|name" for one top-level item in an IDE's plugins directory.
plugin_metadata() {
  local item="$1" xml="" archive id name
  if [[ -f "$item/META-INF/plugin.xml" ]]; then
    xml="$(tr '\n' ' ' <"$item/META-INF/plugin.xml")"
  elif [[ -f "$item" ]]; then
    xml="$(unzip -p "$item" META-INF/plugin.xml 2>/dev/null | tr '\n' ' ' || true)"
  elif [[ -d "$item" ]]; then
    while IFS= read -r -d '' archive; do
      xml="$(unzip -p "$archive" META-INF/plugin.xml 2>/dev/null | tr '\n' ' ' || true)"
      [[ -n "$xml" ]] && break
    done < <(find "$item" -maxdepth 3 -type f \( -name '*.jar' -o -name '*.zip' \) -print0 2>/dev/null)
  fi
  [[ -n "$xml" ]] || return 1
  id="$(xml_tag_value "$xml" id || true)"
  name="$(xml_tag_value "$xml" name || true)"
  [[ -n "$id" ]] || id="$name"
  [[ -n "$name" ]] || name="$id"
  [[ -n "$id" ]] || return 1
  printf '%s|%s\n' "$id" "$name"
}

# ------------------------------------------------------------------
# IDE picker
# ------------------------------------------------------------------
declare -a ide_lines=()
for c in "${CANDIDATES[@]}"; do
  # human-readable picker line: just the third field
  ide_lines+=("${c#*|*|}")
done

# Map label -> (type, launcher) so we can recover after picking.
declare -A label_type label_launcher
for c in "${CANDIDATES[@]}"; do
  IFS='|' read -r t l lab <<<"$c"
  label_type[$lab]="$t"
  label_launcher[$lab]="$l"
done

echo "Detected ${#CANDIDATES[@]} JetBrains target(s)."
mapfile -t SELECTED_IDES < <(
  printf '%s\n' "${ide_lines[@]}" |
    pick_multi "IDEs>" "Pick IDE(s) — TAB to toggle (fzf) or spaced indexes"
)

if (( ${#SELECTED_IDES[@]} == 0 )); then
  echo "No IDEs selected; exiting." >&2
  exit 1
fi

# ------------------------------------------------------------------
# Action, then plugin picker
# ------------------------------------------------------------------
ACTION="$(pick_action)"

declare -a plugin_lines=()
declare -A label_to_id
declare -a INSTALLED_PLUGIN_RECORDS=()

if [[ "$ACTION" == "install" ]]; then
  for entry in "${PLUGIN_CATALOG[@]}"; do
    IFS='|' read -r pid plabel <<<"$entry"
    line="$(printf '%-32s  %s' "$plabel" "$pid")"
    plugin_lines+=("$line")
    label_to_id[$line]="$pid"
  done
else
  if ! command -v unzip >/dev/null 2>&1; then
    echo "Uninstall requires 'unzip' so installed plugin IDs can be read safely." >&2
    exit 1
  fi

  declare -A seen_plugin_ids=()
  for ide_label in "${SELECTED_IDES[@]}"; do
    itype="${label_type[$ide_label]}"
    launcher="${label_launcher[$ide_label]}"
    if ! plugin_root="$(plugin_root_for_target "$launcher" "$itype")"; then
      echo "Could not resolve the plugin directory for: $ide_label" >&2
      continue
    fi
    [[ -d "$plugin_root" ]] || continue

    while IFS= read -r -d '' item; do
      metadata="$(plugin_metadata "$item" || true)"
      [[ -n "$metadata" ]] || continue
      IFS='|' read -r pid plabel <<<"$metadata"
      INSTALLED_PLUGIN_RECORDS+=("${pid}|${plabel}|${item}|${plugin_root}|${ide_label}")
      if [[ -z "${seen_plugin_ids[$pid]+x}" ]]; then
        line="$(printf '%-32s  %s' "$plabel" "$pid")"
        plugin_lines+=("$line")
        label_to_id[$line]="$pid"
        seen_plugin_ids[$pid]=1
      fi
    done < <(find "$plugin_root" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
  done

  if (( ${#plugin_lines[@]} == 0 )); then
    echo "No removable user-installed plugins were found for the selected IDE(s)." >&2
    echo "Bundled plugins cannot be uninstalled; disable them in the IDE instead." >&2
    exit 0
  fi
fi

mapfile -t SELECTED_PLUGIN_LINES < <(
  printf '%s\n' "${plugin_lines[@]}" |
    pick_multi "Plugins>" "Pick plugin(s) to ${ACTION} — TAB to toggle (fzf) or spaced indexes"
)

if (( ${#SELECTED_PLUGIN_LINES[@]} == 0 )); then
  echo "No plugins selected; exiting." >&2
  exit 1
fi

declare -a SELECTED_PLUGIN_IDS=()
for line in "${SELECTED_PLUGIN_LINES[@]}"; do
  SELECTED_PLUGIN_IDS+=("${label_to_id[$line]}")
done

# ------------------------------------------------------------------
# Running-IDE preflight
# JetBrains' `installPlugins` refuses to run while the IDE GUI is open
# ("Only one instance of IDEA can be run at a time."). We do a best-effort
# pgrep against the IDE app directory so the user can close them upfront
# instead of seeing N identical errors during the install loop.
# ------------------------------------------------------------------
ide_is_running() {
  local launcher="$1" type="$2"

  # remote-dev-server.sh spawns short-lived workers; WSL2->Windows lookups
  # would need tasklist.exe — skip in both cases.
  case "$type" in
    remotedev|wincmd) return 1 ;;
  esac

  case "$launcher" in
    */Toolbox/apps/*)
      # /.../Toolbox/apps/<ide-dir>/[ch-N/<ver>/]bin/idea.sh -> match "<ide-dir>"
      local rest="${launcher#*/Toolbox/apps/}"
      local ide_dir="${rest%%/*}"
      [[ -n "$ide_dir" ]] && pgrep -f -- "/Toolbox/apps/${ide_dir}/" >/dev/null 2>&1
      ;;
    */Applications/*.app/*)
      local app_path="${launcher%/Contents/MacOS/*}"
      pgrep -f -- "${app_path}/Contents/" >/dev/null 2>&1
      ;;
    *)
      local b
      b="$(basename "$launcher")"
      b="${b%.sh}"
      pgrep -fi -- "$b" >/dev/null 2>&1
      ;;
  esac
}

while :; do
  declare -a _running=()
  for ide_label in "${SELECTED_IDES[@]}"; do
    if ide_is_running "${label_launcher[$ide_label]}" "${label_type[$ide_label]}"; then
      _running+=("$ide_label")
    fi
  done
  (( ${#_running[@]} == 0 )) && break

  {
    echo
    echo "The following IDE(s) appear to be running — plugin changes need them closed:"
    for r in "${_running[@]}"; do
      echo "  - $r"
    done
    echo
    printf 'Close them, then press Enter to retry (or Ctrl+C to abort): '
  } >&2
  if ! IFS= read -r _ </dev/tty; then
    echo "no tty; aborting" >&2
    exit 1
  fi
done

# ------------------------------------------------------------------
# Install/uninstall loop
# ------------------------------------------------------------------
if [[ "$ACTION" == "uninstall" ]]; then
  declare -A selected_id_map=()
  for pid in "${SELECTED_PLUGIN_IDS[@]}"; do selected_id_map[$pid]=1; done

  declare -a TO_REMOVE=()
  for record in "${INSTALLED_PLUGIN_RECORDS[@]}"; do
    IFS='|' read -r pid plabel plugin_path plugin_root ide_label <<<"$record"
    [[ -n "${selected_id_map[$pid]+x}" ]] || continue
    # Safety boundary: only top-level children found in the resolved plugin root.
    [[ "$(dirname "$plugin_path")" == "$plugin_root" ]] || continue
    TO_REMOVE+=("$record")
  done

  if (( ${#TO_REMOVE[@]} == 0 )); then
    echo "None of the selected plugins are installed in the selected IDE(s)." >&2
    exit 0
  fi

  echo
  echo "The following plugin installations will be removed:"
  for record in "${TO_REMOVE[@]}"; do
    IFS='|' read -r pid plabel plugin_path plugin_root ide_label <<<"$record"
    echo "  - $plabel ($pid)"
    echo "      IDE:  $ide_label"
    echo "      Path: $plugin_path"
  done
  printf 'Continue? [y/N]: ' >&2
  if ! IFS= read -r confirm </dev/tty || [[ ! "$confirm" =~ ^[Yy]$ ]]; then
    echo "Uninstall cancelled."
    exit 0
  fi

  removed=0
  failed=0
  declare -a FAILED_LIST=()
  for record in "${TO_REMOVE[@]}"; do
    IFS='|' read -r pid plabel plugin_path plugin_root ide_label <<<"$record"
    printf 'Removing %-32s from %s... ' "$plabel" "$ide_label"
    if rm -rf -- "$plugin_path"; then
      echo "ok"
      removed=$((removed + 1))
    else
      echo "FAILED"
      failed=$((failed + 1))
      FAILED_LIST+=("${ide_label} :: ${pid}")
    fi
  done

  echo "Done. Removed: ${removed}, Failed: ${failed}. Restart the IDE(s) to finish."
  if (( failed > 0 )); then
    printf '  - %s\n' "${FAILED_LIST[@]}" >&2
    exit 1
  fi
  exit 0
fi

LOG_FILE="$(mktemp -t jb-install.XXXXXX)"
trap 'rm -f "$LOG_FILE"' EXIT

installed=0
failed=0
declare -a FAILED_LIST=()

echo
echo "Installing ${#SELECTED_PLUGIN_IDS[@]} plugin(s) into ${#SELECTED_IDES[@]} IDE(s)..."
echo

handle_single_instance_abort() {
  local ide_label="$1" current_pid="$2"
  shift 2
  echo
  echo "  ! '${ide_label}' is still running. Skipping remaining plugins for this IDE." >&2
  echo "  ! Close the IDE and re-run the script to finish."                           >&2
  # Mark current and remaining-for-this-IDE plugins as failed.
  local mark_from=0 p
  for p in "${SELECTED_PLUGIN_IDS[@]}"; do
    if (( mark_from )) || [[ "$p" == "$current_pid" ]]; then
      mark_from=1
      failed=$((failed + 1))
      FAILED_LIST+=("${ide_label} :: ${p}  (IDE running — close it and re-run)")
    fi
  done
}

for ide_label in "${SELECTED_IDES[@]}"; do
  itype="${label_type[$ide_label]}"
  launcher="${label_launcher[$ide_label]}"

  echo ">>> $ide_label"
  ide_aborted=0
  for pid in "${SELECTED_PLUGIN_IDS[@]}"; do
    (( ide_aborted )) && break
    printf '        %-34s ' "$pid"
    rc=0
    case "$itype" in
      ide|remotedev)
        "$launcher" installPlugins "$pid" >"$LOG_FILE" 2>&1 || rc=$?
        ;;
      wincmd)
        cmd.exe /c "$launcher" installPlugins "$pid" >"$LOG_FILE" 2>&1 || rc=$?
        ;;
    esac

    if (( rc == 0 )); then
      echo "ok"
      installed=$((installed + 1))
    elif grep -q "Only one instance" "$LOG_FILE" 2>/dev/null; then
      echo "SKIPPED"
      handle_single_instance_abort "$ide_label" "$pid"
      ide_aborted=1
    else
      echo "FAILED"
      failed=$((failed + 1))
      FAILED_LIST+=("${ide_label} :: ${pid}")
      sed 's/^/         /' "$LOG_FILE" >&2 || true
    fi
  done
  echo
done

echo "Done. Installed: ${installed}, Failed: ${failed}"

if (( failed > 0 )); then
  echo "Failed:" >&2
  for f in "${FAILED_LIST[@]}"; do
    echo "  - $f" >&2
  done
  exit 1
fi
