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

# Single-shot waiter: block until the watched target changes, debounce edits for
# 5 seconds, confirm the change is real, then exit 0. Meant to be re-invoked in a
# loop, not to stream continuous events.
#
# Takes one argument: the file or directory to watch. A directory fires when
# anything under it changes -- a file added, removed, or modified, at any depth.
#
# Behavior and design choices:
#   - resolve the target to an absolute path so the caller's cwd does not matter
#   - capture the baseline in memory at startup; only changes that happen while
#     this process is running are detected (no cross-run state is kept, by design)
#   - debounce bursts of edits (5 seconds of quiet after the last event)
#
# Change detection uses a fingerprint rather than a single mtime: a recursive
# walk produces a sorted "path<TAB>mtime" listing that is hashed into one value,
# so any add/remove/modify moves the hash. The walk is recursive to match the
# watcher scope (fswatch is recursive on macOS via FSEvents, and we pass -r on
# Linux), so a deep change can never wake us without also moving the fingerprint.
#
# On a clean exit it prints "Changed: <path>"; for a directory it also lists the
# entries it noticed were added, removed, or modified (capped for readability).
#
# Known limitations (deliberate, to stay simple):
#   - mtime-only: a change that does not move an entry's mtime is not detected,
#     even though fswatch may wake us (we treat it as a spurious event and
#     re-arm). This covers metadata-only edits (chmod/chown/xattr) and two edits
#     to the same file within one wall-clock second on the seconds-only stat
#     fallback. Closing this would require content hashing, not worth the cost.
#   - symlinks are not followed: find lists the link node and stat reports the
#     link itself, so a change to a symlink's *target* does not move the
#     fingerprint. Watching the link node is the intended behavior.
#   - cost: the walk runs one stat (or date/stat -c) per entry, recomputed on
#     each wake and once per second while settling. Fine for small trees; on
#     very large/deep trees a sample may exceed the 1s settle tick.

if [ "$#" -ne 1 ]; then
  echo "Usage: $0 <file-or-directory>" >&2
  exit 1
fi

TARGET="$1"

if [ ! -e "$TARGET" ]; then
  echo "Error: path does not exist: $TARGET" >&2
  exit 1
fi

# Resolve to an absolute path so the watch target is stable even if the caller's
# cwd changes. Directories resolve by cd'ing in; a file resolves via its parent
# so a symlinked file resolves predictably.
if [ -d "$TARGET" ]; then
  ABS_TARGET="$(cd "$TARGET" && pwd)"
else
  case "$TARGET" in
    /*) ABS_TARGET="$TARGET" ;;
    *) ABS_TARGET="$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" ;;
  esac
fi

if [ ! -e "$ABS_TARGET" ]; then
  echo "Error: path does not exist: $ABS_TARGET" >&2
  exit 1
fi

# Accept only a regular file or a directory. Other node types (sockets, FIFOs,
# devices) are rejected -- the fingerprint/watch logic is not meant for them.
if [ ! -d "$ABS_TARGET" ] && [ ! -f "$ABS_TARGET" ]; then
  echo "Error: not a regular file or directory: $ABS_TARGET" >&2
  exit 1
fi

# Build the fswatch argument list once.
#   - Directory: watch it recursively (-r forces recursion on Linux/inotify; it
#     is harmless on macOS, where FSEvents is already recursive).
#   - File: watch the file and its parent directory so atomic editor saves
#     (write temp + rename over the target, which swaps the inode) are detected.
if [ -d "$ABS_TARGET" ]; then
  WATCH_ARGS=(-r "$ABS_TARGET")
  # The directory listing relies on `find`. Fail loudly here rather than let a
  # missing `find` collapse the walk to an empty listing (which would hash to a
  # constant and make the watcher arm but never fire).
  if ! command -v find >/dev/null 2>&1; then
    echo "Error: find is required to watch a directory" >&2
    exit 1
  fi
else
  WATCH_ARGS=("$ABS_TARGET" "$(dirname "$ABS_TARGET")")
fi

# Hash whatever arrives on stdin to a stable hex digest.
hash_stdin() {
  if command -v shasum >/dev/null 2>&1; then
    shasum | awk '{print $1}'
  else
    sha1sum | awk '{print $1}'
  fi
}

# Normalize an mtime value to seconds.nanoseconds with exactly 9 fractional
# digits so values are stable across different stat/date outputs.
normalize_mtime() {
  local mtime="$1"
  if [[ "$mtime" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
    local secs="${BASH_REMATCH[1]}"
    local frac="${BASH_REMATCH[2]}"
    while [ "${#frac}" -lt 9 ]; do frac="${frac}0"; done
    frac="${frac:0:9}"
    printf '%s.%s' "$secs" "$frac"
  else
    printf '%s.000000000' "$mtime"
  fi
}

# Cross-platform mtime with sub-second precision where available.
# Try the macOS/BSD high-precision stat first (avoids a failing `date` exec on
# every poll on stock macOS, where BSD `date -r` only accepts epoch seconds).
# GNU `date -r <file>` gives nanoseconds on Linux. The seconds-only GNU stat
# fallback is the last resort; rapid same-second edits may be invisible there.
# Prints the normalized mtime on stdout and returns 0; returns 1 on failure.
mtime_of() {
  local f="$1"
  local out
  if out=$(stat -f '%Fm' "$f" 2>/dev/null); then
    normalize_mtime "$out"
    return 0
  elif out=$(date -r "$f" +%s.%N 2>/dev/null) && [[ "$out" =~ ^[0-9]+\.[0-9]{9}$ ]]; then
    printf '%s' "$out"
    return 0
  elif out=$(stat -c '%Y' "$f" 2>/dev/null); then
    normalize_mtime "$out"
    return 0
  fi
  return 1
}

# Emit the target's "path<TAB>mtime" listing on stdout, one line per entry:
#   - File: a single line.
#   - Directory: the directory itself and every descendant, sorted by path for
#     determinism.
# Entries that vanish mid-walk are skipped: their absence simply changes the
# listing on the next sample, which is the correct signal. A directory always
# yields at least itself, so an *empty* listing while it still exists means the
# walk tooling failed (find/sort/stat); we fail loudly there rather than emit
# nothing, which would hide a change behind the constant empty-string hash. A
# truly vanished target also yields nothing, but the main loop's existence check
# reports that with a clear message.
listing_of() {
  local target="$1"
  if [ -d "$target" ]; then
    local listing
    # Requires `sort -z` (GNU sort and Apple's sort 2.3+ both support it). If a
    # platform's sort lacks it, the pipeline yields no output and the empty
    # listing is caught loudly below rather than degrading to a constant hash.
    listing=$(find "$target" -print0 2>/dev/null \
      | LC_ALL=C sort -z \
      | while IFS= read -r -d '' entry; do
          m=$(mtime_of "$entry") || continue
          printf '%s\t%s\n' "$entry" "$m"
        done) || true
    if [ -z "$listing" ] && [ -d "$target" ]; then
      echo "Error: directory walk produced no entries for $target (find/sort/stat failure?)" >&2
      return 1
    fi
    printf '%s' "$listing"
  else
    local m
    if m=$(mtime_of "$target"); then
      printf '%s\t%s' "$target" "$m"
    else
      # Fail loudly rather than emit nothing and hide the change behind a hash.
      echo "Error: unable to read mtime of $target" >&2
      return 1
    fi
  fi
}

# Hash a target's listing into one digest -- cheap to compare, used only to
# detect that *something* changed.
fingerprint_of() {
  listing_of "$1" | hash_stdin
}

# Summarize what changed between two listings (the "path<TAB>mtime" output of
# listing_of) to stdout, as added/removed/modified entries with paths relative
# to the watched root. A directory's own mtime bump is always a side effect of a
# child being added or removed -- already reported via that child -- so those
# "modified" lines are dropped as noise. Output is capped to stay readable.
report_changes() {
  local base="$1" final="$2" root="$3"
  local max=10 shown=0 total=0 status path rel
  while IFS=$'\t' read -r status path; do
    # Drop pure directory mtime bumps; the child add/remove is reported too.
    if [ "$status" = M ] && [ -d "$path" ]; then
      continue
    fi
    total=$((total + 1))
    if [ "$shown" -lt "$max" ]; then
      case "$path" in
        "$root") rel="." ;;
        "$root"/*) rel="${path#"$root"/}" ;;
        *) rel="$path" ;;
      esac
      case "$status" in
        A) printf '  added: %s\n' "$rel" ;;
        R) printf '  removed: %s\n' "$rel" ;;
        M) printf '  modified: %s\n' "$rel" ;;
      esac
      shown=$((shown + 1))
    fi
  done < <(
    awk -F'\t' '
      NR==FNR { bm[$1]=$2; b[$1]=1; next }
      { f[$1]=1
        if (!($1 in b)) print "A\t" $1
        else if (bm[$1] != $2) print "M\t" $1 }
      END { for (p in b) if (!(p in f)) print "R\t" p }
    ' <(printf '%s\n' "$base") <(printf '%s\n' "$final") | LC_ALL=C sort
  )
  if [ "$total" -gt "$shown" ]; then
    printf '  ... and %d more\n' "$((total - shown))"
  fi
}

SETTLE=5

if ! command -v fswatch >/dev/null 2>&1; then
  echo "Error: fswatch is required (https://github.com/emcrisostomo/fswatch)" >&2
  exit 1
fi

# Capture the baseline in memory at startup. No state is kept across runs: only
# changes that happen while this process is running are detected. Keep the raw
# listing too so a directory change can be described, not just detected.
BASE_LISTING="$(listing_of "$ABS_TARGET")"
BASE_FP="$(printf '%s' "$BASE_LISTING" | hash_stdin)"

while :; do
  CURRENT_FP="$(fingerprint_of "$ABS_TARGET")"

  if [ "$CURRENT_FP" = "$BASE_FP" ]; then
    # No change yet; block on a filesystem notification.
    # NOTE: there is a tiny check-then-arm window between the fingerprint read
    # above and fswatch starting; a change in that gap may not be noticed until
    # the next event wakes us.
    # NOTE: watching a directory (or a file's parent) means unrelated entries can
    # wake us up; the immediate fingerprint check below keeps that cheap.
    if FS_OUT=$(fswatch -1 "${WATCH_ARGS[@]}" 2>&1); then
      FS_RC=0
    else
      FS_RC=$?
    fi
    if [ "$FS_RC" -ne 0 ]; then
      if [ ! -e "$ABS_TARGET" ]; then
        echo "Error: watched path disappeared: $ABS_TARGET" >&2
      else
        echo "Error: fswatch failed (exit $FS_RC): $FS_OUT" >&2
      fi
      exit "$FS_RC"
    fi
    if [ ! -e "$ABS_TARGET" ]; then
      echo "Error: watched path disappeared: $ABS_TARGET" >&2
      exit 1
    fi
  fi

  # Unrelated events can wake us up. Recompute the fingerprint immediately; if it
  # has not moved, re-arm without paying the full stabilization penalty.
  EVENT_FP="$(fingerprint_of "$ABS_TARGET")"
  if [ "$EVENT_FP" = "$BASE_FP" ]; then
    continue
  fi

  # Stabilize: wait until the target has been quiet for SETTLE seconds. If it
  # changes again during that window, reset the timer. For a directory this
  # naturally absorbs bursts like a multi-file save or a checkout.
  LAST_FP="$EVENT_FP"
  QUIET=0
  while [ "$QUIET" -lt "$SETTLE" ]; do
    sleep 1
    if [ ! -e "$ABS_TARGET" ]; then
      echo "Error: watched path disappeared: $ABS_TARGET" >&2
      exit 1
    fi
    NOW_FP="$(fingerprint_of "$ABS_TARGET")"
    if [ "$NOW_FP" != "$LAST_FP" ]; then
      LAST_FP="$NOW_FP"
      QUIET=0
    else
      QUIET=$((QUIET + 1))
    fi
  done

  # Confirm the listing actually changed from the baseline. fswatch can report
  # spurious or duplicate events, so we only fire on a real delta.
  FINAL_LISTING="$(listing_of "$ABS_TARGET")"
  FINAL_FP="$(printf '%s' "$FINAL_LISTING" | hash_stdin)"
  if [ "$FINAL_FP" != "$BASE_FP" ]; then
    echo "Changed: $ABS_TARGET" >&2
    if [ -d "$ABS_TARGET" ]; then
      report_changes "$BASE_LISTING" "$FINAL_LISTING" "$ABS_TARGET" >&2
    fi
    exit 0
  fi

done
