#!/usr/bin/env bash
set -euo pipefail

# runplan's long-lived daemon may start with a minimal PATH. Append installed
# pnpm shim/CLI locations as fallback — caller-provided PATH entries MUST win
# (callers and tests rely on shadowing the managers via PATH).
export PATH="${PATH:-/usr/bin:/bin}:$HOME/.claude/bin:$HOME/.npm-global/bin:$HOME/.local/bin:/usr/local/bin"

DEP_PROVISION_FAILED=17
CACHE_ENTRY_MARKER=".runplan-cache-complete"

usage() {
  echo "usage: $0 <detect|provision|repair|warm> <workspace>" >&2
}

die_usage() {
  usage
  exit 2
}

die_provision() {
  local message="$1"
  echo "$0: $message" >&2
  exit "$DEP_PROVISION_FAILED"
}

require_workspace() {
  local workspace="${1:-}"
  [[ -n "$workspace" ]] || die_usage
  [[ -d "$workspace" ]] || die_provision "workspace not found: $workspace"
}

detect_from_package_manager() {
  local workspace="$1"
  local package_json="$workspace/package.json"
  [[ -f "$package_json" ]] || return 1

  local package_manager
  package_manager="$(
    node -e '
      const fs = require("fs");
      try {
        const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
        const value = data && typeof data.packageManager === "string" ? data.packageManager : "";
        process.stdout.write(value);
      } catch (error) {
        process.exit(1);
      }
    ' "$package_json"
  )" || die_provision "failed to parse package.json"

  [[ -n "$package_manager" ]] || return 1

  case "${package_manager%%@*}" in
    pnpm|npm|yarn|bun)
      printf '%s\n' "$package_manager"
      return 0
      ;;
    *)
      die_provision "unsupported packageManager in $package_json: $package_manager"
      ;;
  esac
}

detect_from_lockfiles() {
  local workspace="$1"

  if [[ -f "$workspace/pnpm-lock.yaml" ]]; then
    echo "pnpm"
    return 0
  fi

  if [[ -f "$workspace/yarn.lock" ]]; then
    echo "yarn"
    return 0
  fi

  if [[ -f "$workspace/package-lock.json" ]]; then
    echo "npm"
    return 0
  fi

  if [[ -f "$workspace/bun.lockb" || -f "$workspace/bun.lock" ]]; then
    echo "bun"
    return 0
  fi

  return 1
}

detect_toolchain() {
  local workspace="$1"
  local repo_root

  detect_from_package_manager "$workspace" && return 0
  detect_from_lockfiles "$workspace" && return 0

  repo_root="$(repo_root_for_workspace "$workspace")"
  if [[ "$repo_root" != "$(cd "$workspace" && pwd)" ]]; then
    detect_from_package_manager "$repo_root" && return 0
    detect_from_lockfiles "$repo_root" && return 0
  fi

  die_provision "could not detect package manager for $workspace"
}

package_manager_name() {
  local package_manager="$1"
  printf '%s\n' "${package_manager%%@*}"
}

package_manager_version() {
  local package_manager="$1"
  if [[ "$package_manager" == *"@"* ]]; then
    printf '%s\n' "${package_manager#*@}"
    return 0
  fi
  return 1
}

lockfile_name_for_manager() {
  local manager="$1"

  case "$manager" in
    pnpm) echo "pnpm-lock.yaml" ;;
    yarn) echo "yarn.lock" ;;
    npm) echo "package-lock.json" ;;
    bun)
      if [[ -f "$2/bun.lockb" ]]; then
        echo "bun.lockb"
      else
        echo "bun.lock"
      fi
      ;;
    *)
      die_provision "unsupported detected package manager: $manager"
      ;;
  esac
}

node_version() {
  node -p 'process.version'
}

node_arch() {
  node -p 'process.arch'
}

repo_root_for_workspace() {
  local workspace="$1"
  if git -C "$workspace" rev-parse --show-toplevel >/dev/null 2>&1; then
    git -C "$workspace" rev-parse --show-toplevel
    return 0
  fi
  (cd "$workspace" && pwd)
}

cache_key() {
  local pm_id="$1"
  local node_ver="$2"
  local arch="$3"
  local lockfile_path="$4"

  node - "$pm_id" "$node_ver" "$arch" "$lockfile_path" <<'EOF'
const crypto = require("crypto");
const fs = require("fs");

try {
  const [, , pmId, nodeVer, arch, lockfilePath] = process.argv;
  const hash = crypto.createHash("sha256");
  hash.update(String(pmId));
  hash.update("\0");
  hash.update(String(nodeVer));
  hash.update("\0");
  hash.update(String(arch));
  hash.update("\0");
  hash.update(fs.readFileSync(lockfilePath));
  process.stdout.write(hash.digest("hex"));
} catch (error) {
  process.exit(1);
}
EOF
}

copy_workspace_for_install() {
  local workspace="$1"
  local staging="$2"
  mkdir -p "$staging"
  tar \
    --exclude='./node_modules' \
    --exclude='./.git' \
    --exclude='./.runplan-cache' \
    -C "$workspace" \
    -cf - . | tar -C "$staging" -xf -
}

run_install() {
  local manager="$1"
  local workspace="$2"

  case "$manager" in
    pnpm)
      if [[ "${RUNPLAN_ALLOW_ONLINE:-}" == "1" ]]; then
        local output rc=0
        output="$(cd "$workspace" && pnpm install --frozen-lockfile 2>&1)" || rc=$?
        if [[ "$rc" -ne 0 ]]; then
          printf '%s\n' "$output" >&2
          if grep -q 'ERR_PNPM_TARBALL_INTEGRITY' <<<"$output"; then
            (cd "$workspace" && pnpm store prune) || die_provision "pnpm store prune failed"
            (cd "$workspace" && pnpm install --no-frozen-lockfile --fix-lockfile) \
              || die_provision "pnpm lockfile integrity repair failed after store prune"
          else
            die_provision "pnpm install failed"
          fi
        fi
      else
        (cd "$workspace" && pnpm install --frozen-lockfile --offline) || die_provision "pnpm install failed"
      fi
      ;;
    yarn)
      (cd "$workspace" && yarn install --immutable --offline) || die_provision "yarn install failed"
      ;;
    npm)
      (cd "$workspace" && npm ci --offline) || die_provision "npm ci failed"
      ;;
    bun)
      (cd "$workspace" && bun install --frozen-lockfile --offline) || die_provision "bun install failed"
      ;;
    *)
      die_provision "unsupported detected package manager: $manager"
      ;;
  esac
}

require_online_allowed() {
  [[ "${RUNPLAN_ALLOW_ONLINE:-}" == "1" ]] || {
    echo "$0: online dependency repair requires RUNPLAN_ALLOW_ONLINE=1" >&2
    exit 2
  }
}

run_online_manager() {
  local manager="$1"
  shift
  "$manager" "$@"
}

repair_lockfile() {
  local manager="$1"
  local workspace="$2"
  local pm_id="$3"

  case "$manager" in
    pnpm)
      (cd "$workspace" && run_online_manager pnpm install --lockfile-only) || die_provision "pnpm lockfile repair failed"
      ;;
    npm)
      (cd "$workspace" && run_online_manager npm install --package-lock-only) || die_provision "npm lockfile repair failed"
      ;;
    yarn)
      if yarn_is_berry "$pm_id" "$workspace"; then
        (cd "$workspace" && run_online_manager yarn install --mode=update-lockfile) || die_provision "yarn lockfile repair failed"
      else
        (cd "$workspace" && run_online_manager yarn install --ignore-scripts) || die_provision "yarn lockfile repair failed"
      fi
      ;;
    bun)
      (cd "$workspace" && run_online_manager bun install --lockfile-only) || die_provision "bun lockfile repair failed"
      ;;
    *)
      die_provision "unsupported detected package manager: $manager"
      ;;
  esac

  echo "repaired: lockfile"
}

warm_store() {
  local manager="$1"
  local workspace="$2"

  case "$manager" in
    pnpm)
      (cd "$workspace" && run_online_manager pnpm install --force --frozen-lockfile) || die_provision "pnpm store warm failed"
      ;;
    npm)
      (cd "$workspace" && run_online_manager npm ci --dry-run) || die_provision "npm store warm failed"
      ;;
    yarn)
      (cd "$workspace" && run_online_manager yarn install --immutable) || die_provision "yarn store warm failed"
      ;;
    bun)
      (cd "$workspace" && run_online_manager bun install --frozen-lockfile) || die_provision "bun store warm failed"
      ;;
    *)
      die_provision "unsupported detected package manager: $manager"
      ;;
  esac

  echo "repaired: store"
}

repair_deps() {
  local workspace="$1"
  local pm_id manager
  require_online_allowed
  pm_id="$(detect_toolchain "$workspace")"
  manager="$(package_manager_name "$pm_id")"
  repair_lockfile "$manager" "$workspace" "$pm_id"
  warm_store "$manager" "$workspace"
}

warm_deps() {
  local workspace="$1"
  local pm_id manager
  require_online_allowed
  pm_id="$(detect_toolchain "$workspace")"
  manager="$(package_manager_name "$pm_id")"
  warm_store "$manager" "$workspace"
}

make_read_only() {
  local target="$1"
  chmod -R a-w "$target"
}

make_writable() {
  local target="$1"
  [[ -e "$target" ]] || return 0
  chmod -R u+w "$target" 2>/dev/null || true
}

yarn_is_berry() {
  local pm_id="$1"
  local workspace="$2"
  [[ "$(package_manager_name "$pm_id")" == "yarn" ]] || return 1

  local version
  version="$(package_manager_version "$pm_id" 2>/dev/null || true)"
  if [[ -n "$version" ]]; then
    local major="${version%%.*}"
    if [[ "$major" =~ ^[0-9]+$ ]] && (( major >= 2 )); then
      return 0
    fi
  fi

  [[ -f "$workspace/.yarnrc.yml" ]]
}

uses_native_store_delegate() {
  local pm_id="$1"
  local workspace="$2"
  local manager
  manager="$(package_manager_name "$pm_id")"
  [[ "$manager" == "pnpm" ]] && return 0
  yarn_is_berry "$pm_id" "$workspace" && return 0
  return 1
}

materialize_probe_file() {
  local cache_root="$1"
  printf '%s\n' "$cache_root/.materialize-mode"
}

cache_gc_keep() {
  local value="${RUNPLAN_CACHE_GC_KEEP:-5}"
  [[ "$value" =~ ^[0-9]+$ ]] || die_provision "RUNPLAN_CACHE_GC_KEEP must be a non-negative integer"
  printf '%s\n' "$value"
}

cache_gc_max_bytes() {
  local value="${RUNPLAN_CACHE_GC_MAX_BYTES:-1073741824}"
  [[ "$value" =~ ^[0-9]+$ ]] || die_provision "RUNPLAN_CACHE_GC_MAX_BYTES must be a non-negative integer"
  printf '%s\n' "$value"
}

cache_entry_size_bytes() {
  local path="$1"
  local size_kb
  size_kb="$(du -sk "$path" | awk '{print $1}')"
  printf '%s\n' $(( size_kb * 1024 ))
}

sweep_orphan_tmp_entries() {
  local cache_root="$1"
  [[ -d "$cache_root" ]] || return 0
  find "$cache_root" -mindepth 1 -maxdepth 1 \
    \( -name '*.tmp.*' -o -name '.tmp-*' \) \
    -exec rm -rf {} +
}

touch_cache_entry() {
  local path="$1"
  [[ -d "$path" ]] || return 0
  touch "$path"
}

cache_entry_valid() {
  local cache_entry="$1"
  [[ -d "$cache_entry/node_modules" && -f "$cache_entry/$CACHE_ENTRY_MARKER" ]]
}

gc_cache_entries() {
  local cache_root="$1"
  local live_key="$2"
  local keep_limit byte_limit live_entry
  local total_count=0
  local total_bytes=0
  local line path size
  live_entry="$cache_root/$live_key"
  keep_limit="$(cache_gc_keep)"
  byte_limit="$(cache_gc_max_bytes)"

  sweep_orphan_tmp_entries "$cache_root"
  [[ -d "$cache_root" ]] || return 0

  while IFS=$'\t' read -r _mtime path size; do
    [[ -n "$path" ]] || continue
    total_count=$(( total_count + 1 ))
    total_bytes=$(( total_bytes + size ))
  done < <(
    find "$cache_root" -mindepth 1 -maxdepth 1 -type d \
      ! -name '*.tmp.*' \
      ! -name '.tmp-*' \
      -print0 | while IFS= read -r -d '' candidate; do
        printf '%s\t%s\t%s\n' "$(stat -c %Y "$candidate")" "$candidate" "$(cache_entry_size_bytes "$candidate")"
      done | sort -n
  )

  while IFS=$'\t' read -r _mtime path size; do
    [[ -n "$path" ]] || continue
    if (( total_count <= keep_limit && total_bytes <= byte_limit )); then
      break
    fi
    if [[ "$path" == "$live_entry" ]]; then
      continue
    fi
    make_writable "$path"
    rm -rf "$path"
    total_count=$(( total_count - 1 ))
    total_bytes=$(( total_bytes - size ))
  done < <(
    find "$cache_root" -mindepth 1 -maxdepth 1 -type d \
      ! -name '*.tmp.*' \
      ! -name '.tmp-*' \
      -print0 | while IFS= read -r -d '' candidate; do
        printf '%s\t%s\t%s\n' "$(stat -c %Y "$candidate")" "$candidate" "$(cache_entry_size_bytes "$candidate")"
      done | sort -n
  )
}

write_materialize_probe() {
  local cache_root="$1"
  local mode="$2"
  local probe_file
  probe_file="$(materialize_probe_file "$cache_root")"
  printf '%s\n' "$mode" >"$probe_file"
}

read_materialize_probe() {
  local cache_root="$1"
  local probe_file
  probe_file="$(materialize_probe_file "$cache_root")"
  [[ -f "$probe_file" ]] || return 1
  local mode
  mode="$(tr -d '\n' <"$probe_file")"
  case "$mode" in
    reflink|hardlink|copy)
      printf '%s\n' "$mode"
      return 0
      ;;
  esac
  return 1
}

copy_tree_with_mode() {
  local mode="$1"
  local source="$2"
  local dest="$3"

  case "$mode" in
    reflink)
      cp --reflink=always -a "$source" "$dest"
      ;;
    hardlink)
      cp -al "$source" "$dest"
      ;;
    copy)
      cp -a "$source" "$dest"
      ;;
    *)
      return 1
      ;;
  esac
}

probe_materialize_mode() {
  local cache_root="$1"
  local probe_root="$cache_root/.probe.$$"
  local source="$probe_root/source"
  local dest="$probe_root/dest"
  local mode

  rm -rf "$probe_root"
  mkdir -p "$source"
  printf 'probe' >"$source/file.txt"

  for mode in reflink hardlink copy; do
    rm -rf "$dest"
    if copy_tree_with_mode "$mode" "$source" "$dest" >/dev/null 2>&1; then
      write_materialize_probe "$cache_root" "$mode"
      rm -rf "$probe_root"
      printf '%s\n' "$mode"
      return 0
    fi
  done

  rm -rf "$probe_root"
  die_provision "failed to probe workspace materialization mode"
}

materialize_mode() {
  local cache_root="$1"
  read_materialize_probe "$cache_root" && return 0
  probe_materialize_mode "$cache_root"
}

materialize_workspace_node_modules() {
  local workspace="$1"
  local cache_entry="$2"
  local cache_root="$3"
  local source="$cache_entry/node_modules"
  local dest="$workspace/node_modules"
  local mode

  [[ -d "$source" ]] || die_provision "cache entry missing node_modules: $source"
  make_writable "$dest"
  rm -rf "$dest"
  mode="$(materialize_mode "$cache_root")"
  copy_tree_with_mode "$mode" "$source" "$dest" || die_provision "failed to materialize node_modules via $mode"
  make_writable "$dest"
}

populate_cache_entry() {
  local workspace="$1"
  local cache_entry="$2"
  local manager="$3"

  local staging_root
  staging_root="$(dirname "$cache_entry")"
  local staging="$staging_root/$(basename "$cache_entry").tmp.$$"

  rm -rf "$staging"
  mkdir -p "$staging"

  copy_workspace_for_install "$workspace" "$staging"
  run_install "$manager" "$staging"
  # zero-dependency installs legitimately create no node_modules
  mkdir -p "$staging/node_modules"
  printf 'v1\n' >"$staging/$CACHE_ENTRY_MARKER"
  make_read_only "$staging"
  mv "$staging" "$cache_entry"
}

provision_deps() {
  local workspace="$1"
  local pm_id manager repo_root cache_root lockfile_name lockfile_path key cache_entry
  if ! pm_id="$(detect_from_package_manager "$workspace" || detect_from_lockfiles "$workspace")" || [[ -z "$pm_id" ]]; then
    return 0
  fi
  manager="$(package_manager_name "$pm_id")"
  repo_root="$(repo_root_for_workspace "$workspace")"

  if uses_native_store_delegate "$pm_id" "$workspace"; then
    run_install "$manager" "$workspace"
    return 0
  fi

  cache_root="$repo_root/.runplan-cache/deps"
  lockfile_name="$(lockfile_name_for_manager "$manager" "$workspace")"
  lockfile_path="$workspace/$lockfile_name"
  [[ -f "$lockfile_path" ]] || die_provision "missing lockfile for $manager: $lockfile_path"
  key="$(cache_key "$pm_id" "$(node_version)" "$(node_arch)" "$lockfile_path")" || die_provision "failed to derive cache key"
  cache_entry="$cache_root/$key"

  mkdir -p "$cache_root"
  exec {LOCK_FD}>"$cache_root/$key.lock"
  flock -x "$LOCK_FD" || die_provision "failed to acquire cache lock: $cache_root/$key.lock"
  if ! cache_entry_valid "$cache_entry"; then
    make_writable "$cache_entry"
    rm -rf "$cache_entry"
    populate_cache_entry "$workspace" "$cache_entry" "$manager"
  fi
  touch_cache_entry "$cache_entry"
  flock -u "$LOCK_FD"
  exec {LOCK_FD}>&-

  exec {GC_LOCK_FD}>"$cache_root/.gc.lock"
  flock -x "$GC_LOCK_FD" || die_provision "failed to acquire cache GC lock: $cache_root/.gc.lock"
  gc_cache_entries "$cache_root" "$key"
  flock -u "$GC_LOCK_FD"
  exec {GC_LOCK_FD}>&-

  materialize_workspace_node_modules "$workspace" "$cache_entry" "$cache_root"
}

main() {
  local command="${1:-}"
  local workspace="${2:-}"
  local pm_id

  require_workspace "$workspace"

  case "$command" in
    detect)
      pm_id="$(detect_toolchain "$workspace")"
      package_manager_name "$pm_id"
      ;;
    provision)
      provision_deps "$workspace"
      ;;
    repair)
      repair_deps "$workspace"
      ;;
    warm)
      warm_deps "$workspace"
      ;;
    *)
      die_usage
      ;;
  esac
}

main "$@"
