#!/usr/bin/env bash
# ops-voice — Voice / call helper (native macOS + Twilio + FaceTime + Zoom + Bland AI)
#
# Subcommands:
#   phone    <number> [--json]                       Native Phone.app via tel: (Continuity)
#   facetime <number-or-handle> [--audio] [--json]   FaceTime video (default) or audio
#   zoom     start|join <meeting-id> [--pwd P]       Open zoom.us app to start/join
#   zoom     schedule "<topic>" [--start ISO8601] [--duration MIN]   Zoom REST schedule
#   whatsapp-call <number> [--video] [--json]        Open WhatsApp desktop to call
#   meet     start|join <code> [--json]              Open Google Meet (new or by code)
#   twilio-call <to> <from> --twiml URL [--json]     Programmatic outbound voice
#   twilio-sms  <to> <from> "<body>" [--json]        Programmatic outbound SMS
#   bland-call  <number> "<prompt>" [--json]         AI agent phone call
#   --help | -h
#
# Credential resolution order (per channel):
#   1. Env vars
#   2. ops_cred_get <service> <account>   (via lib/credential-store.sh)
#   3. $PREFS_PATH (preferences.json)
#   4. Doppler CLI fallback
#
# Native channels (phone, facetime, zoom start/join) require no credentials —
# they invoke macOS URL schemes via /usr/bin/open. Twilio / Bland / Zoom-schedule
# require API keys.
set -euo pipefail

# ─── Paths & lib sourcing ───────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LIB_OS_DETECT="$SCRIPT_DIR/lib/os-detect.sh"
LIB_CRED="$SCRIPT_DIR/lib/credential-store.sh"
LIB_OPENER="$SCRIPT_DIR/lib/opener.sh"

# shellcheck disable=SC1090
[ -r "$LIB_OS_DETECT" ] && . "$LIB_OS_DETECT" || true
# shellcheck disable=SC1090
[ -r "$LIB_CRED" ] && . "$LIB_CRED" || true
# shellcheck disable=SC1090
[ -r "$LIB_OPENER" ] && . "$LIB_OPENER" || true

PREFS_DIR="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}"
PREFS_PATH="${PREFS_PATH:-$PREFS_DIR/preferences.json}"

USER_AGENT="ops-voice (claude-ops, v1)"

JSON_MODE=false

# ─── Output helpers ─────────────────────────────────────────────────────────
emit_err() {
  local msg="$1"
  if $JSON_MODE; then
    printf '{"error":%s}\n' "$(json_escape "$msg")"
  else
    printf 'ops-voice: %s\n' "$msg" >&2
  fi
}

emit_ok() {
  local channel="$1" detail="$2"
  if $JSON_MODE; then
    printf '{"ok":true,"channel":%s,"detail":%s}\n' \
      "$(json_escape "$channel")" "$(json_escape "$detail")"
  else
    printf 'ops-voice [%s] %s\n' "$channel" "$detail"
  fi
}

json_escape() {
  if command -v jq >/dev/null 2>&1; then
    printf '%s' "$1" | jq -Rs .
    return 0
  fi
  local s="$1"
  s="${s//\\/\\\\}"
  s="${s//\"/\\\"}"
  s="${s//$'\n'/\\n}"
  s="${s//$'\r'/\\r}"
  s="${s//$'\t'/\\t}"
  printf '"%s"' "$s"
}

require_macos() {
  if [[ "$(uname -s)" != "Darwin" ]]; then
    emit_err "native channel requires macOS (detected $(uname -s))"
    exit 1
  fi
}

# open_native — route URL-scheme launches through ops_open_url (Rule 7:
# prints a copy-able block on SSH/mobile instead of opening on the remote
# host). Falls back to /usr/bin/open if the opener lib isn't sourced.
open_native() {
  local url="$1"
  if declare -F ops_open_url >/dev/null 2>&1; then
    ops_open_url "$url"
    return $?
  fi
  /usr/bin/open "$url" >/dev/null 2>&1
}

# ─── Number normalisation ───────────────────────────────────────────────────
# Accept "+31612345678", "0612345678", "612-345-678", "(555) 123-4567".
# Return canonical E.164 if input starts with +, else digits only.
normalize_number() {
  local raw="$1"
  if [[ "$raw" =~ ^\+ ]]; then
    printf '+%s' "$(printf '%s' "${raw#+}" | tr -cd '0-9')"
  else
    printf '%s' "$(printf '%s' "$raw" | tr -cd '0-9')"
  fi
}

# ─── Credential resolvers ───────────────────────────────────────────────────
get_secret() {
  # get_secret <service> <account> <env-fallback>
  local service="$1" account="$2" env_fallback="$3"
  local val="${!env_fallback:-}"
  [[ -n "$val" ]] && { printf '%s' "$val"; return 0; }

  if declare -F ops_cred_get >/dev/null 2>&1; then
    val="$(ops_cred_get "$service" "$account" 2>/dev/null || true)"
    [[ -n "$val" ]] && { printf '%s' "$val"; return 0; }
  fi

  if [[ -s "$PREFS_PATH" ]] && command -v jq >/dev/null 2>&1; then
    val="$(jq -r --arg s "$service" --arg a "$account" \
      '.[$s][$a] // .[$s].api_key // ""' "$PREFS_PATH" 2>/dev/null || true)"
    [[ -n "$val" && "$val" != "null" ]] && { printf '%s' "$val"; return 0; }
  fi

  if command -v doppler >/dev/null 2>&1; then
    val="$(doppler secrets get "$env_fallback" --plain 2>/dev/null || true)"
    [[ -n "$val" ]] && { printf '%s' "$val"; return 0; }
  fi

  return 1
}

# ─── Subcommand: phone (native Continuity call) ─────────────────────────────
cmd_phone() {
  local raw="${1:-}"
  [[ -z "$raw" ]] && { emit_err "usage: ops-voice phone <number>"; exit 2; }
  require_macos
  local number; number="$(normalize_number "$raw")"
  open_native "tel:${number}" || {
    emit_err "open tel: failed — Continuity / iPhone link may be unavailable"
    exit 1
  }
  emit_ok "phone" "dialing $number via Phone.app (Continuity)"
}

# ─── Subcommand: facetime ───────────────────────────────────────────────────
cmd_facetime() {
  local raw="${1:-}" audio_only=false
  shift || true
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --audio) audio_only=true ;;
      --json)  JSON_MODE=true ;;
      *)       emit_err "unknown flag: $1"; exit 2 ;;
    esac
    shift
  done
  [[ -z "$raw" ]] && { emit_err "usage: ops-voice facetime <number-or-handle> [--audio]"; exit 2; }
  require_macos
  local handle
  if [[ "$raw" =~ @ ]]; then
    handle="$raw"
  else
    handle="$(normalize_number "$raw")"
  fi
  local scheme="facetime"
  $audio_only && scheme="facetime-audio"
  open_native "${scheme}://${handle}" || {
    emit_err "open ${scheme}: failed — FaceTime may not be signed in"
    exit 1
  }
  emit_ok "facetime" "starting ${scheme} with $handle"
}

# ─── Subcommand: whatsapp-call ──────────────────────────────────────────────
# Opens WhatsApp desktop and starts a voice or video call with the contact.
# Uses the `whatsapp://` URL scheme handled by WhatsApp.app / WhatsApp Desktop.
cmd_whatsapp_call() {
  local raw="${1:-}" video=false
  shift || true
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --video) video=true ;;
      --json)  JSON_MODE=true ;;
      *)       emit_err "unknown flag: $1"; exit 2 ;;
    esac
    shift
  done
  [[ -z "$raw" ]] && { emit_err "usage: ops-voice whatsapp-call <number> [--video]"; exit 2; }
  local number; number="$(normalize_number "$raw")"
  # Strip leading + — WhatsApp's scheme expects digits only after `phone=`.
  local digits="${number#+}"
  local url
  if $video; then
    url="whatsapp://video?phone=${digits}"
  else
    url="whatsapp://call?phone=${digits}"
  fi
  open_native "$url" || {
    emit_err "open whatsapp: failed — install WhatsApp Desktop and sign in"
    exit 1
  }
  local kind="voice"; $video && kind="video"
  emit_ok "whatsapp-call" "starting WhatsApp $kind call to $number"
}

# ─── Subcommand: meet ───────────────────────────────────────────────────────
# Google Meet handler.
#   meet start                — open https://meet.new (new instant meeting)
#   meet join <code-or-url>   — join existing meeting by code (abc-defg-hij) or full URL
cmd_meet() {
  local action="${1:-}"; shift || true
  case "$action" in
    start)
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --json) JSON_MODE=true ;;
          *)      emit_err "unknown flag: $1"; exit 2 ;;
        esac
        shift
      done
      open_native "https://meet.new" || {
        emit_err "open https://meet.new failed"
        exit 1
      }
      emit_ok "meet" "starting new Google Meet (meet.new)"
      ;;
    join)
      local target="${1:-}"
      shift || true
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --json) JSON_MODE=true ;;
          *)      emit_err "unknown flag: $1"; exit 2 ;;
        esac
        shift
      done
      [[ -z "$target" ]] && { emit_err "usage: ops-voice meet join <code-or-url>"; exit 2; }
      local url
      if [[ "$target" =~ ^https?:// ]]; then
        url="$target"
      elif [[ "$target" =~ ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ ]]; then
        url="https://meet.google.com/${target}"
      else
        # Accept bare meeting codes without dashes too; pass through unchanged.
        url="https://meet.google.com/${target}"
      fi
      open_native "$url" || {
        emit_err "open $url failed"
        exit 1
      }
      emit_ok "meet" "joining $url"
      ;;
    *)
      emit_err "usage: ops-voice meet {start|join <code-or-url>}"
      exit 2
      ;;
  esac
}

# ─── Subcommand: zoom ───────────────────────────────────────────────────────
cmd_zoom() {
  local action="${1:-}"; shift || true
  case "$action" in
    start)
      require_macos
      # On SSH/mobile, ops_open_url prints the URL instead of launching;
      # falling back to `open -a zoom.us` only makes sense locally.
      if declare -F __ops_is_remote_session >/dev/null 2>&1 && __ops_is_remote_session; then
        open_native "zoommtg://zoom.us/start?confno="
      else
        open_native "zoommtg://zoom.us/start?confno=" || true
        /usr/bin/open -a "zoom.us" >/dev/null 2>&1 || {
          emit_err "Zoom not installed (expected /Applications/zoom.us.app)"
          exit 1
        }
      fi
      emit_ok "zoom" "starting instant meeting in zoom.us"
      ;;
    join)
      local mid="${1:-}" pwd=""
      shift || true
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --pwd|--password) pwd="$2"; shift 2 ;;
          --json) JSON_MODE=true; shift ;;
          *) shift ;;
        esac
      done
      [[ -z "$mid" ]] && { emit_err "usage: ops-voice zoom join <meeting-id> [--pwd P]"; exit 2; }
      require_macos
      local url="zoommtg://zoom.us/join?confno=${mid}"
      [[ -n "$pwd" ]] && url="${url}&pwd=${pwd}"
      open_native "$url" || {
        emit_err "open zoommtg: failed"
        exit 1
      }
      emit_ok "zoom" "joining meeting $mid"
      ;;
    schedule)
      local topic="${1:-}"; shift || true
      local start="" duration=30
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --start)    start="$2"; shift 2 ;;
          --duration) duration="$2"; shift 2 ;;
          --json)     JSON_MODE=true; shift ;;
          *) shift ;;
        esac
      done
      [[ -z "$topic" ]] && { emit_err 'usage: ops-voice zoom schedule "<topic>" [--start ISO8601] [--duration MIN]'; exit 2; }
      local jwt
      jwt="$(get_secret zoom api-token ZOOM_API_TOKEN)" || {
        emit_err "no zoom credential — set ZOOM_API_TOKEN (Server-to-Server OAuth access token)"
        exit 1
      }
      local body
      body="$(jq -n --arg t "$topic" --arg s "$start" --argjson d "$duration" \
        '{topic:$t, type: (if $s == "" then 1 else 2 end), duration:$d} +
         (if $s == "" then {} else {start_time:$s, timezone:"UTC"} end)')"
      local resp
      resp="$(curl -sS -X POST "https://api.zoom.us/v2/users/me/meetings" \
        -H "Authorization: Bearer $jwt" \
        -H "Content-Type: application/json" \
        -A "$USER_AGENT" \
        --data "$body")"
      local join_url id
      join_url="$(printf '%s' "$resp" | jq -r '.join_url // empty' 2>/dev/null)"
      id="$(printf '%s' "$resp" | jq -r '.id // empty' 2>/dev/null)"
      if [[ -z "$join_url" ]]; then
        emit_err "zoom schedule failed: $resp"
        exit 1
      fi
      emit_ok "zoom" "scheduled meeting $id — $join_url"
      ;;
    *)
      emit_err "usage: ops-voice zoom {start|join <id>|schedule \"<topic>\"}"
      exit 2
      ;;
  esac
}

# ─── Subcommand: twilio-call ────────────────────────────────────────────────
cmd_twilio_call() {
  local to="${1:-}" from="${2:-}" twiml=""
  shift 2 || true
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --twiml) twiml="$2"; shift 2 ;;
      --json)  JSON_MODE=true; shift ;;
      *) shift ;;
    esac
  done
  [[ -z "$to" || -z "$from" || -z "$twiml" ]] && {
    emit_err "usage: ops-voice twilio-call <to> <from> --twiml <URL>"
    exit 2
  }
  local sid token
  sid="$(get_secret twilio account-sid TWILIO_ACCOUNT_SID)" || {
    emit_err "no twilio credential — set TWILIO_ACCOUNT_SID"; exit 1; }
  token="$(get_secret twilio auth-token TWILIO_AUTH_TOKEN)" || {
    emit_err "no twilio credential — set TWILIO_AUTH_TOKEN"; exit 1; }
  local to_n from_n
  to_n="$(normalize_number "$to")"
  from_n="$(normalize_number "$from")"
  local resp
  resp="$(curl -sS -X POST \
    "https://api.twilio.com/2010-04-01/Accounts/${sid}/Calls.json" \
    -u "${sid}:${token}" \
    -A "$USER_AGENT" \
    --data-urlencode "To=${to_n}" \
    --data-urlencode "From=${from_n}" \
    --data-urlencode "Url=${twiml}")"
  local call_sid status
  call_sid="$(printf '%s' "$resp" | jq -r '.sid // empty' 2>/dev/null)"
  status="$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)"
  if [[ -z "$call_sid" ]]; then
    emit_err "twilio call failed: $resp"
    exit 1
  fi
  emit_ok "twilio-call" "$call_sid status=$status"
}

# ─── Subcommand: twilio-sms ─────────────────────────────────────────────────
cmd_twilio_sms() {
  local to="${1:-}" from="${2:-}" body="${3:-}"
  shift 3 || true
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --json) JSON_MODE=true; shift ;;
      *) shift ;;
    esac
  done
  [[ -z "$to" || -z "$from" || -z "$body" ]] && {
    emit_err 'usage: ops-voice twilio-sms <to> <from> "<body>"'
    exit 2
  }
  local sid token
  sid="$(get_secret twilio account-sid TWILIO_ACCOUNT_SID)" || {
    emit_err "no twilio credential — set TWILIO_ACCOUNT_SID"; exit 1; }
  token="$(get_secret twilio auth-token TWILIO_AUTH_TOKEN)" || {
    emit_err "no twilio credential — set TWILIO_AUTH_TOKEN"; exit 1; }
  local to_n from_n
  to_n="$(normalize_number "$to")"
  from_n="$(normalize_number "$from")"
  local resp
  resp="$(curl -sS -X POST \
    "https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json" \
    -u "${sid}:${token}" \
    -A "$USER_AGENT" \
    --data-urlencode "To=${to_n}" \
    --data-urlencode "From=${from_n}" \
    --data-urlencode "Body=${body}")"
  local msg_sid status
  msg_sid="$(printf '%s' "$resp" | jq -r '.sid // empty' 2>/dev/null)"
  status="$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)"
  if [[ -z "$msg_sid" ]]; then
    emit_err "twilio sms failed: $resp"
    exit 1
  fi
  emit_ok "twilio-sms" "$msg_sid status=$status"
}

# ─── Subcommand: bland-call ─────────────────────────────────────────────────
cmd_bland_call() {
  local number="${1:-}" prompt="${2:-}"
  shift 2 || true
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --json) JSON_MODE=true; shift ;;
      *) shift ;;
    esac
  done
  [[ -z "$number" || -z "$prompt" ]] && {
    emit_err 'usage: ops-voice bland-call <number> "<task prompt>"'
    exit 2
  }
  local key
  key="$(get_secret bland api-key BLAND_AI_API_KEY)" || {
    emit_err "no bland credential — set BLAND_AI_API_KEY"; exit 1; }
  local number_n; number_n="$(normalize_number "$number")"
  local body
  body="$(jq -n --arg p "$number_n" --arg t "$prompt" \
    '{phone_number:$p, task:$t, record:true, max_duration:300}')"
  local resp
  resp="$(curl -sS -X POST "https://api.bland.ai/v1/calls" \
    -H "authorization: $key" \
    -H "Content-Type: application/json" \
    -A "$USER_AGENT" \
    --data "$body")"
  local call_id
  call_id="$(printf '%s' "$resp" | jq -r '.call_id // empty' 2>/dev/null)"
  if [[ -z "$call_id" ]]; then
    emit_err "bland call failed: $resp"
    exit 1
  fi
  emit_ok "bland-call" "$call_id (poll with: curl -H \"authorization: \$KEY\" https://api.bland.ai/v1/calls/$call_id)"
}

# ─── Cross-OS date helper ──────────────────────────────────────────────────
# iso8601_to_epoch — Converts "2026-05-21T09:50:00+02:00" → epoch seconds.
# Portable across GNU date (Linux/WSL), BSD date (macOS/FreeBSD). Emits
# empty string on failure so callers can guard.
iso8601_to_epoch() {
  local iso="$1"
  [[ -z "$iso" ]] && return 1
  local epoch
  # 1) GNU date — accepts "+02:00" natively.
  if epoch="$(date -d "$iso" +%s 2>/dev/null)" && [[ -n "$epoch" ]]; then
    printf '%s' "$epoch"
    return 0
  fi
  # 2) BSD date — needs offset without colon.
  local stripped="${iso}"
  if [[ "$iso" =~ ([+-][0-9]{2}):([0-9]{2})$ ]]; then
    stripped="${iso%${BASH_REMATCH[0]}}${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
  fi
  if epoch="$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$stripped" +%s 2>/dev/null)" && [[ -n "$epoch" ]]; then
    printf '%s' "$epoch"
    return 0
  fi
  # 3) Naive UTC-only Z form via jq (last resort).
  if [[ "$iso" =~ Z$ ]] && command -v jq >/dev/null 2>&1; then
    epoch="$(printf '"%s"' "$iso" | jq -r 'fromdateiso8601' 2>/dev/null)"
    [[ -n "$epoch" && "$epoch" != "null" ]] && { printf '%s' "$epoch"; return 0; }
  fi
  return 1
}

# ─── AV policy helpers (lid state, mic source, Elgato camera) ───────────────

# detect_lid_state — Returns "open" / "closed" / "unknown".
# macOS: ioreg reports AppleClamshellState=Yes when the lid is closed.
# Linux: /proc/acpi/button/lid/*/state (LID0 etc.) → "state: open|closed".
# Other: "unknown".
detect_lid_state() {
  case "$(uname -s)" in
    Darwin)
      local clamshell
      clamshell="$(ioreg -r -k AppleClamshellState -d 4 2>/dev/null \
        | awk -F'= ' '/AppleClamshellState/ {print $2; exit}')"
      case "$clamshell" in
        Yes) printf 'closed' ;;
        No)  printf 'open' ;;
        *)   printf 'unknown' ;;
      esac
      ;;
    Linux)
      local state_file
      state_file="$(ls /proc/acpi/button/lid/*/state 2>/dev/null | head -1)"
      if [[ -n "$state_file" ]]; then
        local st
        st="$(awk '{print $2}' "$state_file" 2>/dev/null)"
        case "$st" in
          open|closed) printf '%s' "$st" ;;
          *)           printf 'unknown' ;;
        esac
      else
        printf 'unknown'
      fi
      ;;
    *)
      printf 'unknown'
      ;;
  esac
}

# pick_mic_source — "macbook" when lid open, "external" when lid closed,
# "default" when lid state is unknown (non-mac / non-laptop / unsupported).
# Side-effect-free: just emits the policy; actual device switching is left
# to the meeting app's audio settings (Zoom remembers last device).
pick_mic_source() {
  local lid; lid="$(detect_lid_state)"
  case "$lid" in
    closed) printf 'external' ;;
    open)   printf 'macbook' ;;
    *)      printf 'default' ;;
  esac
}

# elgato_camera_hub_path — emits the path/binary for Elgato Camera Hub.
#   macOS: .app bundle under /Applications.
#   Linux: `elgato-camera-hub` on PATH (or AppImage in ~/Applications).
#   Windows / WSL: PROGRAMFILES path if /mnt/c is mounted; else skip.
elgato_camera_hub_path() {
  case "$(uname -s)" in
    Darwin)
      for p in \
        "/Applications/Elgato Camera Hub.app" \
        "$HOME/Applications/Elgato Camera Hub.app" \
        "/Applications/Camera Hub.app"; do
        [[ -d "$p" ]] && { printf '%s' "$p"; return 0; }
      done
      ;;
    Linux)
      if command -v elgato-camera-hub >/dev/null 2>&1; then
        command -v elgato-camera-hub
        return 0
      fi
      local ai
      ai="$(ls "$HOME/Applications"/Elgato*CameraHub*.AppImage 2>/dev/null | head -1)"
      [[ -n "$ai" ]] && { printf '%s' "$ai"; return 0; }
      ;;
    MINGW*|MSYS*|CYGWIN*)
      local pf="${PROGRAMFILES:-/c/Program Files}/Elgato/CameraHub/CameraHub.exe"
      [[ -f "$pf" ]] && { printf '%s' "$pf"; return 0; }
      ;;
  esac
  return 1
}

# launch_elgato_if_available — opens Camera Hub so the Elgato virtual cam
# is registered before Zoom/FaceTime/Meet grab the device. The actual
# "select Elgato Virtual Camera" choice is per-app — Zoom/FaceTime/Meet
# remember the last selected device, so opening Camera Hub once is usually
# enough; the user flips the toggle in-app the first time and it sticks.
launch_elgato_if_available() {
  local hub; hub="$(elgato_camera_hub_path)" || return 1
  case "$(uname -s)" in
    Darwin)
      /usr/bin/open -a "$hub" >/dev/null 2>&1 || return 1 ;;
    Linux)
      ( "$hub" >/dev/null 2>&1 & ) || return 1 ;;
    MINGW*|MSYS*|CYGWIN*)
      ( "$hub" >/dev/null 2>&1 & ) || return 1 ;;
    *)
      return 1 ;;
  esac
  printf '%s' "$hub"
}

# decide_av_policy — Smart heuristic.
#   1 or 2 attendees   → cam ON,  mic ON
#   3–9 attendees      → cam ON,  mic MUTED
#   10+ attendees      → cam OFF, mic MUTED
# Description tags override: [cam:on|off] [mic:on|off|muted]
# Emits a JSON-ish key=value line: cam=on|off mic=on|muted
decide_av_policy() {
  local attendee_count="${1:-0}" description="${2:-}"
  local cam mic
  if (( attendee_count <= 2 )); then
    cam="on"; mic="on"
  elif (( attendee_count <= 9 )); then
    cam="on"; mic="muted"
  else
    cam="off"; mic="muted"
  fi
  # Tag overrides — case-insensitive, accept on/off for cam, on/off/muted for mic.
  local d_lower; d_lower="$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]')"
  if [[ "$d_lower" =~ \[cam:(on|off)\] ]]; then cam="${BASH_REMATCH[1]}"; fi
  if [[ "$d_lower" =~ \[mic:(on|off|muted)\] ]]; then
    mic="${BASH_REMATCH[1]}"
    [[ "$mic" == "off" ]] && mic="muted"
  fi
  printf 'cam=%s mic=%s' "$cam" "$mic"
}

# extract_meeting_url — Pulls a Zoom/Meet/Teams/Webex URL from an event JSON.
#   1. event.hangoutLink (Google Meet — gog surfaces it)
#   2. conferenceData.entryPoints[].uri (preferred)
#   3. location field
#   4. description field (regex scan)
extract_meeting_url() {
  local event_json="$1"
  local url
  url="$(printf '%s' "$event_json" | jq -r '.hangoutLink // ""' 2>/dev/null)"
  [[ -n "$url" && "$url" != "null" ]] && { printf '%s' "$url"; return 0; }
  url="$(printf '%s' "$event_json" | jq -r '
    (.conferenceData.entryPoints // [])
    | map(select(.entryPointType == "video"))
    | .[0].uri // ""' 2>/dev/null)"
  [[ -n "$url" && "$url" != "null" ]] && { printf '%s' "$url"; return 0; }
  local haystack
  haystack="$(printf '%s' "$event_json" | jq -r '"\(.location // "") \(.description // "")"' 2>/dev/null)"
  url="$(printf '%s' "$haystack" \
    | grep -oE 'https?://[^[:space:]<>"]*(zoom\.us/j/[0-9]+(\?pwd=[^[:space:]<>"]*)?|meet\.google\.com/[a-z-]+|teams\.microsoft\.com/l/meetup-join/[^[:space:]<>"]+|[a-z0-9.-]*webex\.com/(meet|j|wbxmjs)/[^[:space:]<>"]+)' \
    | head -1)"
  [[ -n "$url" ]] && { printf '%s' "$url"; return 0; }
  return 1
}

# classify_meeting_url — emits "zoom" / "meet" / "teams" / "webex" / "unknown".
classify_meeting_url() {
  local url="$1"
  case "$url" in
    *zoom.us/*)              printf 'zoom' ;;
    *meet.google.com/*)      printf 'meet' ;;
    *teams.microsoft.com/*)  printf 'teams' ;;
    *webex.com/*)            printf 'webex' ;;
    *)                       printf 'unknown' ;;
  esac
}

# zoom_url_to_zoommtg — Converts https://*.zoom.us/j/<ID>?pwd=<PWD> to a
# zoommtg:// URL that opens directly in the desktop app (no browser prompt).
# Honors AV policy via &zc=0 (mute video) — Zoom doesn't expose a mic-mute
# query param in the URL scheme; that has to be done via the desktop app
# preference (Settings → Audio → "Always mute my microphone when joining").
zoom_url_to_zoommtg() {
  local https_url="$1" cam_pref="${2:-on}"
  local confno pwd
  confno="$(printf '%s' "$https_url" | sed -nE 's|.*zoom\.us/j/([0-9]+).*|\1|p')"
  pwd="$(printf '%s' "$https_url" | sed -nE 's|.*[?&]pwd=([^&]+).*|\1|p')"
  [[ -z "$confno" ]] && { printf '%s' "$https_url"; return 0; }
  local out="zoommtg://zoom.us/join?confno=${confno}"
  [[ -n "$pwd" ]] && out="${out}&pwd=${pwd}"
  [[ "$cam_pref" == "off" ]] && out="${out}&zc=0"
  printf '%s' "$out"
}

# ─── Subcommand: join (smart calendar-driven meeting joiner) ────────────────
cmd_join() {
  local when="now"  # now | next | HH:MM
  local dry_run=false
  local window_min=10
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --at)     when="$2"; shift 2 ;;
      --window) window_min="$2"; shift 2 ;;
      --dry-run) dry_run=true; shift ;;
      --json)   JSON_MODE=true; shift ;;
      *) shift ;;
    esac
  done

  command -v gog >/dev/null 2>&1 || {
    emit_err "join requires gog CLI (calendar lookup)"; exit 1; }

  local raw
  raw="$(gog calendar events --all --today -j --sort start --no-input 2>/dev/null)"
  [[ -z "$raw" ]] && { emit_err "gog returned no calendar data"; exit 1; }

  # Build a parallel TSV of (idx, start_epoch, end_epoch) using the portable
  # iso8601_to_epoch helper — jq's fromdateiso8601 doesn't accept "+HH:MM"
  # offsets (only "Z"), which breaks on every gog timestamp.
  local now_epoch; now_epoch="$(date +%s)"
  local window_sec=$(( window_min * 60 ))

  local total_events
  total_events="$(printf '%s' "$raw" | jq '(.events // []) | length' 2>/dev/null)"
  [[ -z "$total_events" || "$total_events" == "null" ]] && total_events=0

  local picked_idx="" picked_start=""
  local i s_iso e_iso s_ep e_ep
  for (( i=0; i<total_events; i++ )); do
    s_iso="$(printf '%s' "$raw" | jq -r --argjson i "$i" '.events[$i].startLocal // ""')"
    e_iso="$(printf '%s' "$raw" | jq -r --argjson i "$i" '.events[$i].endLocal // ""')"
    [[ -z "$s_iso" || -z "$e_iso" ]] && continue
    s_ep="$(iso8601_to_epoch "$s_iso")" || continue
    e_ep="$(iso8601_to_epoch "$e_iso")" || continue
    if [[ "$when" == "now" ]]; then
      # Current event (within ±window) takes priority — pick the first match.
      if (( now_epoch >= s_ep - window_sec && now_epoch <= e_ep + window_sec )); then
        picked_idx="$i"
        break
      fi
    fi
    # Track earliest future event as fallback (next).
    if (( s_ep > now_epoch )); then
      if [[ -z "$picked_start" ]] || (( s_ep < picked_start )); then
        picked_start="$s_ep"
        # Only commit fallback once outer loop finishes "now" search.
        [[ "$when" != "now" ]] && picked_idx="$i"
      fi
    fi
  done

  # "now" mode: if no current event matched, fall back to earliest future.
  if [[ "$when" == "now" && -z "$picked_idx" && -n "$picked_start" ]]; then
    for (( i=0; i<total_events; i++ )); do
      s_iso="$(printf '%s' "$raw" | jq -r --argjson i "$i" '.events[$i].startLocal // ""')"
      [[ -z "$s_iso" ]] && continue
      s_ep="$(iso8601_to_epoch "$s_iso")" || continue
      if (( s_ep == picked_start )); then
        picked_idx="$i"
        break
      fi
    done
  fi

  if [[ -z "$picked_idx" ]]; then
    emit_err "no current or upcoming meeting found today"
    exit 1
  fi

  local event
  event="$(printf '%s' "$raw" | jq --argjson i "$picked_idx" '.events[$i]')"

  local summary attendees_n description
  summary="$(printf '%s' "$event" | jq -r '.summary // "(untitled)"')"
  attendees_n="$(printf '%s' "$event" | jq -r '(.attendees // []) | length')"
  description="$(printf '%s' "$event" | jq -r '.description // ""')"

  local meeting_url
  if ! meeting_url="$(extract_meeting_url "$event")"; then
    emit_err "no meeting URL found in event \"$summary\" (no hangoutLink, no conferenceData, no scannable URL)"
    exit 1
  fi

  local kind; kind="$(classify_meeting_url "$meeting_url")"
  local policy; policy="$(decide_av_policy "$attendees_n" "$description")"
  local cam_pref mic_pref
  cam_pref="$(printf '%s' "$policy" | sed -nE 's/.*cam=([^ ]+).*/\1/p')"
  mic_pref="$(printf '%s' "$policy" | sed -nE 's/.*mic=([^ ]+).*/\1/p')"

  local lid mic_source elgato_hub=""
  lid="$(detect_lid_state)"
  mic_source="$(pick_mic_source)"
  elgato_hub="$(launch_elgato_if_available 2>/dev/null || true)"

  if $dry_run; then
    if $JSON_MODE; then
      jq -n \
        --arg summary "$summary" \
        --arg url "$meeting_url" \
        --arg kind "$kind" \
        --arg cam "$cam_pref" \
        --arg mic "$mic_pref" \
        --arg lid "$lid" \
        --arg mic_source "$mic_source" \
        --arg elgato "$elgato_hub" \
        --argjson attendees "$attendees_n" \
        '{ok:true, dry_run:true, summary:$summary, url:$url, kind:$kind,
          attendees:$attendees, cam:$cam, mic:$mic,
          lid:$lid, mic_source:$mic_source, elgato_hub:$elgato}'
    else
      printf 'dry-run: would join "%s" (%s, %d attendees)\n' "$summary" "$kind" "$attendees_n"
      printf '  url=%s\n  cam=%s mic=%s\n  lid=%s mic_source=%s\n' \
        "$meeting_url" "$cam_pref" "$mic_pref" "$lid" "$mic_source"
      [[ -n "$elgato_hub" ]] && printf '  elgato_hub=%s\n' "$elgato_hub"
    fi
    return 0
  fi

  # Actually join.
  local launch_url="$meeting_url"
  if [[ "$kind" == "zoom" ]]; then
    launch_url="$(zoom_url_to_zoommtg "$meeting_url" "$cam_pref")"
  fi
  open_native "$launch_url" || {
    emit_err "failed to open meeting URL: $launch_url"
    exit 1
  }

  local detail
  detail="joined \"$summary\" ($kind, ${attendees_n} attendees) — cam=$cam_pref mic=$mic_pref lid=$lid mic_source=$mic_source"
  [[ -n "$elgato_hub" ]] && detail="${detail} elgato=launched"
  emit_ok "join" "$detail"
}

# ─── Dispatcher ─────────────────────────────────────────────────────────────
usage() {
  cat >&2 <<'EOF'
ops-voice — voice / call helper

usage:
  ops-voice phone    <number> [--json]
  ops-voice facetime <number-or-email> [--audio] [--json]
  ops-voice zoom     start
  ops-voice zoom     join     <meeting-id> [--pwd P] [--json]
  ops-voice zoom     schedule "<topic>" [--start ISO8601] [--duration MIN] [--json]
  ops-voice whatsapp-call <number> [--video] [--json]
  ops-voice meet     start [--json]
  ops-voice meet     join  <code-or-url> [--json]
  ops-voice join     [--at now|next|HH:MM] [--window MIN] [--dry-run] [--json]
  ops-voice twilio-call <to> <from> --twiml <URL> [--json]
  ops-voice twilio-sms  <to> <from> "<body>" [--json]
  ops-voice bland-call  <number> "<prompt>" [--json]

native channels (phone/facetime/zoom start|join, join) need no credentials.
twilio/bland/zoom-schedule resolve via env → keychain → preferences.json → doppler.

`join` reads today's calendar via gog, picks the current/next meeting (within
±10min by default), extracts the conference URL, applies a smart AV policy
(1-2 attendees → cam ON mic ON; 3-9 → cam ON mic MUTED; 10+ → both off),
detects MacBook lid state (open=internal mic, closed=external), and launches
Elgato Camera Hub when installed. Tag overrides in event description:
[cam:on|off] [mic:on|off|muted].
EOF
}

main() {
  # Strip a global --json flag if present anywhere.
  local args=()
  for a in "$@"; do
    case "$a" in
      --json) JSON_MODE=true ;;
      *) args+=("$a") ;;
    esac
  done
  set -- "${args[@]:-}"

  local cmd="${1:-}"; shift || true
  case "$cmd" in
    phone)          cmd_phone "$@" ;;
    facetime)       cmd_facetime "$@" ;;
    zoom)           cmd_zoom "$@" ;;
    whatsapp-call)  cmd_whatsapp_call "$@" ;;
    meet)           cmd_meet "$@" ;;
    join)           cmd_join "$@" ;;
    twilio-call)    cmd_twilio_call "$@" ;;
    twilio-sms)     cmd_twilio_sms "$@" ;;
    bland-call)     cmd_bland_call "$@" ;;
    -h|--help|"") usage; exit 0 ;;
    *) emit_err "unknown subcommand: $cmd"; usage; exit 2 ;;
  esac
}

main "$@"
