#!/usr/bin/env bash
# claude-can-speak: speech-out for Claude Code, gated on /voice mode.
#
# This CLI manages the TTS container and the playback lifecycle. The speaking
# itself is driven by a Stop hook (tts-speak.sh); this command is for setup,
# testing, picking a voice, and interrupting playback.
#
# Provided AS IS, with NO WARRANTY of any kind. The author is not liable for
# any damage or loss arising from its use. By using it you accept all risk.
set -uo pipefail

VERSION="0.1.2"

# Install layout: bundled scripts live in ../lib/claude-can-speak relative to
# this CLI, whether installed via npm (into the global node_modules) or run from
# a git checkout. SELF resolves through symlinks so a PATH symlink still works.
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do
  DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
  SOURCE="$(readlink "$SOURCE")"
  [ "${SOURCE#/}" = "$SOURCE" ] && SOURCE="$DIR/$SOURCE"
done
SELF="$(cd -P "$(dirname "$SOURCE")" && pwd)"
LIBEXEC="$SELF/../lib/claude-can-speak"
[ -f "$LIBEXEC/tts-speak.sh" ] || LIBEXEC="$SELF/../lib/claude-can-speak"

CCS_HOME="${CCS_HOME:-$HOME/.config/claude-can-speak}"
CONFIG="$CCS_HOME/config.env"
PIDFILE="$CCS_HOME/speaking.pid"
ENABLED_FLAG="$CCS_HOME/firehose.enabled"
CONTAINER="${CCS_CONTAINER:-ccs-tts}"
IMAGE="${CCS_IMAGE:-claude-can-speak:latest}"
MODELS_DIR="${CCS_MODELS_DIR:-$HOME/.cache/claude-can-speak/models}"
SETTINGS_JSON="${CLAUDE_SETTINGS:-$HOME/.claude/settings.json}"

mkdir -p "$CCS_HOME" 2>/dev/null || true

# Defaults; config.env overrides. (MAX_CHARS is a hook-only concern; the CLI's
# explicit say/test never truncate, so it is not set here.)
ENGINE="kokoro"; VOICE="af_heart"; LANG="en-us"; SPEED="1.0"
# shellcheck source=/dev/null
[ -f "$CONFIG" ] && . "$CONFIG"

die() { echo "claude-can-speak: $*" >&2; exit 1; }

# Docker is a runtime requirement (the TTS engines run in a container so they do
# not touch the host Python env). Check for it with a clear, actionable error.
require_docker() {
  command -v docker >/dev/null 2>&1 || die \
"Docker is required but was not found.
  claude-can-speak runs the TTS engines in a container so they never touch your
  host. Install Docker, then retry:
    https://docs.docker.com/get-docker/"
  docker info >/dev/null 2>&1 || die \
"Docker is installed but the daemon is not reachable.
  Start Docker (e.g. 'systemctl --user start docker' or Docker Desktop) and retry.
  On Linux you may need to be in the 'docker' group: https://docs.docker.com/engine/install/linux-postinstall/"
}

cmd_help() {
  cat <<EOF
claude-can-speak $VERSION - speech-out for Claude Code (gated on /voice)

USAGE
  claude-can-speak <command> [args]

COMMANDS
  setup             One-shot install: Docker check, build, skill, hook.
  on | off          Turn the firehose (speak every reply) on or off. Default off.
  status            Show firehose state, container, config, and model cache.
  test [text]       Speak a sample (or the given text) with the current voice.
  stop              Interrupt any reply currently being spoken.
  say <text>        Speak arbitrary text now (always speaks; used by the skill).
  start | up        Start the persistent TTS container.
  stop-container    Stop and remove the TTS container.
  voice <name>      Set the default voice (e.g. af_heart, af_bella).
  engine <name>     Set the engine: kokoro (English) or piper (multilingual).
  voices            List known voices for the current engine.
  install-hooks     Register the Stop + interrupt hooks in settings.json.
  remove-hooks      Remove this project's hooks from settings.json.
  install-skill     Install the 'speak' skill into ~/.claude/skills.
  skill on|off      Enable/disable the 'speak' skill (settings skillOverrides).
  build             Build the TTS container image locally.
  help | --help     This text.

TWO MODES
  Deliberate (the headline): the 'speak' skill lets Claude choose what to
             voice (notifications, shoutouts) via 'claude-can-speak say'.
             Install with 'claude-can-speak install-skill'.
  Firehose (optional): the Stop hook speaks every reply when the firehose is
             on. Install the hook with 'claude-can-speak install-hooks', then
             toggle with 'claude-can-speak on' / 'off'.

GATING
  The firehose has its own explicit on/off switch ('claude-can-speak on|off',
  default OFF), stored in ~/.config/claude-can-speak/firehose.enabled. It is
  intentionally NOT tied to Claude Code's /voice, which is speech-IN dictation
  and is a separate concern. The deliberate 'speak' skill always speaks when
  invoked, regardless of the firehose switch.

DISCLAIMER
  Provided AS IS, with NO WARRANTY. You accept all risk. See the project
  README for the full text.
EOF
}

ensure_image() {
  docker image inspect "$IMAGE" >/dev/null 2>&1
}

cmd_build() {
  require_docker
  local ctx="$SELF/../container"
  [ -f "$ctx/Dockerfile" ] || die "container build context not found ($ctx)"
  echo "Building $IMAGE from $ctx ..."
  docker build -t "$IMAGE" "$ctx"
}

cmd_start() {
  require_docker
  ensure_image || die "image $IMAGE missing; run: claude-can-speak build"
  if docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -q true; then
    echo "container $CONTAINER already running"; return 0
  fi
  docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
  mkdir -p "$MODELS_DIR"
  docker run -d --name "$CONTAINER" -v "$MODELS_DIR:/models" "$IMAGE" >/dev/null \
    && echo "started $CONTAINER (models cache: $MODELS_DIR)"
}

cmd_stop_container() {
  docker rm -f "$CONTAINER" >/dev/null 2>&1 && echo "removed $CONTAINER" \
    || echo "no container to remove"
}

cmd_stop() {
  # Interrupt current playback. Mirrors stop_current() in the hook.
  local pid stopped=0
  if [ -f "$PIDFILE" ]; then
    pid="$(cat "$PIDFILE" 2>/dev/null)"
    if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
      kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null
      stopped=1
    fi
    rm -f "$PIDFILE" 2>/dev/null || true
  fi
  # Belt and suspenders: stop any stray players too.
  pkill -TERM -x pw-play 2>/dev/null && stopped=1
  pkill -TERM -x paplay  2>/dev/null && stopped=1
  [ "$stopped" = 1 ] && echo "stopped playback" || echo "nothing playing"
}

_synth_to() { # text -> wav path on stdout fd
  ensure_image || die "image $IMAGE missing; run: claude-can-speak build"
  cmd_start >/dev/null
  docker exec -i "$CONTAINER" python3 /app/synth.py \
    --engine "$ENGINE" --voice "$VOICE" --lang "$LANG" --speed "$SPEED"
}

_player() {
  for p in pw-play paplay aplay; do
    command -v "$p" >/dev/null 2>&1 && { echo "$p"; return; }
  done
  return 1
}

cmd_say() {
  local text="$*"
  [ -n "$text" ] || die "usage: claude-can-speak say <text>"
  local player; player="$(_player)" || die "no audio player (pw-play/paplay/aplay)"
  local wav; wav="$(mktemp --suffix=.wav)"
  if printf '%s' "$text" | _synth_to >"$wav" && [ -s "$wav" ]; then
    "$player" "$wav"
  else
    rm -f "$wav"; die "synthesis failed"
  fi
  rm -f "$wav"
}

cmd_test() {
  local text="${*:-Hi Ramazan. This is Claude Code. Voice output is working, using the $VOICE voice.}"
  echo "engine=$ENGINE voice=$VOICE lang=$LANG"
  cmd_say "$text"
}

cmd_status() {
  echo "claude-can-speak $VERSION"
  printf 'firehose     : '
  if [ -f "$ENABLED_FLAG" ]; then
    echo "ON  (replies spoken; 'claude-can-speak off' to silence)"
  else
    echo "off (replies silent; 'claude-can-speak on' to enable)"
  fi
  printf 'engine/voice : %s / %s (%s)\n' "$ENGINE" "$VOICE" "$LANG"
  printf 'image        : '; ensure_image && echo "$IMAGE present" || echo "$IMAGE MISSING (run: build)"
  printf 'container    : '
  docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -q true \
    && echo "$CONTAINER running" || echo "$CONTAINER not running"
  printf 'models cache : %s' "$MODELS_DIR"
  [ -d "$MODELS_DIR" ] && printf ' (%s)\n' "$(du -sh "$MODELS_DIR" 2>/dev/null | cut -f1)" || printf ' (empty)\n'
  printf 'hooks        : '
  if [ -f "$SETTINGS_JSON" ] && grep -q 'tts-speak.sh' "$SETTINGS_JSON" 2>/dev/null; then
    echo "registered"; else echo "NOT registered (run: install-hooks)"; fi
}

_set_config() { # key value
  touch "$CONFIG"
  if grep -q "^$1=" "$CONFIG" 2>/dev/null; then
    sed -i "s|^$1=.*|$1=\"$2\"|" "$CONFIG"
  else
    printf '%s="%s"\n' "$1" "$2" >>"$CONFIG"
  fi
}

cmd_voice() { [ -n "${1:-}" ] || die "usage: claude-can-speak voice <name>"; _set_config VOICE "$1"; echo "voice set to $1"; }
cmd_engine() {
  case "${1:-}" in
    kokoro) _set_config ENGINE kokoro; _set_config LANG en-us; echo "engine set to kokoro (English)";;
    piper)  _set_config ENGINE piper; echo "engine set to piper (multilingual); set a voice with: claude-can-speak voice de_DE-thorsten-high";;
    *) die "engine must be 'kokoro' or 'piper'";;
  esac
}

cmd_voices() {
  if [ "$ENGINE" = piper ]; then
    cat <<EOF
piper voices (multilingual):
  en_US-amy-medium  en_US-lessac-high  en_US-libritts_r-medium
  en_US-hfc_female-medium  en_US-kristin-medium  en_GB-jenny_dioco-medium
  de_DE-thorsten-medium  de_DE-thorsten-high  tr_TR-dfki-medium
EOF
  else
    cat <<EOF
kokoro voices (English; af_=US female, am_=US male, bf_/bm_=British):
  af_heart  af_bella  af_nicole  af_aoede  af_kore  af_sarah
  af_nova  af_sky  af_jessica  af_river  am_michael  am_fenrir  am_puck
EOF
  fi
}

cmd_on() {
  mkdir -p "$CCS_HOME"
  : > "$ENABLED_FLAG"
  echo "firehose ON: replies will be spoken (needs the Stop hook; run install-hooks if you have not)."
}

cmd_off() {
  rm -f "$ENABLED_FLAG" 2>/dev/null
  # Also stop anything currently speaking.
  cmd_stop >/dev/null 2>&1 || true
  echo "firehose OFF: replies will not be spoken."
}

# One-shot setup: the happy path after `npm install -g`. Docker check, build the
# image, install the deliberate skill and the firehose hook. Idempotent.
cmd_setup() {
  echo "claude-can-speak setup"
  require_docker
  echo "1/3 building the TTS container image (first time pulls deps, ~2 min) ..."
  cmd_build
  echo "2/3 installing the 'speak' skill ..."
  cmd_install_skill
  echo "3/3 installing the firehose Stop hook ..."
  cmd_install_hooks
  cat <<EOF

Setup complete.
  - Deliberate mode: Claude can voice notifications via the 'speak' skill (active now).
  - Firehose mode:   turn it on with 'claude-can-speak on' (off by default).
  Restart Claude Code once so it loads the new skill and hook, then try:
    claude-can-speak on
    claude-can-speak test
EOF
}

cmd_install_skill() {
  # Locate the packaged skill (deb vs git checkout).
  local src
  for cand in "/usr/share/claude-can-speak/skills/speak" "$SELF/../skills/speak"; do
    [ -f "$cand/SKILL.md" ] && { src="$cand"; break; }
  done
  [ -n "${src:-}" ] || die "packaged skill not found"
  local dest="$HOME/.claude/skills/speak"
  mkdir -p "$dest"
  cp "$src/SKILL.md" "$dest/SKILL.md"
  echo "installed 'speak' skill -> $dest/SKILL.md"
  echo "if ~/.claude/skills did not exist before, restart your session to discover it."
}

cmd_skill() {
  case "${1:-}" in
    on)  _skill_override on;  echo "'speak' skill enabled" ;;
    off) _skill_override off; echo "'speak' skill disabled" ;;
    *) die "usage: claude-can-speak skill on|off" ;;
  esac
}

_skill_override() { # on|off  -> settings.json skillOverrides.speak
  [ -f "$SETTINGS_JSON" ] || { mkdir -p "$(dirname "$SETTINGS_JSON")"; echo '{}' >"$SETTINGS_JSON"; }
  python3 - "$SETTINGS_JSON" "$1" <<'PY'
import json, sys, os
path, state = sys.argv[1], sys.argv[2]
with open(path) as f: cfg = json.load(f)
ov = cfg.setdefault("skillOverrides", {})
if state == "off": ov["speak"] = "off"
else: ov.pop("speak", None)        # remove override = default 'on'
tmp = path + ".tmp"
with open(tmp, "w") as f: json.dump(cfg, f, indent=2); f.write("\n")
os.replace(tmp, path)
PY
}

cmd_install_hooks() {
  [ -x "$LIBEXEC/install-hooks.sh" ] || die "install-hooks.sh not found in $LIBEXEC"
  "$LIBEXEC/install-hooks.sh" install
}
cmd_remove_hooks() {
  [ -x "$LIBEXEC/install-hooks.sh" ] || die "install-hooks.sh not found in $LIBEXEC"
  "$LIBEXEC/install-hooks.sh" remove
}

case "${1:-help}" in
  setup)          cmd_setup ;;
  on)             cmd_on ;;
  off)            cmd_off ;;
  status)         cmd_status ;;
  test)           shift; cmd_test "$@" ;;
  say)            shift; cmd_say "$@" ;;
  stop)           cmd_stop ;;
  start|up)       cmd_start ;;
  stop-container) cmd_stop_container ;;
  voice)          shift; cmd_voice "${1:-}" ;;
  engine)         shift; cmd_engine "${1:-}" ;;
  voices)         cmd_voices ;;
  install-hooks)  cmd_install_hooks ;;
  remove-hooks)   cmd_remove_hooks ;;
  install-skill)  cmd_install_skill ;;
  skill)          shift; cmd_skill "${1:-}" ;;
  build)          cmd_build ;;
  help|-h|--help) cmd_help ;;
  --version|-V)   echo "claude-can-speak $VERSION" ;;
  *)              die "unknown command '$1' (try: claude-can-speak help)" ;;
esac
