#!/usr/bin/env bash
# devctx — private dev-context store manager
#
# Keeps private development-context files (AGENTS.md, CLAUDE.md, okf/, ...)
# outside public repos: content lives in a private "store" repo (e.g.
# `dev-context`), projects hold symlinks to it.
#
# Usage:
#   devctx install                           Self-install (bin symlink).
#   devctx init [--as <name>] <target>...    Create the store + targets list.
#   devctx apply [--as <name>] [target]...   Apply: no args reads the targets list.
#   devctx restore [-a|--all] [target]...    Rollback (repo content restored).
#
# The store root defaults to ~/source/dev-context and can be overridden
# with $DEV_CONTEXT_ROOT (any clone location works).
set -u

# Store root: where private per-project content lives. Configurable so the
# tool repo (this script) and the store repo are decoupled.
STORE_ROOT="${DEV_CONTEXT_ROOT:-$HOME/source/dev-context}"

die() { echo "devctx: $*" >&2; return 1; }
die_hard() { echo "devctx: $*" >&2; exit 1; }

cmd_install() {
  local bin_dir="${1:-$HOME/.local/bin}"
  local link_target="$bin_dir/devctx"

  if [ -e "$link_target" ] && [ ! -L "$link_target" ]; then
    die "'$link_target' exists and is a regular file. Remove it first."
  fi

  mkdir -p "$bin_dir"
  # Self-locate: resolve symlinks so install works via ~/.local/bin links.
  SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
  if [ -L "${BASH_SOURCE[0]}" ]; then
    SELF_DIR="$(cd -- "$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd -P)"
  fi
  ln -sfn "$SELF_DIR/devctx" "$link_target"
  echo "devctx: installed at $link_target -> $SELF_DIR/devctx"

  if [ -d "$HOME/.config/direnv" ]; then
    echo "devctx: remember the direnv wrapper (copy from this repo's README,"
    echo "devctx: section 'direnv integration') so .envrc files can call devctx."
  fi
}

cmd_init() {
  local as_name=""
  if [ "${1:-}" = "--as" ]; then
    as_name="$2"
    shift 2
  fi

  local proj="${as_name:-$(basename "$PWD")}"
  local store_base="$STORE_ROOT/$proj"
  local marker="$store_base/.origin"
  local targets_file="$store_base/targets"

  if [ -f "$marker" ]; then
    die_hard "store '$proj' already exists (owned by: $(cat "$marker")).
devctx: If this is the same project restored elsewhere, edit '$targets_file' directly."
  fi

  mkdir -p "$store_base"
  printf '%s\n' "$PWD" > "$marker"
  printf '%s\n' "$@" > "$targets_file"
  echo "devctx: store created at $store_base"
  echo "devctx: targets list written ($# targets): $targets_file"
  echo "devctx: next: add 'devctx apply' (no args) to the project's .envrc."
}

cmd_apply() {
  local as_name=""
  if [ "${1:-}" = "--as" ]; then
    as_name="$2"
    shift 2
  fi

  local proj="${as_name:-$(basename "$PWD")}"
  local store_base="$STORE_ROOT/$proj"
  local marker="$store_base/.origin"

  # No args: read the targets list from the store (model B).
  if [ "$#" = 0 ]; then
    local targets_file="$store_base/targets"
    if [ ! -f "$targets_file" ]; then
      echo "devctx: no targets list for '$proj' at $targets_file" >&2
      echo "devctx: (create the store first: 'devctx init <target>...')" >&2
      return 0
    fi
    # shellcheck disable=SC2046
    set -- $(grep -vE '^\s*(#|$)' "$targets_file")
  fi

  # Identity marker: first project that claims this store name owns it.
  # A mismatch means a name collision -> fail loudly, never mix projects.
  if [ -f "$marker" ] && [ "$(cat "$marker")" != "$PWD" ]; then
    die_hard "store '$proj' already belongs to: $(cat "$marker").
devctx: Same directory name, different path. Use 'devctx apply --as <unique-name> ...'."
  elif [ ! -f "$marker" ]; then
    mkdir -p "$store_base"
    printf '%s\n' "$PWD" > "$marker"
  fi

  local target
  for target in "$@"; do
    target="${target%/}"

    local store_path="$store_base/$target"

    # 0. Broken symlink (e.g. store restored on a new machine): drop it;
    #    recreated below.
    if [ -L "$target" ] && [ ! -e "$target" ]; then
      rm "$target"
      echo "devctx: broken symlink for '$target' removed, will be recreated."
    fi

    # 0b. Missing locally but present in the store: only the symlink is
    #     missing (partial migration or interrupted move). Link it.
    if [ ! -e "$target" ] && [ ! -L "$target" ] && [ -e "$store_path" ]; then
      ln -s "$store_path" "$target"
      echo "devctx: '$target' linked from store (was missing locally)."
      continue
    fi

    # Skip if nowhere: neither local nor in the store.
    if [ ! -e "$target" ] && [ ! -L "$target" ]; then
      continue
    fi

    # 1. Tracked content: do NOT move automatically. Tell the user how.
    if git rev-parse --git-dir >/dev/null 2>&1 \
       && [ -n "$(git ls-files -- "$target")" ]; then
      echo "devctx: WARNING '$target' has git-tracked files. Not touched." >&2
      echo "devctx: Untrack it first (keeps the working-tree copy), then re-run:" >&2
      echo "devctx:   git rm -r --cached '$target' && git commit -m 'chore: untrack $target'" >&2
      continue
    fi

    # 2. Real file/dir: move it into the store.
    if [ ! -L "$target" ] && [ -e "$target" ]; then
      mkdir -p "$(dirname "$store_path")"
      mv "$target" "$store_path"
      echo "devctx: '$target' moved to store."
    fi

    # 3. Create the symlink if missing.
    if [ ! -L "$target" ] && [ ! -e "$target" ]; then
      ln -s "$store_path" "$target"
      echo "devctx: symlink created for '$target'."
    fi

    # 4. Exclude from git locally (never tracked, never published).
    if git rev-parse --git-dir >/dev/null 2>&1; then
      if ! grep -qsxF "$target" .git/info/exclude 2>/dev/null; then
        mkdir -p .git/info
        printf '%s\n' "$target" >> .git/info/exclude
        echo "devctx: '$target' added to .git/info/exclude."
      fi
    fi
  done
}

cmd_restore() {
  local all=0
  if [ "${1:-}" = "-a" ] || [ "${1:-}" = "--all" ]; then
    all=1
    shift
  fi

  local proj="${DEV_CONTEXT_AS:-$(basename "$PWD")}"
  local store_base="$STORE_ROOT/$proj"

  [ -d "$store_base" ] || { die "no store found at '$store_base'. Nothing to restore."; return 1; }

  local targets=()
  local link
  local found=0 skipped=0
  if [ "$all" = 1 ]; then
    for link in .* *; do
      { [ "$link" = "." ] || [ "$link" = ".." ]; } && continue
      [ -L "$link" ] || continue
      found=$((found + 1))
      if [ "$(readlink -f "$link")" = "$(readlink -f "$store_base/$link")" ]; then
        targets+=("$link")
      else
        skipped=$((skipped + 1))
        echo "devctx: skipping '$link' (not owned by this store: $(readlink "$link"))." >&2
      fi
    done
    if [ "${#targets[@]}" = 0 ]; then
      if [ "$found" = 0 ]; then
        echo "devctx: no symlinks in the repo. Nothing to restore."
      else
        echo "devctx: $found symlink(s) found, none owned by this store. Nothing to restore."
      fi
      return 0
    fi
  else
    targets=("$@")
  fi

  local target
  local restored=0
  for target in "${targets[@]}"; do
    target="${target%/}"

    # Only undo our own symlinks: resolve both sides before acting.
    if [ -L "$target" ] \
       && [ "$(readlink -f "$target")" = "$(readlink -f "$store_base/$target")" ] \
       && [ -e "$store_base/$target" ]; then
      rm "$target"
      mv "$store_base/$target" "$target"
      restored=$((restored + 1))
      echo "devctx: '$target' restored from store."
    elif [ -e "$target" ] && [ ! -L "$target" ]; then
      echo "devctx: '$target' is a real file (not ours), left untouched."
    fi
  done

  if [ "$all" = 1 ]; then
    echo "devctx: restored $restored target(s) from $store_base."
    echo "devctx: store leftovers (origin marker, exclude lines) at:"
    echo "devctx:   $store_base"
    echo "devctx:   .git/info/exclude  (devctx-added lines)"
    echo "devctx: Review and remove manually if the rollback is final."
  fi
}

# cmd_unregister: full deregistration — undo links, clean exclude lines,
# and delete the store if nothing real is left in the project.
cmd_unregister() {
  local as_name=""
  if [ "${1:-}" = "--as" ]; then
    as_name="$2"
    shift 2
  fi
  if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ] || [ "$#" -gt 0 ]; then
    die_hard "usage: devctx unregister [--as <name>]
devctx: Unexpected argument: '$*'. This command tears down the store for
devctx: the current project — run it with no arguments to proceed."
  fi

  local proj="${as_name:-$(basename "$PWD")}"
  local store_base="$STORE_ROOT/$proj"
  local marker="$store_base/.origin"
  local targets_file="$store_base/targets"

  if [ ! -d "$store_base" ]; then
    die "no store found at '$store_base'. Nothing to unregister."
    return 1
  fi

  # Identity check: refuse unless this project owns the store (or --as).
  if [ -f "$marker" ] && [ -z "$as_name" ] && [ "$(cat "$marker")" != "$PWD" ]; then
    die_hard "store '$proj' belongs to: $(cat "$marker"). Not unregistering from here.
devctx: Run from the owning project, or use 'devctx unregister --as <name>'."
  fi

  local targets=()
  # From the targets list, if present.
  if [ -f "$targets_file" ]; then
    while IFS= read -r target; do
      [ -z "$target" ] || [ "${target:0:1}" = "#" ] && continue
      targets+=("${target%/}")
    done < "$targets_file"
  fi
  # Plus any symlink in the project owned by this store (belt and braces:
  # covers stores whose targets file is missing or out of date).
  local link
  for link in .* *; do
    { [ "$link" = "." ] || [ "$link" = ".." ]; } && continue
    [ -L "$link" ] || continue
    if [ "$(readlink -f "$link")" = "$(readlink -f "$store_base/$link")" ]; then
      local dup=0 t
      for t in "${targets[@]}"; do [ "$t" = "$link" ] && dup=1 && break; done
      [ "$dup" = 0 ] && targets+=("$link")
    fi
  done

  if [ "${#targets[@]}" = 0 ]; then
    echo "devctx: no owned links and no targets list. Cleaning store remnants only."
  fi

  # Content safety: move store copies back to the project. Never delete
  # content that exists only in the store.
  local target blocked=0
  for target in "${targets[@]}"; do
    if [ -L "$target" ] && [ -e "$store_base/$target" ]; then
      rm "$target"
      mv "$store_base/$target" "$target"
      echo "devctx: '$target' unlinked, content restored from store."
    elif [ -L "$target" ]; then
      rm "$target"
      echo "devctx: '$target' broken symlink removed."
    elif [ ! -e "$target" ] && [ -e "$store_base/$target" ]; then
      mkdir -p "$(dirname "$target")"
      mv "$store_base/$target" "$target"
      echo "devctx: '$target' restored from store (was missing locally)."
    elif [ -e "$target" ]; then
      echo "devctx: '$target' is a real file; store copy kept at $store_base/$target." >&2
      blocked=1
    fi
  done

  # Clean the .git/info/exclude lines we added (exact target-name matches).
  if [ -f ".git/info/exclude" ] && [ "${#targets[@]}" -gt 0 ]; then
    local tmp
    tmp="$(mktemp)"
    grep -vxF -f <(printf '%s\n' "${targets[@]}") .git/info/exclude > "$tmp" || true
    if ! cmp -s "$tmp" .git/info/exclude; then
      mv "$tmp" .git/info/exclude
      echo "devctx: devctx lines removed from .git/info/exclude."
    fi
    rm -f "$tmp"
  fi

  # Delete the store only if nothing real is left inside (no content loss).
  if [ "$blocked" = 1 ]; then
    echo "devctx: store NOT deleted, real-file conflicts remain: $store_base" >&2
    echo "devctx: Review, resolve, and delete '$store_base' manually." >&2
    return 0
  fi
  if [ -n "$(find "$store_base" -mindepth 1 -type f -not -name .origin -not -name targets)" ]; then
    echo "devctx: store NOT deleted, unclaimed files remain: $store_base" >&2
    echo "devctx: Review them and delete '$store_base' manually if they are unwanted." >&2
    return 0
  fi
  rm -rf "$store_base"
  echo "devctx: store deleted: $store_base"

  # Remove the 'devctx apply' line from .envrc (plus its contiguous
  # devctx comment lines). Only if the exact known pattern is present;
  # never touch anything else in the file.
  local envrc=".envrc"
  if [ -f "$envrc" ] && grep -qE 'command -v devctx.*devctx apply' "$envrc"; then
    tmp="$(mktemp)"
    # Drop the devctx call line.
    grep -vE 'command -v devctx.*devctx apply' "$envrc" > "$tmp" || true
    # Drop contiguous comment lines immediately above it (the setup block).
    local cut=1
    while [ "$cut" = 1 ] && [ -s "$tmp" ]; do
      last_line="$(tail -n 1 "$tmp")"
      case "$last_line" in
        \#*) printf '%s\n' "$(sed '$d' "$tmp")" > "$tmp" ;;
        *)   cut=0 ;;
      esac
    done
    if ! cmp -s "$tmp" "$envrc"; then
      mv "$tmp" "$envrc"
      echo "devctx: 'devctx apply' line removed from .envrc."
    else
      echo "devctx: .envrc still references 'devctx apply' with an unexpected pattern; review manually." >&2
    fi
    rm -f "$tmp"
  else
    echo "devctx: remaining manual step: remove the 'devctx apply' line from .envrc." 2>/dev/null || true
  fi
  if [ -f "$envrc" ] && grep -qE 'command -v devctx.*devctx apply' "$envrc"; then
    echo "devctx: remaining manual step: remove the 'devctx apply' line from .envrc." >&2
  fi
}
cmd_status() {
  local proj="${1:-$(basename "$PWD")}"
  local store_base="$STORE_ROOT/$proj"
  local marker="$store_base/.origin"
  local targets_file="$store_base/targets"

  echo "devctx: project      : $PWD"
  echo "devctx: store        : $store_base"

  if [ -f "$marker" ]; then
    local owner
    owner="$(cat "$marker")"
    if [ "$owner" = "$PWD" ]; then
      echo "devctx: ownership    : ok (this project)"
    else
      echo "devctx: ownership    : MISMATCH — store belongs to: $owner"
    fi
  else
    echo "devctx: ownership    : no store (run 'devctx init <target>...')"
    return 0
  fi

  echo "devctx: store content: $(find "$store_base" -mindepth 1 -not -name .origin -not -name targets | wc -l) item(s)"

  if [ ! -f "$targets_file" ]; then
    echo "devctx: targets      : MISSING (model B requires it — recreate with devctx init)"
    return 0
  fi
  echo "devctx: targets      : $targets_file ($(grep -cE '\S' "$targets_file") entries)"

  # Per-target state: ok / broken / real / missing / untracked
  local target state ok=0 warn=0
  while IFS= read -r target; do
    [ -z "$target" ] || [ "${target:0:1}" = "#" ] && continue
    target="${target%/}"
    if [ -L "$target" ]; then
      if [ -e "$target" ] \
         && [ "$(readlink -f "$target")" = "$(readlink -f "$store_base/$target")" ]; then
        state="ok (store link)"
        ok=$((ok + 1))
      else
        state="BROKEN link -> $(readlink "$target")"
        warn=$((warn + 1))
      fi
    elif [ -e "$target" ]; then
      if [ -n "$(git ls-files -- "$target" 2>/dev/null)" ]; then
        state="REAL file, git-tracked (untrack before apply)"
      else
        state="REAL file (not linked — run 'devctx apply')"
      fi
      warn=$((warn + 1))
    elif [ -e "$store_base/$target" ]; then
      state="missing locally, in store (run 'devctx apply')"
      warn=$((warn + 1))
    else
      state="missing everywhere"
      warn=$((warn + 1))
    fi
    printf 'devctx:   %-22s %s\n' "$target" "$state"
  done < "$targets_file"

  if [ "$warn" = 0 ]; then
    echo "devctx: summary      : all $ok target(s) linked and healthy."
  else
    echo "devctx: summary      : $ok ok, $warn need attention."
  fi
}

case "${1:-}" in
  install) shift; cmd_install "$@" ;;
  init)    shift; cmd_init "$@" ;;
  status)  shift; cmd_status "$@" ;;
  apply)   shift; cmd_apply "$@" ;;
  restore) shift; cmd_restore "$@" ;;
  unregister) shift; cmd_unregister "$@" ;;
  -h|--help|help)
    cat <<EOF
usage: devctx install [bin-dir]              self-install (default: ~/.local/bin)
       devctx init [--as <name>] <target>...  create store + targets list (once, owner only)
       devctx status [<project>]              read-only state report (default: $PWD)
       devctx apply [--as <name>] [target]... no args = apply the store's targets list
       devctx restore [-a|--all] [<target>...]
       devctx unregister [--as <name>]        undo links + clean store remnants

env:
  DEV_CONTEXT_ROOT  store root (default: repo root detected from script path)
  DEV_CONTEXT_AS    project store name used by 'restore' (for --as projects)
EOF
    exit 0 ;;
  *)
    cat >&2 <<EOF
usage: devctx install [bin-dir]              self-install (default: ~/.local/bin)
       devctx init [--as <name>] <target>...  create store + targets list (once, owner only)
       devctx status [<project>]              read-only state report (default: $PWD)
       devctx apply [--as <name>] [target]... no args = apply the store's targets list
       devctx restore [-a|--all] [<target>...]
       devctx unregister [--as <name>]        undo links + clean store remnants

env:
  DEV_CONTEXT_ROOT  store root (default: repo root detected from script path)
  DEV_CONTEXT_AS    project store name used by 'restore' (for --as projects)
EOF
    exit 1 ;;
esac