#!/usr/bin/env bash
# deckctl sync — workstation config pull/apply/diff (fail-closed).
set -euo pipefail

SYNC_WS_ROOT=""
SYNC_DENY_PATTERNS=()
SYNC_LINK_BOUNDARY=""

sync_usage() {
  echo "usage: deckctl sync <pull|apply|diff> [agent] | deckctl sync apply <agent> <entry>" >&2
  exit 2
}

sync_expand_home() { # $1=path with optional ~
  local p="$1"
  case "$p" in
    "~") printf '%s\n' "$HOME" ;;
    "~/"*) printf '%s\n' "${HOME}/${p:2}" ;;
    *) printf '%s\n' "$p" ;;
  esac
}

sync_init() {
  SYNC_WS_ROOT="$DECKCTL_ROOT/modules/workstation"
  [[ -d "$SYNC_WS_ROOT/manifest" ]] || die "missing workstation manifests at $SYNC_WS_ROOT/manifest"

  SYNC_DENY_PATTERNS=()
  local deny="$SYNC_WS_ROOT/deny.list" line
  [[ -f "$deny" ]] || die "missing workstation deny.list at $deny"
  while IFS= read -r line || [[ -n "$line" ]]; do
    line="${line%%#*}"
    line="${line#"${line%%[![:space:]]*}"}"
    line="${line%"${line##*[![:space:]]}"}"
    [[ -n "$line" ]] && SYNC_DENY_PATTERNS+=("$line")
  done <"$deny"
}

sync_deny_matches() { # $1=relative path segment or basename
  local path="$1" base="$1" pat
  base=$(basename "$path")
  for pat in "${SYNC_DENY_PATTERNS[@]}"; do
    [[ "$path" == $pat || "$base" == $pat ]] && return 0
    case "$base" in
      $pat) return 0 ;;
    esac
    case "$path" in
      $pat) return 0 ;;
    esac
  done
  return 1
}

sync_assert_not_denied() { # $1=entry path $2=context label
  local entry="$1" ctx="$2" part
  if sync_deny_matches "$entry"; then
    die "sync: refuse $ctx: deny-listed path $entry"
  fi
  IFS='/' read -ra parts <<<"$entry"
  for part in "${parts[@]}"; do
    [[ -n "$part" ]] || continue
    if sync_deny_matches "$part"; then
      die "sync: refuse $ctx: deny-listed path $entry (segment $part)"
    fi
  done
}

sync_secret_scan() { # $1=path
  local target="$1" rel rc
  local ignore_args=()
  # a symlink is copied as a link; its target's bytes never enter the repo.
  [[ -L "$target" ]] && return 0
  command -v gitleaks >/dev/null 2>&1 || die "sync: gitleaks not found (required for pull secret scan)"
  # scan by repo-relative path: --no-git fingerprints embed the source path, and an absolute
  # one differs per worktree, so .gitleaksignore entries would not match.
  rel="${target#"$DECKCTL_ROOT"/}"
  [[ -f "$DECKCTL_ROOT/.gitleaksignore" ]] && ignore_args=(--gitleaks-ignore-path "$DECKCTL_ROOT/.gitleaksignore")
  set +e
  ( cd "$DECKCTL_ROOT" && gitleaks detect --no-git --source "$rel" --no-banner --log-level error \
      --exit-code 9 "${ignore_args[@]}" ) >/dev/null 2>&1
  rc=$?
  set -e
  case "$rc" in
    0) return 0 ;;
    9) die "sync: refuse pull: secret pattern detected in $rel" ;;
    *) die "sync: refuse pull: gitleaks failed (rc=$rc) on $rel" ;;
  esac
}

sync_scan_tree() { # $1=root
  local root="$1" rel base
  [[ -e "$root" ]] || return 0
  if [[ -f "$root" ]]; then
    sync_secret_scan "$root"
    return 0
  fi
  while IFS= read -r -d '' file; do
    rel="${file#"$root"/}"
    base=$(basename "$file")
    # deny-listed paths are pruned during copy; one surviving here means the prune is broken.
    if sync_deny_matches "$rel" || sync_deny_matches "$base"; then
      die "sync: refuse pull: deny-listed path $rel survived prune"
    fi
    sync_secret_scan "$file"
  done < <(find "$root" \( -type f -o -type l \) -print0 2>/dev/null)
}

sync_link_portable() { # $1=symlink → 1 when it cannot survive a move to another workstation
  local link="$1" resolved
  [[ -e "$link" ]] || return 1
  resolved=$(readlink -f "$link" 2>/dev/null) || return 1
  [[ -n "$SYNC_LINK_BOUNDARY" && "$resolved" == "$SYNC_LINK_BOUNDARY"/* ]]
}

sync_prune_scan() { # $1=dir $2=rel prefix → "reason<TAB>anchored path"; does not descend into a match
  local dir="$1" prefix="$2" e base rel
  for e in "$dir"/*; do
    base="${e##*/}"
    rel="${prefix}${base}"
    if sync_deny_matches "$rel" || sync_deny_matches "$base"; then
      if [[ -d "$e" && ! -L "$e" ]]; then printf 'deny-listed\t/%s/\n' "$rel"; else printf 'deny-listed\t/%s\n' "$rel"; fi
      continue
    fi
    if [[ -L "$e" ]] && ! sync_link_portable "$e"; then
      printf 'non-portable symlink\t/%s\n' "$rel"
      continue
    fi
    if [[ -d "$e" && ! -L "$e" ]]; then
      sync_prune_scan "$e" "$rel/"
    fi
  done
  return 0
}

sync_prune_list() { # $1=root → deny-listed paths under root, one per line
  ( shopt -s nullglob dotglob; sync_prune_scan "$1" "" )
}

sync_agent_manifest() { # $1=agent
  local agent="$1" mf
  mf="$SYNC_WS_ROOT/manifest/${agent}.json"
  [[ -f "$mf" ]] || die "sync: unknown agent '$agent' (missing $mf)"
  printf '%s\n' "$mf"
}

sync_list_agents() {
  local mf
  for mf in "$SYNC_WS_ROOT/manifest"/*.json; do
    [[ -f "$mf" ]] || continue
    basename "$mf" .json
  done
}

sync_repo_path() { # $1=agent $2=entry path
  printf '%s\n' "$SYNC_WS_ROOT/$1/$2"
}

sync_entry_source() { # $1=agent $2=entry → repo|deploy
  local src
  src=$(jq -r --arg entry "$2" \
    '.entries[]? | select(.path == $entry) | (.source // "repo")' "$(sync_agent_manifest "$1")")
  case "$src" in
    repo | deploy) printf '%s\n' "$src" ;;
    *) die "sync: unknown source '$src' for $1:$2" ;;
  esac
}

# The deploy clone is a landed, pinned checkout. Entries that are executed rather than read —
# transports, gates, hooks — resolve there so the running toolchain never changes with whatever
# branch the dev checkout happens to be parked on.
sync_entry_runtime_mutable() { # $1=agent $2=entry
  jq -e --arg entry "$2" \
    '.entries[]? | select(.path == $entry) | .runtime_mutable == true' \
    "$(sync_agent_manifest "$1")" >/dev/null
}

sync_entry_src() { # $1=agent $2=entry → repo-side relative name (optional src override)
  jq -r --arg entry "$2" \
    '.entries[]? | select(.path == $entry) | (.src // .path)' "$(sync_agent_manifest "$1")"
}

sync_source_path() { # $1=agent $2=entry → path the home symlink must resolve to
  local agent="$1" entry="$2" deploy src
  src=$(sync_entry_src "$agent" "$entry")
  if [[ "$(sync_entry_source "$agent" "$entry")" != deploy ]]; then
    sync_repo_path "$agent" "$src"
    return 0
  fi
  deploy="${OVERDECK_DEPLOY_DIR:-$HOME/.local/share/overdeck/deploy}"
  [[ -d "$deploy" ]] || die "sync: deploy clone missing at $deploy (run packaging/deploy-local.sh)"
  printf '%s\n' "$deploy/modules/workstation/$agent/$src"
}

sync_assert_runtime_pull_provenance() { # $1=agent $2=entry
  local agent="$1" entry="$2" deploy top common head main status expected
  deploy="${OVERDECK_DEPLOY_DIR:-$HOME/.local/share/overdeck/deploy}"
  [[ -d "$deploy/.git" && ! -L "$deploy/.git" ]] \
    || die "sync: refuse pull: canonical deploy clone missing or not standalone at $deploy"
  deploy=$(cd -P "$deploy" && pwd) || die "sync: cannot resolve deploy clone $deploy"
  top=$(/usr/bin/git -C "$deploy" rev-parse --show-toplevel 2>/dev/null) \
    || die "sync: refuse pull: deploy clone git metadata is invalid at $deploy"
  common=$(/usr/bin/git -C "$deploy" rev-parse --git-common-dir 2>/dev/null) \
    || die "sync: refuse pull: deploy clone git metadata is invalid at $deploy"
  [[ "$top" == "$deploy" && "$common" == ".git" ]] \
    || die "sync: refuse pull: deploy clone must be standalone at $deploy"
  head=$(/usr/bin/git -C "$deploy" rev-parse --verify HEAD 2>/dev/null) \
    || die "sync: refuse pull: deploy clone has no checked out revision"
  /usr/bin/git -C "$deploy" rev-parse --verify origin/main >/dev/null 2>&1 \
    || die "sync: refuse pull: deploy clone lacks origin/main"
  # Compare against the sha deploy-local.sh recorded when IT checked out, not against
  # origin/main's live value: a concurrent fetch in this clone (another session, the
  # land queue) can advance origin/main while the build runs for minutes, so HEAD vs.
  # live origin/main races and fails a deploy that installed exactly what it pinned.
  expected=$(cat "$deploy/.git/deploy-pinned-sha" 2>/dev/null) \
    || die "sync: refuse pull: deploy clone has no recorded pin (run packaging/deploy-local.sh)"
  [[ -n "$expected" ]] || die "sync: refuse pull: deploy clone pin file is empty"
  [[ "$head" == "$expected" ]] || die "sync: refuse pull: deploy clone is not pinned to its recorded checkout"
  status=$(/usr/bin/git -C "$deploy" status --porcelain --untracked-files=all) \
    || die "sync: refuse pull: cannot inspect deploy clone status"
  expected="modules/workstation/$agent/$(sync_entry_src "$agent" "$entry")"
  if [[ -n "$status" ]]; then
    [[ $(printf '%s\n' "$status" | wc -l) -eq 1 && "${status:3}" == "$expected" ]] \
      || die "sync: refuse pull: deploy clone has changes outside runtime-mutable $agent:$entry"
  fi
}

sync_assert_deploy_provenance() { # $1=agent $2=entry
  local agent="$1" entry="$2" deploy root top common head main status
  deploy="${OVERDECK_DEPLOY_DIR:-$HOME/.local/share/overdeck/deploy}"
  [[ -d "$deploy" ]] || die "sync: deploy clone missing at $deploy (run packaging/deploy-local.sh)"
  root=$(cd -P "$DECKCTL_ROOT" && pwd) || die "sync: cannot resolve deckctl root $DECKCTL_ROOT"
  deploy=$(cd -P "$deploy" && pwd) || die "sync: cannot resolve deploy clone $deploy"
  [[ "$root" == "$deploy" ]] || die "sync: refuse apply: deploy-sourced $agent:$entry may only be installed by deckctl from the deploy clone"
  [[ -d "$deploy/.git" && ! -L "$deploy/.git" ]] \
    || die "sync: refuse apply: deploy clone must be a standalone git checkout at $deploy"
  top=$(/usr/bin/git -C "$deploy" rev-parse --show-toplevel 2>/dev/null) \
    || die "sync: refuse apply: deploy clone git metadata is invalid at $deploy"
  common=$(/usr/bin/git -C "$deploy" rev-parse --git-common-dir 2>/dev/null) \
    || die "sync: refuse apply: deploy clone git metadata is invalid at $deploy"
  [[ "$top" == "$deploy" && "$common" == ".git" ]] \
    || die "sync: refuse apply: deploy clone must not be a git worktree at $deploy"
  status=$(/usr/bin/git -C "$deploy" status --porcelain --untracked-files=all) \
    || die "sync: refuse apply: cannot inspect deploy clone status at $deploy"
  [[ -z "$status" ]] || die "sync: refuse apply: deploy clone is dirty at $deploy"
  head=$(/usr/bin/git -C "$deploy" rev-parse --verify HEAD 2>/dev/null) \
    || die "sync: refuse apply: deploy clone has no checked out revision at $deploy"
  /usr/bin/git -C "$deploy" rev-parse --verify origin/main >/dev/null 2>&1 \
    || die "sync: refuse apply: deploy clone lacks origin/main at $deploy"
  # See sync_assert_runtime_pull_provenance: compare HEAD against the sha deploy-local.sh
  # recorded at its own checkout, not against origin/main's live value, which a
  # concurrent fetch can advance mid-deploy.
  expected=$(/usr/bin/cat "$deploy/.git/deploy-pinned-sha" 2>/dev/null) \
    || die "sync: refuse apply: deploy clone has no recorded pin at $deploy (run packaging/deploy-local.sh)"
  [[ -n "$expected" ]] || die "sync: refuse apply: deploy clone pin file is empty at $deploy"
  [[ "$head" == "$expected" ]] \
    || die "sync: refuse apply: deploy clone is not pinned to its recorded checkout at $deploy"
}

sync_backup_target() { # $1=home path
  local target="$1" ts dest rel
  [[ -e "$target" ]] || return 0
  ts=$(date -u +%Y%m%dT%H%M%SZ)
  dest="${HOME}/.local/state/overdeck/backups/${ts}"
  if [[ "$target" == "$HOME/"* ]]; then
    rel="${target#"$HOME"/}"
  else
    rel=$(basename "$target")
  fi
  mkdir -p "$dest/$(dirname "$rel")"
  mv "$target" "$dest/$rel"
  echo "sync: backed up $target -> $dest/$rel"
}

sync_snapshot_target() { # $1=home path
  local target="$1" ts dest rel
  [[ -e "$target" || -L "$target" ]] || return 0
  ts=$(date -u +%Y%m%dT%H%M%SZ)
  dest="${HOME}/.local/state/overdeck/backups/${ts}"
  if [[ "$target" == "$HOME/"* ]]; then rel="${target#"$HOME"/}"; else rel=$(basename "$target"); fi
  mkdir -p "$dest/$(dirname "$rel")"
  cp -a "$target" "$dest/$rel"
  echo "sync: backed up $target -> $dest/$rel"
}

sync_copy_tree() { # $1=src $2=dest
  local src="$1" dest="$2" excl p
  local -a pruned=()
  mkdir -p "$(dirname "$dest")"
  rm -rf "$dest"
  if [[ -L "$src" ]] && ! sync_link_portable "$src"; then
    echo "sync: excluded non-portable symlink $src"
    return 0
  fi
  if [[ -d "$src" ]]; then
    mapfile -t pruned < <(sync_prune_list "$src")
    if ((${#pruned[@]})); then
      command -v rsync >/dev/null 2>&1 || die "sync: rsync not found (required to prune deny-listed paths)"
      excl=$(mktemp)
      printf '%s\n' "${pruned[@]}" | cut -f2- >"$excl"
      mkdir -p "$dest"
      rsync -a --exclude-from="$excl" "$src/" "$dest/"
      rm -f "$excl"
      for p in "${pruned[@]}"; do echo "sync: excluded ${p%%$'\t'*} ${p#*$'\t'}"; done
      return 0
    fi
    cp -a --no-dereference "$src" "$dest"
  elif [[ -f "$src" || -L "$src" ]]; then
    if [[ -L "$src" ]]; then
      cp -a --no-dereference "$src" "$dest"
    else
      cp -a "$src" "$dest"
    fi
  else
    return 0
  fi
}

sync_baseline_file() { # $1=agent $2=entry
  printf '%s\n' "${HOME}/.local/state/overdeck/sync-baseline/$1/$2.sha256"
}

sync_content_hash() { # $1=path → content hash, "absent" when missing
  local p="$1"
  if [[ -f "$p" && ! -L "$p" ]]; then
    sha256sum "$p" | cut -d' ' -f1
  elif [[ -d "$p" ]]; then
    ( cd "$p" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum ) | sha256sum | cut -d' ' -f1
  else
    echo absent
  fi
}

sync_record_baseline() { # $1=agent $2=entry $3=home path
  local f
  f=$(sync_baseline_file "$1" "$2")
  mkdir -p "$(dirname "$f")"
  sync_content_hash "$3" >"$f"
}

sync_repo_abs() { # $1=repo path
  local repo_path="$1"
  if [[ -d "$(dirname "$repo_path")" ]]; then
    echo "$(cd "$(dirname "$repo_path")" && pwd)/$(basename "$repo_path")"
  else
    printf '%s\n' "$repo_path"
  fi
}

sync_pull_entry() { # $1=agent $2=entry path $3=strategy $4=home root
  local agent="$1" entry="$2" strategy="$3" home_root="$4"
  local home_path="$home_root/$entry" repo_path source_path repo_abs link_target
  repo_path=$(sync_repo_path "$agent" "$(sync_entry_src "$agent" "$entry")")
  source_path=$(sync_source_path "$agent" "$entry")
  repo_abs=$(sync_repo_abs "$source_path")

  sync_assert_not_denied "$entry" "pull"
  SYNC_LINK_BOUNDARY="$home_root"

  if [[ "$(sync_entry_source "$agent" "$entry")" == deploy ]]; then
    if ! sync_entry_runtime_mutable "$agent" "$entry"; then
      echo "sync: skipped pull of $agent:$entry (deploy-sourced; edit in repo, then deploy)"
      return 0
    fi
    sync_assert_runtime_pull_provenance "$agent" "$entry"
    [[ -L "$home_path" ]] || die "sync: refuse pull: runtime-mutable $agent:$entry must be a canonical deploy symlink"
    link_target=$(readlink "$home_path")
    [[ "$link_target" == "$repo_abs" || "$link_target" == "$source_path" ]] \
      || die "sync: refuse pull: runtime-mutable $agent:$entry is symlink to foreign path $link_target"
    [[ -f "$source_path" && ! -L "$source_path" ]] \
      || die "sync: refuse pull: runtime-mutable $agent:$entry canonical source is not a regular file"
    [[ "$source_path" != "$repo_path" ]] \
      || die "sync: refuse pull: runtime-mutable $agent:$entry requires a separate repository checkout"
    sync_secret_scan "$source_path"
    sync_copy_tree "$source_path" "$repo_path"
    sync_secret_scan "$repo_path"
    return 0
  fi

  [[ -e "$home_path" || -L "$home_path" ]] || return 0

  case "$strategy" in
    symlink|copy)
      if [[ -L "$home_path" ]]; then
        link_target=$(readlink "$home_path")
        if [[ "$link_target" == "$repo_abs" || "$link_target" == "$repo_path" ]]; then
          sync_scan_tree "$repo_path"
          return 0
        fi
        die "sync: refuse pull: $agent:$entry is symlink to foreign path $link_target"
      fi
      sync_copy_tree "$home_path" "$repo_path"
      # a refusal must not leave the offending bytes in the worktree, where a later `git add` finds them.
      if ! ( sync_scan_tree "$repo_path" ); then
        rm -rf "$repo_path"
        die "sync: refuse pull: $agent:$entry rejected; partial copy removed"
      fi
      ;;
    *)
      die "sync: unknown strategy '$strategy' for $agent:$entry"
      ;;
  esac
}

sync_apply_converge() { # $1=agent $2=entry → stdout JSON; dies on engine failure
  local agent="$1" entry="$2"
  local fleet_file="${DECKCTL_FLEET_FILE:-$DECKCTL_ROOT/modules/fleet/fleet.json}"
  local buildbox_hosts_config="${BUILDBOX_HOSTS_CONFIG:-}"
  local json rc
  if [[ "$agent:$entry" == "claude:buildbox-hosts.json" ]]; then
    buildbox_hosts_config=$(sync_source_path "$agent" "$entry")
  fi
  set +e
  json=$(
    cd "$DECKCTL_ROOT"
    DECKCTL_FLEET_FILE="$fleet_file" DECKCTL_ROOT="$DECKCTL_ROOT" \
      SYNC_APPLY_AGENT="$agent" SYNC_APPLY_ENTRY="$entry" \
      SYNC_TRANSPORT_HOME="${HOME}" \
      BUILDBOX_HOSTS_CONFIG="$buildbox_hosts_config" \
      OVERDECK_DEPLOY_DIR="${OVERDECK_DEPLOY_DIR:-}" \
      node --input-type=module -e '
import { loadFleet, FleetError } from "./lib/fleet/loader.mjs";
import { expandNode } from "./lib/fleet/expand.mjs";
import { convergeNode } from "./lib/fleet/engine.mjs";
import { createLocalTransport } from "./lib/fleet/transport-local.mjs";

const root = process.env.DECKCTL_ROOT;
const fleetPath = process.env.DECKCTL_FLEET_FILE;
const agent = process.env.SYNC_APPLY_AGENT;
const entry = process.env.SYNC_APPLY_ENTRY;
const itemId = `agent-home:${agent}:${entry}`;
const home = process.env.SYNC_TRANSPORT_HOME;

function fail(message) {
  process.stderr.write(`${message}\n`);
  process.exit(1);
}

let fleet;
try {
  fleet = loadFleet(fleetPath);
} catch (err) {
  fail(err instanceof FleetError ? err.message : String(err.message ?? err));
}

const items = expandNode(fleet, "workstation").filter(
  (item) => item.id.startsWith(`agent-home:${agent}:`) && item.id === itemId,
);
if (items.length !== 1) {
  fail(`sync: fleet item missing for ${agent}:${entry}`);
}

const transport = createLocalTransport({ home });
const report = await convergeNode("workstation", items, transport, {
  repoRoot: root,
  deployRoot: process.env.OVERDECK_DEPLOY_DIR || undefined,
});
const payload = {
  changed: report.changed,
  failed: report.failed,
  finalAudit: {
    ok: report.finalAudit.ok,
    drift: report.finalAudit.drift,
    unreachable: report.finalAudit.unreachable,
    delegated: report.finalAudit.delegated,
  },
};
process.stdout.write(`${JSON.stringify(payload)}\n`);
if (report.failed.length > 0) {
  const detail = report.failed.map((entry) => `${entry.id}: ${entry.error}`).join("; ");
  fail(`sync: converge failed for ${agent}:${entry}: ${detail}`);
}
' 2>&1
  )
  rc=$?
  set -e
  [[ $rc -eq 0 ]] || die "$json"
  printf '%s\n' "$json"
}

sync_apply_entry() { # $1=agent $2=entry $3=strategy $4=home root
  local agent="$1" entry="$2" strategy="$3" home_root="$4"
  local home_path="$home_root/$entry" repo_path repo_abs
  repo_path=$(sync_source_path "$agent" "$entry")
  repo_abs=$(sync_repo_abs "$repo_path")

  [[ -e "$repo_path" ]] || return 0
  if [[ "$(sync_entry_source "$agent" "$entry")" == deploy ]]; then
    if sync_entry_runtime_mutable "$agent" "$entry"; then
      sync_assert_runtime_pull_provenance "$agent" "$entry"
      [[ "$(sync_content_hash "$repo_path")" == "$(sync_content_hash "$(sync_repo_path "$agent" "$(sync_entry_src "$agent" "$entry")")")" ]] \
        || die "sync: refuse apply: runtime-mutable $agent:$entry differs between canonical deploy and repository"
    else
      sync_assert_deploy_provenance "$agent" "$entry"
    fi
  fi
  sync_assert_not_denied "$entry" "apply"
  sync_secret_scan "$repo_path"

  case "$strategy" in
    symlink)
      mkdir -p "$(dirname "$home_path")"
      if [[ "$agent:$entry" == "claude:buildbox-hosts.json" ]]; then
        sync_snapshot_target "$home_path"
      elif [[ -e "$home_path" && ! -L "$home_path" ]]; then
        sync_backup_target "$home_path"
      elif [[ -L "$home_path" ]]; then
        local current
        current=$(readlink "$home_path" || true)
        if [[ "$current" == "$repo_abs" ]]; then
          return 0
        fi
        sync_backup_target "$home_path"
      fi
      sync_apply_converge "$agent" "$entry" >/dev/null
      ;;
    copy)
      mkdir -p "$(dirname "$home_path")"
      if [[ -e "$home_path" && ! -L "$home_path" ]]; then
        if [[ -f "$repo_path" && -f "$home_path" ]] && cmp -s "$repo_path" "$home_path"; then
          sync_record_baseline "$agent" "$entry" "$home_path"
          return 0
        fi
        if [[ -d "$repo_path" && -d "$home_path" ]] && diff -qr "$repo_path" "$home_path" >/dev/null 2>&1; then
          sync_record_baseline "$agent" "$entry" "$home_path"
          return 0
        fi
        # live differs from repo: overwrite only content unchanged since the last apply/pull —
        # anything else is drift (runtime/agent edits) that a copy would silently destroy.
        local live_hash baseline_file baseline=""
        live_hash=$(sync_content_hash "$home_path")
        baseline_file=$(sync_baseline_file "$agent" "$entry")
        [[ -f "$baseline_file" ]] && baseline=$(cat "$baseline_file")
        if [[ "$live_hash" != "$baseline" ]]; then
          die "sync: refuse apply: $agent:$entry has live edits newer than the last apply/pull — run 'deckctl sync pull $agent' to capture them into the repo (then land), and re-apply"
        fi
        sync_backup_target "$home_path"
      elif [[ -L "$home_path" ]]; then
        sync_backup_target "$home_path"
      fi
      sync_apply_converge "$agent" "$entry" >/dev/null
      sync_record_baseline "$agent" "$entry" "$home_path"
      ;;
    *)
      die "sync: unknown strategy '$strategy' for $agent:$entry"
      ;;
  esac
}

sync_diff_entry() { # $1=agent $2=entry $3=strategy $4=home root → 0 clean, 1 drift
  local agent="$1" entry="$2" strategy="$3" home_root="$4"
  local home_path="$home_root/$entry" repo_path repo_abs
  repo_path=$(sync_source_path "$agent" "$entry")
  repo_abs=$(sync_repo_abs "$repo_path")

  local repo_exists=0 home_exists=0
  [[ -e "$repo_path" ]] && repo_exists=1
  [[ -e "$home_path" || -L "$home_path" ]] && home_exists=1

  if [[ "$repo_exists" -eq 0 && "$home_exists" -eq 0 ]]; then
    return 0
  fi

  case "$strategy" in
    symlink)
      if [[ "$home_exists" -eq 0 ]]; then
        echo "sync diff: $agent:$entry missing at home (repo present)"
        return 1
      fi
      if [[ ! -L "$home_path" ]]; then
        echo "sync diff: $agent:$entry not a symlink at home"
        return 1
      fi
      local link
      link=$(readlink "$home_path")
      if [[ "$link" != "$repo_abs" ]]; then
        echo "sync diff: $agent:$entry symlink -> $link (want $repo_abs)"
        return 1
      fi
      ;;
    copy)
      if [[ "$repo_exists" -eq 0 && "$home_exists" -eq 1 ]]; then
        echo "sync diff: $agent:$entry present at home but missing in repo"
        return 1
      fi
      if [[ "$repo_exists" -eq 1 && "$home_exists" -eq 0 ]]; then
        echo "sync diff: $agent:$entry missing at home"
        return 1
      fi
      if [[ -f "$repo_path" && -f "$home_path" ]]; then
        if ! cmp -s "$repo_path" "$home_path"; then
          echo "sync diff: $agent:$entry file differs"
          return 1
        fi
      elif [[ -d "$repo_path" && -d "$home_path" ]]; then
        if ! diff -qr "$repo_path" "$home_path" >/dev/null 2>&1; then
          echo "sync diff: $agent:$entry directory differs"
          return 1
        fi
      else
        echo "sync diff: $agent:$entry type mismatch between repo and home"
        return 1
      fi
      ;;
    *)
      die "sync: unknown strategy '$strategy' for $agent:$entry"
      ;;
  esac
  return 0
}

sync_pull_agent() { # $1=agent $2=home root — all-or-nothing: a refused entry restores the agent tree
  local agent="$1" home_root="$2" mf agent_dir snap="" rc=0 entry strategy
  mf=$(sync_agent_manifest "$agent")
  agent_dir="$SYNC_WS_ROOT/$agent"
  if [[ -e "$agent_dir" ]]; then
    snap=$(mktemp -d)
    cp -a "$agent_dir" "$snap/"
  fi
  (
    while IFS=$'\t' read -r entry strategy; do
      [[ -n "$entry" ]] || continue
      sync_pull_entry "$agent" "$entry" "$strategy" "$home_root"
    done < <(jq -r '.entries[]? | [.path, .strategy] | @tsv' "$mf")
  ) || rc=$?
  if [[ $rc -ne 0 ]]; then
    rm -rf "$agent_dir"
    if [[ -n "$snap" ]]; then mv "$snap/$agent" "$agent_dir"; rm -rf "$snap"; fi
    die "sync: pull of $agent failed; agent tree left unchanged"
  fi
  [[ -n "$snap" ]] && rm -rf "$snap"
  # baselines advance only after the whole agent pull survived: a restored tree with
  # advanced baselines would let the next apply overwrite drift the restore discarded.
  while IFS=$'\t' read -r entry strategy; do
    [[ "$strategy" == copy ]] || continue
    [[ -e "$home_root/$entry" && ! -L "$home_root/$entry" ]] || continue
    sync_record_baseline "$agent" "$entry" "$home_root/$entry"
  done < <(jq -r '.entries[]? | [.path, .strategy] | @tsv' "$mf")
  return 0
}

sync_run_agent() { # $1=mode $2=agent
  local mode="$1" agent="$2" mf home_raw home_root entry strategy drift=0
  mf=$(sync_agent_manifest "$agent")
  home_raw=$(jq -r '.home' "$mf")
  [[ -n "$home_raw" && "$home_raw" != "null" ]] || die "sync: manifest $agent missing home"
  home_root=$(sync_expand_home "$home_raw")

  if [[ "$mode" == "pull" ]]; then
    sync_pull_agent "$agent" "$home_root"
    return 0
  fi

  while IFS=$'\t' read -r entry strategy; do
    [[ -n "$entry" ]] || continue
    case "$mode" in
      apply) sync_apply_entry "$agent" "$entry" "$strategy" "$home_root" ;;
      diff)
        if ! sync_diff_entry "$agent" "$entry" "$strategy" "$home_root"; then
          drift=1
        fi
        ;;
    esac
  done < <(jq -r '.entries[]? | [.path, .strategy] | @tsv' "$mf")

  [[ "$mode" != "diff" || "$drift" -eq 0 ]] || return 1
}

sync_apply_selected_entry() { # $1=agent $2=entry
  local agent="$1" selected_entry="$2" mf home_raw home_root
  local -a matches=()
  mf=$(sync_agent_manifest "$agent")
  home_raw=$(jq -r '.home' "$mf")
  [[ -n "$home_raw" && "$home_raw" != "null" ]] || die "sync: manifest $agent missing home"
  home_root=$(sync_expand_home "$home_raw")
  mapfile -t matches < <(jq -r --arg entry "$selected_entry" \
    '.entries[]? | select(.path == $entry) | [.path, .strategy] | @tsv' "$mf")
  [[ ${#matches[@]} -eq 1 ]] || die "sync: unknown manifest entry '$selected_entry' for agent '$agent'"
  IFS=$'\t' read -r entry strategy <<<"${matches[0]}"
  sync_apply_entry "$agent" "$entry" "$strategy" "$home_root"
}

cmd_sync() {
  [[ $# -ge 1 ]] || sync_usage
  local mode="$1"
  shift
  case "$mode" in
    pull|apply|diff) ;;
    *) sync_usage ;;
  esac

  sync_init

  local selected_entry=""
  [[ $# -le 2 ]] || sync_usage
  if [[ $# -eq 2 ]]; then
    [[ "$mode" == "apply" ]] || sync_usage
    selected_entry="$2"
  fi

  local agents=() agent
  if [[ $# -ge 1 ]]; then
    agents=("$1")
  else
    while IFS= read -r agent; do
      [[ -n "$agent" ]] && agents+=("$agent")
    done < <(sync_list_agents | sort)
  fi

  [[ ${#agents[@]} -gt 0 ]] || die "sync: no agents configured"

  local rc=0 a
  for a in "${agents[@]}"; do
    if [[ -n "$selected_entry" ]]; then
      sync_apply_selected_entry "$a" "$selected_entry"
    elif ! sync_run_agent "$mode" "$a"; then
      rc=1
    fi
  done
  return "$rc"
}
