#!/usr/bin/env bash
# Workstation side of ~/.claude fleet convergence. Sourced by bin/buildbox.
#
# The set of paths that must be identical on every box is not re-declared here: it is
# modules/workstation/manifest/claude.json, the same manifest `deckctl sync` installs the
# workstation from. A second list would be a second source of truth, which is the defect
# this exists to remove. Everything under ~/.claude that the manifest does not name is
# machine-local by construction — transcripts, sessions, shell snapshots, run state,
# receipts, caches, job state — and is never shipped.

CLAUDE_MANIFEST="${CLAUDE_MANIFEST:-$MOD/../workstation/manifest/claude.json}"
CLAUDE_REPO_ROOT="${CLAUDE_REPO_ROOT:-$MOD/../workstation/claude}"
CLAUDE_DEPLOY_ROOT="${OVERDECK_DEPLOY_DIR:-$HOME/.local/share/overdeck/deploy}/modules/workstation/claude"
# Build boxes receive a standalone ~/.claude tree, not this workstation's adjacent
# checkouts or per-user tools.  Override only in isolated tests.
CLAUDE_HOME_TARGET="${CLAUDE_HOME_TARGET:-$HOME/.claude}"
CLAUDE_SOURCE_HOME="${CLAUDE_SOURCE_HOME:-$HOME}"
CLAUDE_DEVTOOLS="${CLAUDE_DEVTOOLS:-$MOD/devtools.json}"

claude_home_entry_list() { # -> "<path>\t<repo|deploy>\t<source-relative-path>" per entry
  jq -r '.entries[]? | [.path, (.source // "repo"), (.src // .path)] | @tsv' "$CLAUDE_MANIFEST"
}

claude_home_entries() { # -> one entry path per line
  claude_home_entry_list | cut -f1
}

claude_home_entry_source() { # path -> repo|deploy, per the manifest
  local src
  src=$(jq -r --arg e "$1" '.entries[]? | select(.path == $e) | (.source // "repo")' "$CLAUDE_MANIFEST")
  [ -n "$src" ] || { echo "buildbox: claude manifest declares no entry '$1'" >&2; return 1; }
  printf '%s\n' "$src"
}

# Entries the workstation *executes* rather than reads — hooks, lib, bin — are declared
# `source: deploy`, so their canonical bytes are the landed deploy clone, not the working
# tree. Shipping the working tree for those would put code on three boxes that the
# workstation itself is not running.
claude_home_source_path() { # manifest path source source-relative-path -> absolute path
  local relative="${3:-$1}"
  case "$2" in
    repo)   printf '%s/%s\n' "$CLAUDE_REPO_ROOT" "$relative" ;;
    deploy) printf '%s/%s\n' "$CLAUDE_DEPLOY_ROOT" "$relative" ;;
    *) echo "buildbox: unknown manifest source '$2' for claude:$1" >&2; return 1 ;;
  esac
}

claude_home_settings_commands() { # settings.json -> one hook command per line
  jq -r '.. | objects | select(.type? == "command" and (.command? | type == "string")) | .command' "$1"
}

claude_home_devtools_root() { # -> the devtools root as it exists on a box
  local root
  root="$(jq -r '.root // empty' "$CLAUDE_DEVTOOLS")" || return 1
  [ -n "$root" ] || { echo "buildbox: $CLAUDE_DEVTOOLS declares no devtools root" >&2; return 1; }
  printf '%s\n' "${root/#\~/${CLAUDE_HOME_TARGET%/.claude}}"
}

# devtools.json owns where a fleet tool lives, so the workstation->box prefix map lives
# with it rather than being re-derived from path basenames here.
claude_home_load_rewrites() { # -> fills CLAUDE_HOOK_RW_FROM/CLAUDE_HOOK_RW_TO
  local from to devtools_root
  devtools_root="$(claude_home_devtools_root)" || return 1
  CLAUDE_HOOK_RW_FROM=(); CLAUDE_HOOK_RW_TO=()
  while IFS=$'\t' read -r from to; do
    [ -n "$from" ] || continue
    CLAUDE_HOOK_RW_FROM+=("$from"); CLAUDE_HOOK_RW_TO+=("$to")
  done < <(jq -r \
      --arg src "$CLAUDE_SOURCE_HOME" --arg claude_home "$CLAUDE_HOME_TARGET" --arg devtools "$devtools_root" '
    .hook_paths.rewrites[]?
    | [ (.from | sub("^~"; $src)),
        (.to | gsub("\\{claude_home\\}"; $claude_home) | gsub("\\{devtools_root\\}"; $devtools)) ]
    | @tsv' "$CLAUDE_DEVTOOLS")
}

claude_home_rewrite_command() { # command -> command with declared prefixes replaced
  local cmd="$1" out=() tok bare i from
  read -r -a out <<<"$cmd"
  for i in "${!out[@]}"; do
    tok="${out[$i]}"
    bare="${tok#[\"\']}"
    for ((from = 0; from < ${#CLAUDE_HOOK_RW_FROM[@]}; from++)); do
      case "$bare" in
        "${CLAUDE_HOOK_RW_FROM[$from]}" | "${CLAUDE_HOOK_RW_FROM[$from]}"/*)
          out[$i]="${tok/"${CLAUDE_HOOK_RW_FROM[$from]}"/"${CLAUDE_HOOK_RW_TO[$from]}"}"
          break
          ;;
      esac
    done
  done
  printf '%s\n' "${out[*]}"
}

# settings.json is shared configuration, but some of its hooks name where a tool sits on
# this workstation. Those registrations are rewritten to the box-side location, never
# dropped: a dropped registration is a gate that silently stops running on every box.
claude_home_prepare_settings() { # staged-settings.json
  local settings="$1" cmd rewritten map tmp
  claude_home_load_rewrites || return 1
  map="$(
    claude_home_settings_commands "$settings" | sort -u | while IFS= read -r cmd; do
      rewritten="$(claude_home_rewrite_command "$cmd")"
      [ "$rewritten" = "$cmd" ] || jq -n --arg o "$cmd" --arg n "$rewritten" '{($o): $n}'
    done | jq -s 'add // {}'
  )" || { echo "buildbox: could not map buildbox hook paths: $settings" >&2; return 1; }
  local mode
  mode="$(stat -c %a "$settings")" || return 1
  tmp="$(mktemp "${settings}.XXXXXX")" || return 1
  if ! jq --argjson map "$map" '
    walk(if type == "object" and .type? == "command" and (.command? | type == "string")
         then .command = ($map[.command] // .command) else . end)
  ' "$settings" >"$tmp"; then
    rm -f "$tmp"
    echo "buildbox: could not prepare buildbox settings: $settings" >&2
    return 1
  fi
  chmod "$mode" "$tmp" || { rm -f "$tmp"; return 1; }
  mv "$tmp" "$settings"
}

# Every hook registration is part of the payload's contract, so every absolute path a
# rewritten command names must be accounted for: payload state is checked against the
# staged tree, a devtools path must name a tool devtools.json declares, and anything else
# is a path no box has. Checking the staged tree rather than this workstation is what
# stops a settings or manifest edit from making a box look converged while Claude is
# quietly unable to start a hook.
claude_home_validate_settings_hooks() { # staged-settings.json staged-claude-root
  local settings="$1" tree="$2" devtools_root command tok bare tool rc=0
  local -a tokens
  devtools_root="$(claude_home_devtools_root)" || return 1
  while IFS= read -r command; do
    read -r -a tokens <<<"$command"
    for tok in "${tokens[@]}"; do
      bare="${tok#[\"\']}"; bare="${bare%[\"\']}"
      case "$bare" in /*) ;; *) continue ;; esac
      case "$bare" in
        "$CLAUDE_HOME_TARGET"/*)
          [ -e "$tree/${bare#"$CLAUDE_HOME_TARGET"/}" ] \
            || { echo "buildbox: settings hook target missing from payload: $bare" >&2; rc=1; }
          ;;
        "$devtools_root"/*)
          tool="${bare#"$devtools_root"/}"; tool="${tool%%/*}"
          jq -e --arg t "$tool" '[.tools[]?.name] | index($t)' "$CLAUDE_DEVTOOLS" >/dev/null \
            || { echo "buildbox: settings hook names $bare, but devtools.json declares no tool '$tool'" >&2; rc=1; }
          ;;
        *)
          echo "buildbox: settings hook names $bare, which is neither payload state under $CLAUDE_HOME_TARGET nor a declared devtools path — no box has it (ship it in the claude manifest, or declare a hook_paths rewrite in $CLAUDE_DEVTOOLS)" >&2
          rc=1
          ;;
      esac
    done
  done < <(claude_home_settings_commands "$settings")
  return "$rc"
}

claude_home_discard_stage() { # stage-dir
  local stage="${1:-}"
  [ -n "$stage" ] && [ -d "$stage" ] || return 0
  # Manifest entries may be copied from immutable releases. Reopen only temporary
  # directories so cleanup can unlink their children; never mutate source targets.
  find "$stage" -type d -exec chmod u+rwx {} + 2>/dev/null || true
  rm -rf "$stage"
}

# Fail-closed: a missing source is a broken declaration, never a silently thinner payload.
# Shipping "everything that happened to exist" is how a box ends up converged-looking and
# missing a hook.
claude_home_stage() { # -> prints a staging dir holding tree/ and entries
  local stage tree path source relative src
  stage="$(mktemp -d)"
  tree="$stage/tree"
  mkdir -p "$tree"
  while IFS=$'\t' read -r path source relative; do
    [ -n "$path" ] || continue
    src="$(claude_home_source_path "$path" "$source" "$relative")" \
      || { claude_home_discard_stage "$stage"; return 1; }
    if [ ! -e "$src" ] && [ ! -L "$src" ]; then
      echo "buildbox: claude manifest declares $path ($source:$relative) but $src does not exist" >&2
      claude_home_discard_stage "$stage"; return 1
    fi
    mkdir -p "$tree/$(dirname "$path")"
    cp -a "$src" "$tree/$path"
  done < <(claude_home_entry_list)
  claude_home_prepare_settings "$tree/settings.json" \
    && claude_home_validate_settings_hooks "$tree/settings.json" "$tree" \
    || { claude_home_discard_stage "$stage"; return 1; }
  claude_home_entries >"$stage/entries"
  printf '%s' "$stage"
}

# The box holds real files where the workstation holds symlinks into the deploy clone:
# a box has no deploy clone, and a dangling symlink is a hook that does not run. Symlinks
# *inside* an entry (~/.claude/bin/bun onto the cpu-guard shim) are preserved as symlinks,
# which is why the digest covers link targets.
push_claude_home() { # host stage-dir
  local host="$1" stage="$2" have
  have=$(sshx "$host" "CLAUDE_HOME_ENTRIES=$CLAUDE_HOME_ENTRIES bash -s" \
    < <(cat "$PARITY"; echo 'claude_home_digest_env') 2>/dev/null | tr -d '\r\n')
  if [ "$have" = "$EXPECT_CLAUDE_HOME" ]; then
    echo "OK    claude-home already $have"
    return 0
  fi
  tar -C "$stage" -czf - tree entries \
    | sshx "$host" 'set -e
        d=$(mktemp -d)
        cleanup() { find "$d" -type d -exec chmod u+rwx {} + 2>/dev/null || true; rm -rf "$d"; }
        trap cleanup EXIT
        tar -xzf - -C "$d"
        mkdir -p ~/.claude
        while IFS= read -r e; do
          [ -n "$e" ] || continue
          mkdir -p "$HOME/.claude/$(dirname "$e")"
          # Existing entries may themselves be a prior immutable release copy. Reopen
          # directories only when they are real trees about to be replaced; never follow
          # seat-managed entry symlinks into their runtime store.
          if [ -d "$HOME/.claude/$e" ] && [ ! -L "$HOME/.claude/$e" ]; then
            find "$HOME/.claude/$e" -type d -exec chmod u+rwx {} +
          fi
          rm -rf "$HOME/.claude/$e"
          # /tmp and $HOME may be different filesystems. mv then degrades to copy+unlink,
          # and unlinking files from a 0555 release directory fails. Copy preserves modes
          # without requiring write access to the extracted source.
          cp -a "$d/tree/$e" "$HOME/.claude/$e"
        done < "$d/entries"' || return 1
  have=$(sshx "$host" "CLAUDE_HOME_ENTRIES=$CLAUDE_HOME_ENTRIES bash -s" \
    < <(cat "$PARITY"; echo 'claude_home_digest_env') 2>/dev/null | tr -d '\r\n')
  [ "$have" = "$EXPECT_CLAUDE_HOME" ] || {
    local local_entries remote_entries
    local_entries=$(claude_home_entry_digests "$stage/tree" "${_entries[@]}")
    remote_entries=$(sshx "$host" "CLAUDE_HOME_ENTRIES=$CLAUDE_HOME_ENTRIES bash -s" \
      < <(cat "$PARITY"; echo 'claude_home_entry_digests_env') 2>/dev/null)
    echo "buildbox: claude-home post-write verification failed on $host: have=$have want=$EXPECT_CLAUDE_HOME" >&2
    diff --old-line-format='want %L' --new-line-format='have %L' --unchanged-line-format='' \
      <(printf '%s\n' "$local_entries") <(printf '%s\n' "$remote_entries") >&2 || true
    return 1
  }
  echo "PUSH  claude-home from workstation"
}
