#!/usr/bin/env bash
# Shared helpers for main-checkout-guard.sh and worktree-lock-gate.sh.
# A repo opts into worktree isolation by having a <main_root>/.worktrees directory
# (created by `od-worktree add`); repos without one are left untouched by both gates.

# wt_resolve <path> -> prints <path> with every symlink component resolved, without
# requiring it to exist (Write targets a file that is not there yet). Falls back to the
# input when realpath is unavailable. Every path fed to a comparison below must go
# through this: ~/.claude/hooks and ~/.claude/workflows/lib are symlinks INTO the
# deploy clone, so an unresolved compare silently fails open on the executing tree.
# `~` reaches the guard unexpanded: the hook reads the command text, not the shell's argv.
wt_resolve() {
  local path="$1"
  case "$path" in
    "~") path="$HOME" ;;
    "~/"*) path="$HOME/${path#\~/}" ;;
  esac
  realpath -m "$path" 2>/dev/null || printf '%s\n' "$path"
}

# wt_deploy_root -> prints the resolved live deploy clone root (OVERDECK_DEPLOY_DIR).
wt_deploy_root() {
  wt_resolve "${OVERDECK_DEPLOY_DIR:-$HOME/.local/share/overdeck/deploy}"
}

# wt_is_within <path> <root> -> rc 0 if <path> IS <root> or sits under it. Both sides
# must already be resolved.
wt_is_within() {
  [[ "$1" == "$2" ]] && return 0
  case "$1" in "$2"/*) return 0 ;; esac
  return 1
}

# wt_main_root <dir> -> prints the main checkout root for the repo containing <dir>.
# rc 1 (no output) if <dir> is not inside a git repo. Uses WT_GIT_BIN (default: `git`
# resolved via PATH) — the git-guard PATH shim sets this to the REAL git binary it
# already resolved, so this internal call never recurses back into the shim itself.
wt_main_root() {
  local common
  common=$("${WT_GIT_BIN:-git}" -C "$1" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
  dirname "$common"
}

# wt_slug_for <path> <main_root> -> prints the worktree slug if <path> is under
# a supported, registered worktree root; prints nothing otherwise.
wt_slug_for() {
  local path="$1" main_root="$2" rest slug worktree_root registered_path
  local -a worktree_fields
  case "$path" in
    "$main_root"/.worktrees/*)
      rest="${path#"$main_root"/.worktrees/}"
      worktree_root="$main_root/.worktrees/${rest%%/*}"
      ;;
    "$main_root"/.claude/worktrees/*)
      rest="${path#"$main_root"/.claude/worktrees/}"
      worktree_root="$main_root/.claude/worktrees/${rest%%/*}"
      ;;
    *) return 0 ;;
  esac
  slug="${rest%%/*}"
  mapfile -d '' -t worktree_fields < <("${WT_GIT_BIN:-git}" -C "$main_root" worktree list --porcelain -z 2>/dev/null) || return 0
  for registered_path in "${worktree_fields[@]}"; do
    case "$registered_path" in
      "worktree "*)
        registered_path="${registered_path#worktree }"
        [[ "$(wt_resolve "$registered_path")" == "$(wt_resolve "$worktree_root")" ]] || continue
        printf '%s\n' "$slug"
        return 0
        ;;
    esac
  done
}

# wt_is_main_checkout_path <path> <main_root> -> rc 0 if <path> IS the main checkout
# root, or a path inside it that is not inside a supported, registered worktree root.
wt_is_main_checkout_path() {
  local path="$1" main_root="$2"
  [[ "$path" == "$main_root" ]] && return 0
  case "$path" in
    "$main_root"/*)
      [[ -n "$(wt_slug_for "$path" "$main_root")" ]] && return 1
      return 0
      ;;
    *) return 1 ;;
  esac
}

# wt_cd_targets <command> -> one `cd` argument per line, resolved neither absolute
# nor relative (caller resolves). Mirrors deny-gate.mjs candidateDirs: an agent can
# route a destructive verb through a leading `cd <dir> &&` instead of typing it in cwd.
wt_cd_targets() {
  printf '%s' "$1" | perl -ne "while (/\\bcd\\s+['\"]?([^\\s'\";|&]+)/g) { print \"\$1\\n\" }"
}

# wt_git_dir_targets <command> -> one directory-redirect argument per line, in the
# order git would apply them: `-C <dir>`, `--work-tree=<dir>`, `--work-tree <dir>`.
# Printed as written, neither absolute nor relative (caller resolves). Each of these
# points git's working tree somewhere other than the process cwd, so a guard that
# reads cwd alone sees a safe directory while git mutates a protected one.
wt_git_dir_targets() {
  printf '%s' "$1" | perl -ne "while (/(?:-C\\s+|--work-tree(?:=|\\s+))['\"]?([^\\s'\";|&]+)/g) { print \"\$1\\n\" }"
}

# wt_has_destructive_git <command> -> rc 0 if <command> contains one of the git verbs
# that mutates/discards the working tree or index: restore, checkout -- / checkout .,
# stash push/pop/drop/clear (or bare stash), clean -f*, reset --hard. Any run of git's
# own global options (-C, -c, --git-dir, --work-tree, --namespace, in either the `=`
# or the separate-argument form) between `git` and the verb is tolerated (the
# git-guard PATH shim resolves the directory itself but still reconstructs the full
# argv line for this check).
wt_has_destructive_git() {
  local cmd="$1"
  perl -e '
    my $c = $ARGV[0];
    my $b = q{(^|[|;&(]|\$\(|\n)\s*};
    my $g = q{git\s+(?:-C\s+\S+\s+|-c\s+\S+\s+|--git-dir[=\s]\S+\s+|--work-tree[=\s]\S+\s+|--namespace[=\s]\S+\s+)*};
    exit 0 if $c =~ /${b}${g}restore(\s|$)/;
    exit 0 if $c =~ /${b}${g}checkout\s+(?:--(?:\s|$)|\.(?:\s|$))/;
    exit 0 if $c =~ /${b}${g}stash(?:\s*(?:[;&|)]|$))/;
    exit 0 if $c =~ /${b}${g}stash\s+(?:push|pop|drop|clear)(?:\s|[;&|)]|$)/;
    exit 0 if $c =~ /${b}${g}clean\s+[^;&|\n]*-[a-zA-Z]*f/;
    exit 0 if $c =~ /${b}${g}reset\s+[^;&|\n]*--hard(?:\s|$)/;
    exit 1
  ' "$cmd"
}

# wt_write_targets <command> -> one WRITE TARGET path per line, for the non-git vectors
# that mutate a tree without git ever running: output redirection, tee, cp/mv/install/rsync
# destinations, in-place sed/perl, touch/truncate, dd of=. A path used as a SOURCE is never
# printed — reading a protected tree stays allowed. A relative target is printed prefixed by
# the `cd` chain in force at that point in the command, so the caller resolves everything
# against the tool cwd alone: `cd <main> && cd .worktrees/x && echo hi > f` writes into the
# worktree, not into <main>.
wt_write_targets() {
  printf '%s' "$1" | perl -e '
    my $c = do { local $/; <STDIN> };

    # A heredoc body is data, not shell: python/awk/json payloads routinely contain `>`
    # and would otherwise read as redirections into whatever cwd is in force.
    while ($c =~ /<<-?\s*(["\x27]?)([A-Za-z_][A-Za-z0-9_]*)\1/) {
      my $tag = $2;
      $c =~ s/<<-?\s*(["\x27]?)\Q$tag\E\1([^\n]*)\n.*?^[ \t]*\Q$tag\E[ \t]*$/$2/sm
        or $c =~ s/<<-?\s*(["\x27]?)\Q$tag\E\1([^\n]*).*/$2/s;
    }

    my @out;
    my $cwd = q{};
    my $join = sub { my ($t) = @_; return $t if $t =~ m{^[/~]}; return $cwd eq q{} ? $t : "$cwd/$t" };

    # Separators inside a quoted argument are data: a sed script may contain `&&` or `|`,
    # and splitting on it would shred the script into tokens that read as file names.
    my @segs; my $cur = q{}; my $q = q{};
    for my $ch (split //, $c) {
      if ($q ne q{}) { $cur .= $ch; $q = q{} if $ch eq $q; next }
      if ($ch eq chr(39) || $ch eq q{"}) { $q = $ch; $cur .= $ch; next }
      if ($ch =~ /[;|&\n]/) { push @segs, $cur; $cur = q{}; next }
      $cur .= $ch;
    }
    push @segs, $cur;

    for my $seg (@segs) {
      my @tok;
      while ($seg =~ m{\G\s*(?:\x27([^\x27]*)\x27|"([^"]*)"|([^\s]+))}gc) {
        push @tok, defined $1 ? $1 : defined $2 ? $2 : $3;
      }
      pos($seg) = 0;
      while ($seg =~ m{(?:^|[\s;&|(])(?:\d+|&)?>>?\s*(?:\x27([^\x27]*)\x27|"([^"]*)"|([^\s;&|<>()]+))}g) {
        my $t = defined $1 ? $1 : defined $2 ? $2 : $3;
        push @out, $join->($t) if defined $t && $t !~ /^[&(]/;
      }
      for my $i (reverse 0 .. $#tok) {
        next unless $tok[$i] =~ /^(?:\d+|&)?>>?$/;
        splice @tok, $i, 2;
      }
      @tok = grep { $_ !~ /^(?:\d+|&)?>>?/ } @tok;
      while (@tok) {
        last if $tok[0] !~ /^(?:\w+=|sudo$|deck-sudo$|command$|env$|nice$|nohup$|time$|xargs$)/;
        my $head = shift @tok;
        shift @tok if $head eq q{xargs} && @tok && $tok[0] =~ /^-/;
      }
      if (@tok && $tok[0] eq q{timeout}) { shift @tok; shift @tok while @tok && $tok[0] =~ /^-/; shift @tok; }
      next unless @tok;
      my $cmd = $tok[0]; $cmd =~ s{.*/}{};
      my @rest = @tok[1 .. $#tok];
      if ($cmd eq q{cd}) {
        my ($d) = grep { $_ !~ /^-/ } @rest;
        $cwd = $join->($d) if defined $d;
        next;
      }
      my @words = grep { $_ !~ /^-/ } @rest;
      my @s;

      if ($cmd =~ /^(cp|mv|install|rsync)$/) {
        my $dest;
        for my $i (0 .. $#rest) {
          $dest = $rest[$i + 1] if $rest[$i] =~ /^(-t|--target-directory)$/;
          $dest = $1 if $rest[$i] =~ /^--target-directory=(.+)/;
        }
        $dest = $words[-1] if !defined $dest && @words >= 2;
        push @s, $dest if defined $dest;
      } elsif ($cmd eq q{tee}) {
        push @s, @words;
      } elsif ($cmd eq q{sed} && grep { /^-[a-zA-Z]*i/ || /^--in-place/ } @rest) {
        my $script_given = grep { /^-[ef]$/ || /^--(expression|file)=/ } @rest;
        push @s, $script_given ? @words : @words[1 .. $#words];
      } elsif ($cmd eq q{perl} && grep { /^-[a-zA-Z]*i/ } @rest) {
        my @f; my $skip = 0;
        for my $t (@rest) {
          if ($skip) { $skip = 0; next; }
          if ($t =~ /^-[eEM]$/) { $skip = 1; next; }
          next if $t =~ /^-/;
          push @f, $t;
        }
        push @s, @f;
      } elsif ($cmd =~ /^(touch|truncate)$/) {
        my @f; my $skip = 0;
        for my $t (@rest) {
          if ($skip) { $skip = 0; next; }
          if ($t =~ /^-[srdt]$/) { $skip = 1; next; }
          next if $t =~ /^-/;
          push @f, $t;
        }
        push @s, @f;
      } elsif ($cmd eq q{dd}) {
        push @s, map { /^of=(.+)/ ? $1 : () } @rest;
      }
      push @out, map { $join->($_) } grep { defined } @s;
    }

    my %seen;
    for my $t (@out) { next if $t eq q{} || $seen{$t}++; print "$t\n"; }
  '
}

# wt_session_alive <session_id> -> rc 0 if a running `claude` process has this
# session's transcript (<session_id>.jsonl) open on one of its file descriptors.
# Proc root is overridable via WT_LOCK_PROC_DIR for hermetic tests.
wt_session_alive() {
  local sid="$1" proc_dir="${WT_LOCK_PROC_DIR:-/proc}" d comm fd target
  [[ -n "$sid" ]] || return 1
  for d in "$proc_dir"/[0-9]*; do
    [[ -d "$d" ]] || continue
    comm=$(cat "$d/comm" 2>/dev/null) || continue
    [[ "$comm" == "claude" ]] || continue
    for fd in "$d/fd"/*; do
      [[ -e "$fd" || -L "$fd" ]] || continue
      target=$(readlink "$fd" 2>/dev/null) || continue
      case "$target" in
        */"$sid".jsonl) return 0 ;;
      esac
    done
  done
  return 1
}
