#!/usr/bin/env bash
#
# kristoffer.dev/dotfiles — from a bare machine to my shell in one command.
#
#   curl -fsSL https://install.kristoffer.dev/dotfiles | bash
#   curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- --tools
#   curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- --dry-run
#
# What it does, in order:
#   1. installs git and GNU Stow if they are missing (nothing else, unless --tools)
#   2. clones github.com/KristofferRisa/dotfiles into ~/dotfiles, or fast-forwards it
#   3. moves anything that would block Stow into ~/.dotfiles-backup/<timestamp>/
#   4. hands over to the repo's own install.sh, which does the linking
#   5. checks what is still missing and says what to do about it
#
# The linking logic lives in the repo. This script only gets a machine to the
# point where the repo can run — so it changes rarely, and can be read in full
# before it is piped anywhere: https://kristoffer.dev/dotfiles/install.sh
#
# Everything is inside main(), called on the last line, so a download that is
# cut off halfway runs nothing at all.

set -euo pipefail

DOTFILES_REPO="${DOTFILES_REPO:-https://github.com/KristofferRisa/dotfiles.git}"
DOTFILES_DIR="${DOTFILES_DIR:-$HOME/dotfiles}"
DOTFILES_BRANCH="${DOTFILES_BRANCH:-main}"

DRY_RUN=0
WITH_TOOLS=0
WITH_DEPS=1
BACKUP_DIR="$HOME/.dotfiles-backup/$(date +%Y%m%d-%H%M%S)"

# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------

if [[ -t 1 ]]; then
  C_DIM=$'\033[2m' C_BOLD=$'\033[1m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m'
  C_RED=$'\033[31m' C_BLUE=$'\033[34m' C_RESET=$'\033[0m'
else
  C_DIM='' C_BOLD='' C_GREEN='' C_YELLOW='' C_RED='' C_BLUE='' C_RESET=''
fi

step() { printf '\n%s==>%s %s%s%s\n' "$C_BLUE" "$C_RESET" "$C_BOLD" "$*" "$C_RESET"; }
info() { printf '    %s\n' "$*"; }
ok()   { printf '    %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '    %s!%s %s\n' "$C_YELLOW" "$C_RESET" "$*"; }
# ${var/#$HOME/~} prints a literal backslash on bash 3.2 (macOS), so by hand.
tildify() { case "$1" in "$HOME"*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac; }
die()  { printf '\n%serror:%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }

# Run a command, or only describe it under --dry-run. The description goes to
# stderr so it still shows when the command's own output is silenced.
run() {
  if (( DRY_RUN )); then
    printf '    %s[dry-run]%s %s\n' "$C_DIM" "$C_RESET" "$*" >&2
  else
    "$@"
  fi
}

usage() {
  cat <<'EOF'
kristoffer.dev/dotfiles installer

Usage:
  curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- [options]

Options:
  --tools       also install zsh, tmux, neovim, lazygit, Oh My Zsh and Powerlevel10k
  --no-deps     install nothing; fail if git or stow is missing
  --dry-run     print every change instead of making it
  --dir DIR     clone into DIR instead of ~/dotfiles
  --branch B    check out branch B instead of main
  -h, --help    show this help

Environment:
  DOTFILES_REPO, DOTFILES_DIR, DOTFILES_BRANCH override the defaults above.
EOF
}

parse_args() {
  while (( $# )); do
    case "$1" in
      --tools)   WITH_TOOLS=1 ;;
      --no-deps) WITH_DEPS=0 ;;
      --dry-run) DRY_RUN=1 ;;
      --dir)     [[ $# -ge 2 ]] || die "--dir needs a path"; DOTFILES_DIR="$2"; shift ;;
      --branch)  [[ $# -ge 2 ]] || die "--branch needs a name"; DOTFILES_BRANCH="$2"; shift ;;
      -h|--help) usage; exit 0 ;;
      *)         usage >&2; die "unknown option: $1" ;;
    esac
    shift
  done
}

# ---------------------------------------------------------------------------
# Platform and packages
# ---------------------------------------------------------------------------

OS=""
PM=""
SUDO=""

detect_platform() {
  case "$(uname -s)" in
    Darwin) OS="macOS $(sw_vers -productVersion 2>/dev/null || true)" ;;
    Linux)
      if [[ -r /etc/os-release ]]; then
        # shellcheck source=/dev/null
        OS="$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-Linux}")"
      else
        OS="Linux"
      fi
      ;;
    *) die "unsupported OS: $(uname -s)" ;;
  esac

  # Homebrew wins wherever it exists — it is what the rest of the setup
  # assumes, on macOS and on Linux (linuxbrew) alike.
  if command -v brew >/dev/null 2>&1; then PM=brew
  elif [[ -x /opt/homebrew/bin/brew ]]; then eval "$(/opt/homebrew/bin/brew shellenv)"; PM=brew
  elif [[ -x /home/linuxbrew/.linuxbrew/bin/brew ]]; then eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"; PM=brew
  elif command -v apt-get >/dev/null 2>&1; then PM=apt
  elif command -v dnf >/dev/null 2>&1; then PM=dnf
  elif command -v pacman >/dev/null 2>&1; then PM=pacman
  fi

  if [[ "$PM" != brew && $EUID -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
    SUDO=sudo
  fi

  ok "$OS${PM:+ · $PM}"
}

# The package name differs from the command name in a few places.
pkg_name() {
  case "$1" in
    nvim) echo neovim ;;
    *)    echo "$1" ;;
  esac
}

APT_UPDATED=0

pkg_install() {
  local pkg
  pkg="$(pkg_name "$1")"
  case "$PM" in
    brew)
      if [[ "$1" == ghostty ]]; then
        [[ "$(uname -s)" == Darwin ]] || return 1
        run brew install --cask ghostty
      else
        run brew install "$pkg"
      fi
      ;;
    apt)
      # dpkg ignores -qq; only its errors are worth showing.
      if (( ! APT_UPDATED )); then run $SUDO apt-get update -qq >/dev/null; APT_UPDATED=1; fi
      run $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$pkg" >/dev/null
      ;;
    dnf)    run $SUDO dnf install -y -q "$pkg" >/dev/null ;;
    pacman) run $SUDO pacman -S --needed --noconfirm "$pkg" ;;
    *)      return 1 ;;
  esac
}

ensure() {
  local cmd="$1"
  if command -v "$cmd" >/dev/null 2>&1; then
    ok "$cmd"
    return 0
  fi
  if (( ! WITH_DEPS )) || [[ -z "$PM" ]]; then
    return 1
  fi
  info "installing $cmd…"
  pkg_install "$cmd" && ok "$cmd"
}

install_prerequisites() {
  step "Prerequisites"
  local cmd missing=()
  for cmd in git stow; do
    ensure "$cmd" || missing+=("$cmd")
  done
  if (( ${#missing[@]} )); then
    if (( WITH_DEPS )); then
      die "missing: ${missing[*]}, and no package manager I know was found. Install them and run this again."
    fi
    die "missing: ${missing[*]} (--no-deps). Install them and run this again."
  fi
}

install_tools() {
  (( WITH_TOOLS )) || return 0
  step "Tools (--tools)"
  local cmd
  for cmd in zsh tmux nvim lazygit ghostty; do
    ensure "$cmd" || warn "$cmd: not available from ${PM:-any package manager} here — install it by hand"
  done

  local omz="$HOME/.oh-my-zsh"
  if [[ -d "$omz" ]]; then
    ok "Oh My Zsh"
  else
    info "cloning Oh My Zsh…"
    run git clone -q --depth=1 https://github.com/ohmyzsh/ohmyzsh.git "$omz"
    ok "Oh My Zsh"
  fi

  local p10k="$omz/custom/themes/powerlevel10k"
  if [[ -d "$p10k" ]]; then
    ok "Powerlevel10k"
  else
    info "cloning Powerlevel10k…"
    run git clone -q --depth=1 https://github.com/romkatv/powerlevel10k.git "$p10k"
    ok "Powerlevel10k"
  fi
}

# ---------------------------------------------------------------------------
# The repo
# ---------------------------------------------------------------------------

# Where install.sh and the stow packages actually are. Under --dry-run on a
# machine without the repo, that is a throwaway clone, so the conflict check
# below can still say what *would* be moved.
SOURCE_DIR=""
PREVIEW_DIR=""

cleanup() { [[ -n "$PREVIEW_DIR" ]] && rm -rf "$PREVIEW_DIR"; return 0; }

fetch_repo() {
  step "Repository"
  if [[ -d "$DOTFILES_DIR/.git" ]]; then
    SOURCE_DIR="$DOTFILES_DIR"
    if [[ -n "$(git -C "$DOTFILES_DIR" status --porcelain --untracked-files=no)" ]]; then
      warn "$DOTFILES_DIR has uncommitted changes — leaving it as it is"
      return 0
    fi
    info "updating $DOTFILES_DIR…"
    run git -C "$DOTFILES_DIR" pull -q --ff-only origin "$DOTFILES_BRANCH" \
      || warn "could not fast-forward — resolve it in $DOTFILES_DIR and run this again"
    ok "$DOTFILES_DIR is up to date"
  elif [[ -e "$DOTFILES_DIR" ]]; then
    die "$DOTFILES_DIR exists and is not a git repo. Move it, or pass --dir."
  elif (( DRY_RUN )) && ! command -v git >/dev/null 2>&1; then
    printf '    %s[dry-run]%s git clone %s %s\n' "$C_DIM" "$C_RESET" "$DOTFILES_REPO" "$DOTFILES_DIR"
    info "(git is not installed yet, so the conflict check is skipped)"
  elif (( DRY_RUN )); then
    PREVIEW_DIR="$(mktemp -d)"
    trap cleanup EXIT
    git clone -q --depth=1 --branch "$DOTFILES_BRANCH" "$DOTFILES_REPO" "$PREVIEW_DIR/dotfiles"
    SOURCE_DIR="$PREVIEW_DIR/dotfiles"
    printf '    %s[dry-run]%s git clone %s %s\n' "$C_DIM" "$C_RESET" "$DOTFILES_REPO" "$DOTFILES_DIR"
  else
    info "cloning into $DOTFILES_DIR…"
    git clone -q --branch "$DOTFILES_BRANCH" "$DOTFILES_REPO" "$DOTFILES_DIR"
    SOURCE_DIR="$DOTFILES_DIR"
    ok "cloned $(git -C "$DOTFILES_DIR" log -1 --format='%h %s')"
  fi
}

# Stow refuses to link over a real file, and the repo's install.sh stops on
# the first refusal. On a new machine the usual culprit is a default config
# the OS or an app wrote on first launch. Ask stow what it would refuse, and
# move exactly those top-level entries aside — never delete anything.
backup_conflicts() {
  step "Conflicts"
  # A plain string, not an associative array: macOS still ships bash 3.2.
  local moved=0 pkg target entry line seen=" "
  [[ -n "$SOURCE_DIR" ]] || { info "skipped"; return 0; }
  local verb=moved
  (( DRY_RUN )) && verb="would move"

  for pkg in .config .claude; do
    [[ -d "$SOURCE_DIR/$pkg" ]] || continue
    target="$HOME/$pkg"
    [[ -d "$target" ]] || continue

    # Ask stow (via -d, so nothing has to cd) what it would refuse. It prints
    # the conflicts on its way to a nonzero exit, so that exit is the expected
    # case here, not an error.
    while IFS= read -r line; do
      # stow 2.4: "* cannot stow … over existing target zsh/.zshrc since …"
      # stow 2.3: "* existing target is neither a link nor a directory: zsh/.zshrc"
      entry=""
      if [[ "$line" =~ existing\ target\ ([^[:space:]]+)\ since ]]; then
        entry="${BASH_REMATCH[1]}"
      elif [[ "$line" =~ existing\ target\ [^:]*:\ (.+)$ ]]; then
        entry="${BASH_REMATCH[1]}"
      fi
      [[ -n "$entry" ]] || continue
      entry="${entry%%/*}"
      [[ "$seen" != *" $pkg/$entry "* ]] || continue
      seen+="$pkg/$entry "

      run mkdir -p "$BACKUP_DIR/$pkg"
      run mv "$target/$entry" "$BACKUP_DIR/$pkg/$entry"
      warn "$verb ~/$pkg/$entry → $(tildify "$BACKUP_DIR")/$pkg/"
      moved=$((moved + 1))
    done < <(stow -n -d "$SOURCE_DIR" -t "$target" "$pkg/" 2>&1 || true)
  done

  if (( moved )); then
    info "$moved $( (( DRY_RUN )) && echo 'would be' || echo 'were') moved aside. Nothing is deleted."
  else
    ok "nothing in the way"
  fi
}

link() {
  step "Linking"
  if (( DRY_RUN )); then
    printf '    %s[dry-run]%s %s/install.sh\n' "$C_DIM" "$C_RESET" "$DOTFILES_DIR"
    return 0
  fi
  "$DOTFILES_DIR/install.sh" | sed 's/^/    /'
}

# ---------------------------------------------------------------------------
# What is left
# ---------------------------------------------------------------------------

doctor() {
  step "Doctor"
  local cmd todo=0
  for cmd in zsh tmux nvim lazygit; do
    if command -v "$cmd" >/dev/null 2>&1; then ok "$cmd"
    else warn "$cmd is not installed"; todo=1; fi
  done
  if [[ -d "$HOME/.oh-my-zsh" ]]; then ok "Oh My Zsh"
  else warn "Oh My Zsh is missing — .zshrc sources it"; todo=1; fi
  if [[ -d "$HOME/.oh-my-zsh/custom/themes/powerlevel10k" ]]; then ok "Powerlevel10k"
  else warn "Powerlevel10k is missing — the prompt falls back to plain"; todo=1; fi

  if (( todo && ! WITH_TOOLS )); then
    info ""
    info "Re-run with --tools to install the missing pieces:"
    info "  curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- --tools"
  fi
}

next_steps() {
  printf '\n%s%sDone.%s ' "$C_BOLD" "$C_GREEN" "$C_RESET"
  (( DRY_RUN )) && printf '(dry run — nothing was changed)'
  printf '\n\n'
  if [[ "$(basename "${SHELL:-}")" != zsh ]] && command -v zsh >/dev/null 2>&1; then
    info "Make zsh your login shell:  chsh -s \"$(command -v zsh)\""
  fi
  info "Start a fresh shell:         exec zsh"
  info "First nvim launch installs the plugins. Give it a minute."
  info "The guide: https://kristoffer.dev/dotfiles/"
  printf '\n'
}

main() {
  parse_args "$@"
  printf '%skristoffer.dev/dotfiles%s %s→ %s%s\n' "$C_BOLD" "$C_RESET" "$C_DIM" "$DOTFILES_DIR" "$C_RESET"
  detect_platform
  install_prerequisites
  install_tools
  fetch_repo
  backup_conflicts
  link
  doctor
  next_steps
}

main "$@"
