#!/usr/bin/env bash
# Install the NexusCRM agent tooling into the current user's Claude skills dir.
# Reproducible: the repo is the source of truth; this just lays it down + installs MCP deps.
#
# Usage:  ./install.sh            # install/update into ~/.claude/skills/
#         SKILLS_DIR=/custom ./install.sh
#         SKILLS_ARCHIVE_DIR=/custom ./install.sh   # where replaced copies are kept
#                                                   # (default: "<SKILLS_DIR>-archive")
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILLS_DIR="${SKILLS_DIR:-$HOME/.claude/skills}"
# Strip EVERY trailing separator, not just one: "${x%/}" on "/a/b//" leaves "/a/b/", which would
# compute an archive of "/a/b/-archive". Guard the root so "/" does not become the empty string.
while [ "$SKILLS_DIR" != "/" ] && [ "${SKILLS_DIR%/}" != "$SKILLS_DIR" ]; do
  SKILLS_DIR="${SKILLS_DIR%/}"
done
# The filesystem root is not a real install target, and it is the one value for which the archive
# computation degenerates: "${SKILLS_DIR%/}-archive" would expand to a RELATIVE "-archive" in the
# caller's cwd. Refuse instead of silently writing somewhere nobody named.
if [ "$SKILLS_DIR" = "/" ]; then
  echo "  ✗ SKILLS_DIR must not be the filesystem root." >&2
  exit 2
fi
# Backups go NEXT TO the skills dir, never inside it. A backup kept at
# "$SKILLS_DIR/<component>.bak.<ts>" still contains SKILL.md, so the agent host loads it as a
# SECOND skill carrying the same name and a byte-identical description — two candidates for every
# invocation, with nothing to tell them apart. Observed on a real machine right after an ordinary
# install; the pre-existing ~/.claude/skills-archive on that machine shows someone had already been
# moving them out by hand.
# "${SKILLS_DIR%/}-archive" rather than "$(dirname)/skills-archive": adjacent for the default
# ~/.claude/skills, and for a custom SKILLS_DIR it stays adjacent too instead of escaping into a
# parent nobody named.
SKILLS_ARCHIVE_DIR="${SKILLS_ARCHIVE_DIR:-${SKILLS_DIR%/}-archive}"

echo "Installing NexusCRM agent tooling → $SKILLS_DIR"
mkdir -p "$SKILLS_DIR"

# An archive INSIDE the skills tree defeats the entire point — the backups stay loadable as
# duplicate skills — and one pointing at a component directory would make `mv` try to move a
# directory into itself and abort mid-install. The computed default ("<SKILLS_DIR>-archive") is a
# sibling and can never land inside, so this only ever fires on an explicit override: operator
# error, caught before anything has moved, and therefore worth failing fast on rather than
# silently doing the wrong thing.
if [ -d "$SKILLS_ARCHIVE_DIR" ] || mkdir -p "$SKILLS_ARCHIVE_DIR" 2>/dev/null; then
  _arc_real="$(cd "$SKILLS_ARCHIVE_DIR" 2>/dev/null && pwd -P || true)"
  _skl_real="$(cd "$SKILLS_DIR" 2>/dev/null && pwd -P || true)"
  if [ -n "$_arc_real" ] && [ -n "$_skl_real" ]; then
    case "$_arc_real" in
      "$_skl_real" | "$_skl_real"/*)
        echo "  ✗ SKILLS_ARCHIVE_DIR ($_arc_real) is inside SKILLS_DIR ($_skl_real)." >&2
        echo "    Backups kept there would still load as duplicate skills, which is what this avoids." >&2
        exit 2;;
    esac
    SKILLS_ARCHIVE_DIR="$_arc_real"
  fi
fi

# Echoes a usable archive dir, or fails. Never aborts the install: losing the previous copy is a
# far worse outcome than leaving one duplicate skill behind, so callers fall back in place.
archive_dir() {
  mkdir -p "$SKILLS_ARCHIVE_DIR" 2>/dev/null || return 1
  [ -w "$SKILLS_ARCHIVE_DIR" ] || return 1
  printf '%s' "$SKILLS_ARCHIVE_DIR"
}

# `mv a b` moves a INSIDE b when b already exists as a directory, so two installs in the same
# second would nest one backup within another and the older one would stop being findable.
uniq_path() {
  local base="$1" t="$1" n=0
  while [ -e "$t" ]; do n=$((n + 1)); t="${base}.${n}"; done
  printf '%s' "$t"
}


# Auto-discover every component: any direct subdir containing a SKILL.md. This way a
# skill owner can drop their own folder in here and it installs without editing this script.
COMPONENTS=()
for d in "$HERE"/*/; do
  [ -f "${d}SKILL.md" ] && COMPONENTS+=("$(basename "$d")")
done
[ ${#COMPONENTS[@]} -gt 0 ] || { echo "  ✗ no skill folders (with SKILL.md) found in $HERE" >&2; exit 1; }

# Sweep out backups an earlier version of this script left inside the skills dir; they are
# duplicate skills for as long as they sit there, and fixing only new installs would leave every
# already-affected machine broken.
#
# Matched NARROWLY: "<a component we install>.bak.<digits>" with an optional ".<digits>" collision
# suffix. A bare "*.bak.*" glob would also sweep up unrelated user content — a third-party skill
# legitimately named something like "my.bak.utility" is not ours to move. Being confident the
# naming was "unambiguously ours" was exactly the wrong instinct.
# Only the two shapes this script actually writes: "<epoch>" and "<epoch>.<collision>". A looser
# test accepted things like ".bak.123.456.789", which we never generate — so it belongs to someone
# else, and moving it out of the skills dir disables whatever it was.
stray_re='^[0-9]+(\.[0-9]+)?$'
stray_backups=()
for c in "${COMPONENTS[@]}"; do
  shopt -s nullglob
  for sb in "$SKILLS_DIR/$c".bak.*; do
    sb_suffix="${sb##*/}"
    sb_suffix="${sb_suffix#"$c".bak.}"
    if [[ "$sb_suffix" =~ $stray_re ]]; then
      stray_backups[${#stray_backups[@]}]="$sb"
    fi
  done
  shopt -u nullglob
done
if [ ${#stray_backups[@]} -gt 0 ]; then
  if stray_dest="$(archive_dir)"; then
    for sb in "${stray_backups[@]}"; do
      moved="$(uniq_path "$stray_dest/$(basename "$sb")")"
      mv "$sb" "$moved" && echo "  • moved a stray backup out of the skills dir → $moved"
    done
  else
    echo "  ⚠ ${#stray_backups[@]} old backup(s) remain in $SKILLS_DIR and may load as duplicate skills; $SKILLS_ARCHIVE_DIR is not writable" >&2
  fi
fi

for c in "${COMPONENTS[@]}"; do
  src="$HERE/$c"
  dest="$SKILLS_DIR/$c"
  if [ -d "$dest" ]; then
    ts="$(date +%s)"
    if adir="$(archive_dir)"; then
      backup="$(uniq_path "$adir/${c}.bak.${ts}")"
    else
      echo "  ⚠ $SKILLS_ARCHIVE_DIR is not writable — keeping the backup beside the skill, where the agent host may load it as a duplicate" >&2
      backup="$(uniq_path "${dest}.bak.${ts}")"
    fi
    echo "  • backing up existing $c → $backup"
    mv "$dest" "$backup"
  fi
  # copy without node_modules / backups / OS cruft
  rsync -a --exclude 'node_modules' --exclude '*.bak.*' --exclude '.DS_Store' "$src/" "$dest/"
  v="$(grep -m1 '^version:' "$dest/SKILL.md" 2>/dev/null | awk '{print $2}' || true)"
  echo "  ✓ $c${v:+ (v$v)}"
done

# Point the MCP registration snippet at the resolved server path. JSON argv is
# handed to node verbatim (no shell/tilde/$HOME expansion), so the shipped
# placeholder must become a concrete absolute path for the snippet to work.
MCP_JSON="$SKILLS_DIR/nexuscrm-connect/mcp/mcp.json"
if [ -f "$MCP_JSON" ]; then
  server_path="$SKILLS_DIR/nexuscrm-connect/mcp/server.mjs"
  # Escape characters special to sed's replacement text (\ & |).
  esc_path="$(printf '%s' "$server_path" | sed -e 's/[\\&|]/\\&/g')"
  tmp_json="$(mktemp)"
  sed "s|/absolute/path/to/nexuscrm-connect/mcp/server.mjs|${esc_path}|g" "$MCP_JSON" > "$tmp_json" \
    && mv "$tmp_json" "$MCP_JSON" \
    && echo "  ✓ MCP registration path → $server_path"
fi

# Install MCP server dependencies (Node 18+ required).
MCP="$SKILLS_DIR/nexuscrm-connect/mcp"
if command -v npm >/dev/null 2>&1; then
  echo "  • npm install (MCP server) ..."
  ( cd "$MCP" && npm install --no-audit --no-fund >/dev/null 2>&1 ) && echo "  ✓ MCP deps installed" \
    || echo "  ⚠ npm install failed — run 'cd $MCP && npm install' manually"
else
  echo "  ⚠ npm not found — install Node 18+ then run 'cd $MCP && npm install'"
fi

# ---------------------------------------------------------------------------
# Stamp each component LAST — after the mcp.json path rewrite and npm install above, so the stamp
# describes the tree as it finally rests. Stamping inside the copy loop would record a state this
# script then mutates, and every later verification would report a false mismatch.
#
# We hash file CONTENT, not just version strings. Version metadata is a proxy for content and it
# fails in exactly the case this whole feature is for: overwrite mcp/server.mjs and leave
# mcp/package.json alone, and a version-only check sees two matching numbers and says "fine".
# (The real incident that motivated this happened to replace both files, so a version check would
# have caught it — by luck, not by design.)
sha_of() {
  if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
  elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
  else return 1; fi
}
jstr() { if [ -n "${1:-}" ]; then printf '"%s"' "$1"; else printf 'null'; fi; }

for c in "${COMPONENTS[@]}"; do
  dest="$SKILLS_DIR/$c"
  v="$(grep -m1 '^version:' "$dest/SKILL.md" 2>/dev/null | awk '{print $2}' || true)"
  eng="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$dest/package.json" 2>/dev/null | head -1 || true)"
  [ -n "$eng" ] || eng="${v:-}"
  srv="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$dest/mcp/package.json" 2>/dev/null | head -1 || true)"
  head_rev="$(git -C "$HERE" rev-parse --short HEAD 2>/dev/null || true)"

  # node_modules is npm's, not ours (excluded from the rsync and regenerated locally); the stamp
  # cannot hash itself. If no sha tool exists we emit "files": null and diagnose degrades to a
  # version-only check rather than failing the install — a missing checksum must not block anyone.
  # Path names go into JSON, so backslash and quote MUST be escaped — an unescaped one produces a
  # stamp that no longer parses, and since diagnose now validates strictly that reads as
  # "unverifiable, exit 1" on a perfectly healthy install. Crying wolf on healthy installs is worse
  # than the hole this feature closes.
  #
  # Control characters (a newline in a filename) cannot be represented by this shell loop at all,
  # and would ALSO desync the two sides: skipping such a file here while the Node walker still
  # hashes it would surface as a phantom "added file". So on encountering one we drop to
  # "files": null for the whole component — the already-tested version-only mode — and say so,
  # rather than emitting a stamp that is quietly wrong in either direction.
  files_json=""
  hash_ok=0
  if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then
    hash_ok=1
    while IFS= read -r -d '' f; do
      rel="${f#"$dest"/}"
      case "$rel" in
        *[[:cntrl:]]*)
          echo "  ⚠ $c: path with a control character ($(printf '%q' "$rel")) — recording versions only, file contents will not be verified" >&2
          hash_ok=0; files_json=""; break;;
      esac
      # A file we cannot hash must NOT be quietly dropped while the stamp stays in full-hash mode:
      # if that same file is also unreadable at verify time it is absent from both sides, produces
      # no finding, and diagnose reports ok with hashesChecked=true over a file nobody verified.
      # Degrade the whole component to the already-tested version-only mode instead.
      h="$(sha_of "$f" || true)"
      if [ -z "$h" ]; then
        echo "  ⚠ $c: could not hash $rel — recording versions only; file contents will NOT be verified" >&2
        hash_ok=0; files_json=""; break
      fi
      esc="${rel//\\/\\\\}"   # backslash first, or it would double-escape the quote escapes
      esc="${esc//\"/\\\"}"
      files_json="${files_json:+$files_json,}$(printf '\n    "%s": "%s"' "$esc" "$h")"
    # -print0 / read -d '': newline-delimited output SPLITS a path that contains a newline, so the
    # control-character guard above never sees it and the file is silently dropped from the stamp —
    # while the Node walker still hashes it, surfacing as a phantom "added file" on a healthy
    # install. The guard only works if the path survives transport intact.
    #
    # Deliberately NOT sorted. The stamp is a JSON object compared by key lookup, so order is
    # cosmetic — and sorting here would mean depending on `sort -z`, whose absence would fail
    # inside a process substitution where `set -euo pipefail` cannot see it, silently writing
    # "files": null and turning the content check off without telling anyone. (Checked: this macOS
    # ships sort 2.3-Apple, which does support -z. The point is not to need the answer.)
    done < <(find "$dest" -type f -not -path "*/node_modules/*" -not -name '.install-stamp.json' -not -name '.DS_Store' -print0)
  fi

  # A malformed stamp is worse than no stamp — it reads as "unverifiable" while looking like the
  # check ran. Emit each value as an explicit JSON string-or-null.
  {
    printf '{\n'
    printf '  "stamp_version": 1,\n'
    printf '  "installed_at": %s,\n' "$(jstr "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
    printf '  "engine": %s,\n' "$(jstr "$eng")"
    printf '  "server": %s,\n' "$(jstr "$srv")"
    printf '  "source_head": %s,\n' "$(jstr "$head_rev")"
    # Degradation is announced on the VERIFIER side, not here. A guard in this script would have to
  # use `find` to detect that `find` failed — circular, and untestable, which makes it decoration.
  # `diagnose` instead states plainly that contents were not verified whenever it meets a stamp
  # with "files": null, and that message is covered by a test. Whatever caused the degradation, the
  # person who needs to know hears about it.
  if [ "$hash_ok" = "1" ] && [ -n "$files_json" ]; then printf '  "files": {%s\n  }\n' "$files_json"; else printf '  "files": null\n'; fi
    printf '}\n'
  } > "$dest/.install-stamp.json"
  echo "  ✓ stamped $c${v:+ (v$v)}${srv:+, mcp $srv}"
done

cat <<EOF

Done. Next:
  1. Sign in once (caches a ~1h token):
       node "$SKILLS_DIR/nexuscrm-connect/scripts/nexus_connect.mjs" auth
  2. (Optional) Register the MCP server — see:
       $MCP/mcp.json
  3. Verify:
       node "$SKILLS_DIR/nexuscrm-connect/scripts/nexus_connect.mjs" diagnose   # is this copy intact?
       node "$MCP/server.mjs" --selftest      # offline
       node "$MCP/test_smoke.mjs"             # live (needs the token)
EOF
