#!/usr/bin/env bash
# cc-edit — $EDITOR wrapper for Claude Code's external editor (ctrl+x ctrl+e).
#
# Claude Code writes the chat input buffer to a temp file, opens $EDITOR on it,
# and sends back whatever the file contains on exit. This wrapper appends the
# current session's transcript below a sentinel line so you can reread the
# conversation while composing, then strips everything from the sentinel down
# before Claude reads the file back.
#
# Part of dunders — https://github.com/tumikosha/dunders
#
# Environment:
#   CC_REAL_EDITOR    editor to actually run (default: __)
#   CC_HISTORY_LINES  how many recent messages to show (default: 200)
#   CC_EDIT_DEBUG     0 disables the diagnostic log (default: 1)

set -euo pipefail

BUF="${1:-}"
[[ -n "$BUF" ]] || { echo "cc-edit: no file argument" >&2; exit 1; }

REAL_EDITOR="${CC_REAL_EDITOR:-__}"
MARKER="════ HISTORY BELOW — everything from this line down is discarded ════"

# --- Diagnostics -------------------------------------------------------------
# Records what the editor process actually receives. This log is the first thing
# to look at when history does not show up, so it is on by default.
LOG="$HOME/.claude/cc-edit.log"
diag() { [[ "${CC_EDIT_DEBUG:-1}" == "0" ]] || printf '%s\n' "$*" >> "$LOG"; }
diag "=== $(date '+%F %T') pid=$$ ppid=$PPID"
diag "  argv        : $*"
diag "  cwd         : $PWD"
diag "  buf         : $BUF ($(wc -c < "$BUF" 2>/dev/null || echo missing) bytes)"
diag "  real editor : $REAL_EDITOR"
diag "  claude env  : $(env | grep -c '^CLAUDE' || true) vars"
# Names only for anything secret-shaped. CLAUDE_CODE_OAUTH_TOKEN lives in this
# environment, and this log is exactly the kind of file people paste into chats.
while IFS= read -r line; do
  case "${line%%=*}" in
    *TOKEN*|*SECRET*|*KEY*|*PASSWORD*|*AUTH*) diag "    ${line%%=*}=<redacted>" ;;
    *) diag "    $line" ;;
  esac
done < <(env | grep '^CLAUDE' | sort || true)

# --- Self-removal after the plugin is gone -----------------------------------
# `/plugin uninstall` deletes the plugin directory, and with it the hook that
# would have tidied up — Claude Code has no plugin-removal hook. So the shim
# checks for its own origin on every launch: if the plugin is gone, it undoes
# the settings.json entry, deletes itself, and hands the file to a real editor.
# Triggered by use rather than by uninstall, which is the only moment available.
STATE="$HOME/.claude/dunders-cc/installed.json"
if [[ -f "$STATE" ]]; then
  # Exits 0 when it decided the plugin is gone AND finished tidying up.
  if STATE_FILE="$STATE" python3 - <<'PY' 2>>"$LOG"
import json, os, pathlib, shutil, sys

home = pathlib.Path.home()
settings = home / ".claude" / "settings.json"
try:
    state = json.loads(pathlib.Path(os.environ["STATE_FILE"]).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
    sys.exit(1)
ours = state.get("editor", "")
root = state.get("plugin_root", "")
key = state.get("plugin_key", "")

# Self-removal is strictly the plugin's story: it exists because Claude Code
# has no plugin-removal hook. A `dunders --setup-claude` install has no plugin
# to disappear, and without this guard its empty plugin_root read as "gone" —
# the first keystroke after installing deleted the whole integration.
if state.get("managed_by") != "plugin":
    sys.exit(1)


def still_installed():
    """Is the plugin still installed?

    The registry is the authority: `/plugin uninstall` drops the plugin's key
    from it, while leaving the versioned cache directory on disk (complete
    with its `.in_use` marker). Testing the directory therefore reads
    "installed" long after the plugin is gone — which is precisely how an
    uninstalled integration kept answering the keystroke.

    Enablement is deliberately not consulted: a *disabled* plugin is still
    installed, and must not trigger removal.
    """
    if key:
        registry = home / ".claude" / "plugins" / "installed_plugins.json"
        try:
            plugins = json.loads(registry.read_text(encoding="utf-8")).get("plugins")
        except (OSError, json.JSONDecodeError, ValueError, AttributeError):
            plugins = None
        if isinstance(plugins, dict):
            return key in plugins
    # No key recorded (a clone, an older install), or the registry moved or
    # went unreadable: fall back to the install directory. Erring towards
    # "installed" leaves a working editor, which is the safer failure.
    return bool(root) and pathlib.Path(root).is_dir()


if still_installed():
    sys.exit(1)

try:
    data = json.loads(settings.read_text(encoding="utf-8"))
    env = data.get("env", {})
    # Only drop EDITOR if it is still the entry we wrote; the user may have
    # since pointed it somewhere of their own.
    if env.get("EDITOR") == ours:
        env.pop("EDITOR", None)
        if not env:
            data.pop("env", None)
        shutil.copy2(settings, settings.with_suffix(".json.bak-dunders-cc"))
        tmp = settings.with_suffix(".json.cc-edit.tmp")
        tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
        tmp.replace(settings)
except (OSError, json.JSONDecodeError, ValueError):
    pass

for path in (home / ".claude" / "dunders-cc", home / ".claude" / "session-map"):
    shutil.rmtree(path, ignore_errors=True)

# A shell profile can also export EDITOR at us — install.sh writes exactly such
# a block, and a machine that has been through both installs has both. Nothing
# here may edit a user's profile, so say where the keystroke still comes from
# instead of letting it look like a failed uninstall.
for name in (".zshrc", ".bashrc", ".bash_profile", ".profile", ".zprofile"):
    profile = home / name
    try:
        text = profile.read_text(encoding="utf-8", errors="replace")
    except OSError:
        continue
    if "dunders claude-code editor" in text or "dunders-cc/cc-edit" in text:
        print(
            f"cc-edit: {profile} still exports EDITOR at this wrapper; "
            "run `bash skills/setup/scripts/install.sh --uninstall` from a "
            "dunders clone, or delete that block by hand.",
            file=sys.stderr,
        )
PY
  then
    diag "  plugin gone -> self-removed"
    # The keystroke still has to open something, even if the tidying failed.
    fallback="${CC_REAL_EDITOR:-}"
    if [[ -z "$fallback" ]] || ! command -v "$fallback" >/dev/null 2>&1; then
      command -v __ >/dev/null 2>&1 && fallback="__" || fallback="vi"
    fi
    diag "  handing $BUF to $fallback"
    exec $fallback "$BUF"
  fi
fi

# --- Guard -------------------------------------------------------------------
# $EDITOR is inherited by every Claude child process, including `git commit`.
# Injecting a transcript into a commit message would be a disaster, so anything
# that looks like a git buffer goes straight through untouched.
case "$(basename "$BUF")" in
  COMMIT_EDITMSG|MERGE_MSG|TAG_EDITMSG|git-rebase-todo|*.diff|*.patch)
    exec $REAL_EDITOR "$BUF" ;;
esac
[[ "$BUF" == *"/.git/"* ]] && exec $REAL_EDITOR "$BUF"

# --- Locate this session's transcript ----------------------------------------
# The editor process gets a stripped environment: CLAUDE_CODE_SESSION_ID is
# exported only to tool-call children, never here. What does survive is
# CLAUDE_CODE_MESSAGING_SOCKET, whose basename is claude's PID — the same number
# as our own PPID. The cc-session-map SessionStart hook files the transcript
# path under those keys, which is how we get from a PID back to a transcript.
HIST=""
MAP_DIR="$HOME/.claude/session-map"

sock_pid=""
if [[ -n "${CLAUDE_CODE_MESSAGING_SOCKET:-}" ]]; then
  sock_pid="$(basename "$CLAUDE_CODE_MESSAGING_SOCKET" .sock)"
fi

for pid in "$sock_pid" "$PPID" "${CLAUDE_PID:-}"; do
  [[ -n "$pid" && -f "$MAP_DIR/$pid.json" ]] || continue
  cand="$(MAP_FILE="$MAP_DIR/$pid.json" python3 -c \
    'import json,os;print(json.load(open(os.environ["MAP_FILE"])).get("transcript_path",""))' \
    2>/dev/null || true)"
  if [[ -n "$cand" && -f "$cand" ]]; then
    HIST="$cand"; diag "  resolved by : session-map/$pid.json"; break
  fi
done

# Fallback 1: the tool-child variable, in case it ever does get exported here.
if [[ -z "$HIST" && -n "${CLAUDE_CODE_SESSION_ID:-}" ]]; then
  for c in "$HOME/.claude/projects"/*/"$CLAUDE_CODE_SESSION_ID.jsonl"; do
    [[ -f "$c" ]] && { HIST="$c"; diag "  resolved by : CLAUDE_CODE_SESSION_ID"; break; }
  done
fi

# Fallback 2: newest transcript in this cwd's project directory. Claude Code
# derives that directory name by replacing '/', '_' and '.' with '-'. Ambiguous
# when several sessions share a cwd, hence last resort.
if [[ -z "$HIST" ]]; then
  slug="$(printf '%s' "$PWD" | tr '/_.' '-')"
  newest="$(ls -t "$HOME/.claude/projects/$slug"/*.jsonl 2>/dev/null | head -1 || true)"
  if [[ -n "$newest" ]]; then
    HIST="$newest"; diag "  resolved by : newest-in-$slug (ambiguous)"
  fi
fi
diag "  session id  : ${CLAUDE_CODE_SESSION_ID:-<UNSET>}"
diag "  transcript  : ${HIST:-<NOT FOUND>}"

# --- Append rendered history below the marker --------------------------------
if [[ -n "$HIST" ]]; then
  {
    printf '\n%s\n' "$MARKER"
    CC_HIST_FILE="$HIST" CC_HIST_LINES="${CC_HISTORY_LINES:-200}" python3 - <<'PY'
import json, os, sys

path = os.environ["CC_HIST_FILE"]
limit = int(os.environ.get("CC_HIST_LINES", "200"))


def text_of(msg):
    # Text blocks only. Tool calls and their results are noise when the point is
    # to reread the conversation, and they bloat the buffer enormously — a real
    # transcript weighs megabytes with them and kilobytes without.
    c = msg.get("content")
    if isinstance(c, str):
        return c
    if isinstance(c, list):
        return "\n".join(
            b.get("text", "")
            for b in c
            if isinstance(b, dict) and b.get("type") == "text"
        )
    return ""


rows = []
with open(path, encoding="utf-8", errors="replace") as fh:
    for line in fh:
        line = line.strip()
        if not line:
            continue
        try:
            rec = json.loads(line)
        except json.JSONDecodeError:
            continue
        if rec.get("type") not in ("user", "assistant"):
            continue
        if rec.get("isMeta") or rec.get("isSidechain"):
            continue
        body = text_of(rec.get("message", {})).strip()
        # Records starting with '<' are wrappers like <system-reminder>.
        if not body or body.startswith("<"):
            continue
        rows.append((rec["type"], body))

for role, body in rows[-limit:]:
    sys.stdout.write(f"\n### {role}\n{body}\n")
PY
  } >> "$BUF" 2>>"$LOG" || diag "  !! history render FAILED (stderr above)"
  diag "  buf after injection : $(wc -c < "$BUF") bytes"
fi

# --- Edit --------------------------------------------------------------------
# The plugin wires the wrapper but cannot install the editor it runs: Claude
# Code has no plugin-install event, so the earliest our code executes is the
# next SessionStart, and a multi-minute `uv tool install` does not fit a hook's
# five-second budget. The keystroke is the first moment with a terminal to ask
# on, so that is where the offer lives.
#
# Declining is remembered. Without the marker every single ctrl+x ctrl+e would
# re-ask, which is worse than the missing editor.
DECLINED="$HOME/.claude/dunders-cc/autoinstall-declined"
SPEC="dunders[all] @ git+https://github.com/tumikosha/dunders.git"

offer_install() {
  if [[ -f "$DECLINED" ]]; then
    diag "  auto-install declined earlier -- not asking again"
    return 1
  fi
  # No terminal means no consent: tests, headless runs and `git commit` paths
  # must never block on a question nobody can see.
  if [[ ! -t 0 || ! -t 1 ]]; then
    diag "  no tty -- not offering to install"
    return 1
  fi

  local mgr=""
  if command -v uv >/dev/null 2>&1; then mgr="uv"
  elif command -v pipx >/dev/null 2>&1; then mgr="pipx"
  fi
  if [[ -z "$mgr" ]]; then
    printf '\n  dunders (__) is not installed, and neither uv nor pipx is here.\n'
    printf '  Install uv first:  curl -LsSf https://astral.sh/uv/install.sh | sh\n\n'
    diag "  neither uv nor pipx available -- cannot offer an install"
    return 1
  fi

  printf '\n  dunders (__) is not installed — it is the editor this wrapper opens.\n'
  printf '  Install it now with %s? [Y/n] ' "$mgr"
  local answer=""
  read -r -t 60 answer || answer=""
  case "${answer:-y}" in
    [Nn]*)
      printf '\n  Fine — falling back to another editor, and not asking again.\n'
      printf '  Change your mind later: rm %s\n\n' "$DECLINED"
      mkdir -p "$(dirname "$DECLINED")" 2>/dev/null || true
      : > "$DECLINED" 2>/dev/null || true
      diag "  user declined the install"
      return 1
      ;;
  esac

  printf '\n  Installing with %s — first time takes a minute.\n\n' "$mgr"
  diag "  installing dunders with $mgr"
  if [[ "$mgr" == "uv" ]]; then
    uv tool install --force "$SPEC" || { diag "  install failed"; return 1; }
  else
    pipx install --force "$SPEC" || { diag "  install failed"; return 1; }
  fi
  hash -r 2>/dev/null || true

  # uv and pipx both land in ~/.local/bin, which the current shell may not have
  # on PATH yet — take the absolute path rather than declaring failure.
  local found=""
  for candidate in __ "$HOME/.local/bin/__"; do
    if command -v "$candidate" >/dev/null 2>&1; then found="$candidate"; break; fi
  done
  if [[ -z "$found" ]]; then
    diag "  installed, but __ is still not on PATH"
    return 1
  fi
  REAL_EDITOR="$found"
  diag "  installed: $found"
  printf '  Done: %s\n\n' "$found"
  return 0
}

editor_bin="${REAL_EDITOR%% *}"
if ! command -v "$editor_bin" >/dev/null 2>&1; then
  diag "  !! real editor '$editor_bin' not found"
  if [[ "$(basename "$editor_bin")" == "__" ]]; then
    offer_install || true
  fi
fi

# Still nothing runnable — the user said no, there was no terminal to ask on, or
# the install failed. Open *something*: a dead keystroke looks like a bug, and
# with `set -e` armed it used to leave the un-stripped transcript in the buffer.
if ! command -v "${REAL_EDITOR%% *}" >/dev/null 2>&1; then
  for candidate in "${VISUAL:-}" __ nano vi; do
    [[ -n "$candidate" ]] || continue
    # Never pick ourselves: $VISUAL can point back at this wrapper.
    [[ "$(basename "${candidate%% *}")" == "cc-edit" ]] && continue
    command -v "${candidate%% *}" >/dev/null 2>&1 || continue
    REAL_EDITOR="$candidate"
    break
  done
  diag "     install it with \`uv tool install dunders\` (or pipx install dunders);"
  diag "     falling back to $REAL_EDITOR for now"
fi

# Vim-family editors get +1 so the cursor lands in the empty compose area rather
# than at the bottom of the transcript.
#
# A non-zero exit must not skip the strip below — `set -e` would otherwise hand
# Claude the whole transcript as the prompt.
case "$REAL_EDITOR" in
  vi|vim|nvim|*/vim|*/nvim) $REAL_EDITOR +1 "$BUF" || diag "  editor exited $?" ;;
  *) $REAL_EDITOR "$BUF" || diag "  editor exited $?" ;;
esac

# --- Strip everything from the marker down -----------------------------------
if grep -qF "$MARKER" "$BUF"; then
  tmp="$BUF.cc-edit.$$"
  awk -v m="$MARKER" 'index($0, m) { exit } { print }' "$BUF" > "$tmp"
  mv "$tmp" "$BUF"
  diag "  stripped -> $(wc -c < "$BUF") bytes sent to Claude"
else
  diag "  marker absent at exit -> whole buffer sent to Claude"
fi
