#!/bin/bash
# ocsessnotify - post a message to the most recent opencode session.
#
# By default, discovers opencode servers on the LAN via mDNS and posts the
# message to the session with the most recent activity across all of them.
# Use --host/--port to target a specific server and --session-id to target a
# specific session instead of the most recent one.
#
# This tool only talks REST to opencode's HTTP API. It does no SSH, no
# terminal notifications - it posts a message to a session and exits.
#
# Posting is async by default (prompt_async, HTTP 204, reply generated in the
# background). --sync posts via the blocking /prompt endpoint and prints the
# reply text. --no-reply records the message without triggering an AI turn.
# --abort interrupts the target session's running turn. --list shows recent
# sessions. --json makes status output machine-readable.

VERSION=$(dpkg-query -W -f='${Version}' ocsessnotify 2>/dev/null)
[ -n "$VERSION" ] || VERSION=$(brew list --versions ocsessnotify 2>/dev/null | awk '{print $2}')
[ -n "$VERSION" ] || VERSION="unknown"

usage() {
  cat <<'EOF'
Usage: ocsessnotify [OPTIONS] [MESSAGE]

Post MESSAGE to the most recent opencode session on the LAN, or abort/list
sessions without a MESSAGE.

By default servers are discovered via mDNS and the session with the most
recent activity across all discovered servers is used.

Options:
  -h, --help           Show this help and exit
  -v, --version        Show version and exit
  -H, --host HOST      Server hostname/IP (default: 127.0.0.1)
  -p, --port PORT      Server port (default: 4096)
  -s, --session-id ID  Target this session ID instead of the most recent one
  -d, --directory DIR  Target the most recent session in this project directory
  -t, --timeout SECS   Per-request timeout in seconds (default: 3)
      --sync           Post via /prompt and print the reply text (blocking)
      --async          Post via prompt_async, do not wait for a reply (default)
      --no-reply       Record the message without triggering an AI turn
      --abort          Abort the target session's running turn
      --list [N]       List the N most recent sessions and exit (default: 5)
      --json           Machine-readable JSON status output

Examples:
  ocsessnotify "deploy complete"
  ocsessnotify --directory /srv/myproject "build finished"
  ocsessnotify --no-reply "housekeeping note"
  ocsessnotify --sync "what is the deploy status?"
  ocsessnotify --abort
  ocsessnotify --list --json
EOF
}

HOST=""
PORT=""
SESSION_ID=""
TIMEOUT=3
POST_TIMEOUT=1.5
SYNC_TIMEOUT=300
DIRECTORY=""
NO_REPLY=0
ABORT=0
SYNC=0
JSON=0
LIST_N=""

while [ "$#" -gt 0 ]; do
  case "$1" in
    -h|--help)
      usage
      exit 0
      ;;
    -v|--version|version)
      echo "ocsessnotify $VERSION"
      exit 0
      ;;
    -H|--host)
      HOST="$2"
      shift 2
      ;;
    -p|--port)
      PORT="$2"
      shift 2
      ;;
    -s|--session-id)
      SESSION_ID="$2"
      shift 2
      ;;
    -d|--directory)
      DIRECTORY="$2"
      shift 2
      ;;
    -t|--timeout)
      TIMEOUT="$2"
      shift 2
      ;;
    --sync)
      SYNC=1
      shift
      ;;
    --async)
      SYNC=0
      shift
      ;;
    --no-reply)
      NO_REPLY=1
      shift
      ;;
    --abort)
      ABORT=1
      shift
      ;;
    --list)
      case "$2" in
        ''|*[!0-9]*)
          LIST_N=5
          shift
          ;;
        *)
          LIST_N="$2"
          shift 2
          ;;
      esac
      ;;
    --json)
      JSON=1
      shift
      ;;
    -*)
      echo "Error: unknown option '$1'" >&2
      usage >&2
      exit 1
      ;;
    *)
      break
      ;;
  esac
done

MESSAGE="$*"
if [ -z "$MESSAGE" ] && [ "$ABORT" -eq 0 ] && [ -z "$LIST_N" ]; then
  echo "Error: no message given" >&2
  usage >&2
  exit 1
fi

command -v curl >/dev/null 2>&1 || { echo "Error: curl not found" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "Error: jq not found" >&2; exit 1; }

# discover_servers - emit "HOST<TAB>PORT" lines for opencode-* HTTP services.
# avahi-browse -p is CSV-ish: =;iface;proto;name;type;domain;host;address;port;txt
# dns-sd -Z is the macOS/Bonjour equivalent: PTR lines name the service, the
# matching SRV line carries the target hostname and port. mDNS hostnames such
# as "opencode.local." resolve natively via the system resolver.
discover_servers() {
  if command -v avahi-browse >/dev/null 2>&1; then
    avahi-browse -r -t -p _http._tcp 2>/dev/null | awk -F';' '
      $1 == "=" && $4 ~ /^opencode-/ && NF >= 9 {
        print $8 "\t" $9
      }' | sort -t$'\t' -k2,2 -k1,1 | awk -F'\t' '!seen[$2]++'
    return
  fi
  if command -v dns-sd >/dev/null 2>&1; then
    local tmp pid n
    tmp=$(mktemp)
    dns-sd -Z _http._tcp local. >"$tmp" 2>&1 & pid=$!
    n=0
    while kill -0 "$pid" 2>/dev/null && [ "$n" -lt 10 ] &&
        ! grep -qE '^opencode-[^[:space:]]+[[:space:]]+SRV[[:space:]]' "$tmp"; do
      sleep 0.1
      n=$((n + 1))
    done
    kill "$pid" 2>/dev/null
    wait "$pid" 2>/dev/null
    awk '
      $2 == "PTR" && $3 ~ /^opencode-/ { inst[$3] = 1 }
      $2 == "SRV" && $1 in inst { print $6 "\t" $5 }
    ' "$tmp" | sort -t$'\t' -k2,2 -k1,1 | awk -F'\t' '!seen[$2]++'
    rm -f "$tmp"
    return
  fi
  echo "Error: neither avahi-browse nor dns-sd found" >&2
  return 1
}

# most_recent_session HOST PORT - emit "UPDATED<TAB>ID<TAB>DIR<TAB>HOST<TAB>PORT"
# for the most recent session on the server. The /session endpoint is scoped to
# a single project (the server's own launch directory unless a directory is
# given), so finding the most recent session across every project requires a
# per-project scan. Each project is queried in parallel for its most recent
# session; the caller picks the newest by updated.
most_recent_session() {
  local host="$1" port="$2" projects cwd tmp
  projects=$(curl -s --max-time "$TIMEOUT" "http://${host}:${port}/project")
  cwd=$(curl -s --max-time "$TIMEOUT" "http://${host}:${port}/path" |
    jq -r '.directory // empty')
  tmp=$(mktemp)
  while IFS= read -r wt; do
    [ -n "$wt" ] || continue
    enc=$(printf '%s' "$wt" | jq -sRr '@uri')
    ( curl -s --max-time "$TIMEOUT" \
        "http://${host}:${port}/session?order=desc&limit=1&directory=${enc}" |
      jq -r --arg host "$host" --arg port "$port" \
        '.[] | select(. != null) | "\(.time.updated)\t\(.id)\t\(.directory)\t\($host)\t\($port)"' ) \
      >>"$tmp" &
  done < <({
    echo "$projects" | jq -r '.[].worktree // empty'
    echo "$cwd"
  } | sort -u)
  wait
  cat "$tmp"
  rm -f "$tmp"
}

# scoped_session_lines HOST PORT DIR [LIMIT] - emit the LIMIT most recent
# sessions within DIR, same tab-separated line format as most_recent_session.
scoped_session_lines() {
  local host="$1" port="$2" dir="$3" limit="${4:-1}"
  enc=$(printf '%s' "$dir" | jq -sRr '@uri')
  curl -s --max-time "$TIMEOUT" \
    "http://${host}:${port}/session?order=desc&limit=${limit}&directory=${enc}" |
    jq -r --arg host "$host" --arg port "$port" \
      '.[] | select(. != null) | "\(.time.updated)\t\(.id)\t\(.directory)\t\($host)\t\($port)"'
}

# candidate_lines HOST PORT [LIMIT] - recent session lines for one server,
# scoped to DIRECTORY when set, otherwise the per-project scan.
candidate_lines() {
  local host="$1" port="$2" limit="${3:-1}"
  if [ -n "$DIRECTORY" ]; then
    scoped_session_lines "$host" "$port" "$DIRECTORY" "$limit"
  else
    most_recent_session "$host" "$port"
  fi
}

# session_exists HOST PORT ID - true if the server knows this session.
session_exists() {
  local host="$1" port="$2" id="$3"
  curl -s --max-time "$TIMEOUT" "http://${host}:${port}/session/${id}" |
    jq -e '.id != null' >/dev/null 2>&1
}

# post HOST PORT ID MESSAGE - send MESSAGE to the session.
#
# Async mode posts to prompt_async (HTTP 204) and returns immediately; the
# session's agent generates the reply in the background. Sync mode posts to
# the blocking /prompt endpoint, waits for the turn to finish and prints the
# reply text. --no-reply sets noReply:true so no turn is triggered at all.
post() {
  local host="$1" port="$2" id="$3" message="$4" noReply payload out rc reply
  if [ "$NO_REPLY" -eq 1 ]; then noReply=true; else noReply=false; fi
  payload=$(jq -cn --argjson nr "$noReply" --arg text "$message" \
    '{noReply:$nr,parts:[{type:"text",text:$text}]}')
  if [ "$SYNC" -eq 1 ]; then
    out=$(curl -s --max-time "$SYNC_TIMEOUT" \
      -X POST "http://${host}:${port}/session/${id}/message" \
      -H "Content-Type: application/json" -d "$payload")
    rc=$?
    if [ "$rc" -eq 0 ]; then
      reply=$(printf '%s' "$out" |
        jq -r '[.parts[]? | select(.type == "text") | .text] | join("\n")')
      if [ "$JSON" -eq 1 ]; then
        jq -nc --arg s "$id" --arg h "$host" --arg p "$port" --arg r "$reply" \
          '{session:$s,host:$h,port:$p,status:"replied",reply:$r}'
      else
        printf '%s' "$reply"
      fi
      return 0
    fi
    echo "Error: POST to ${host}:${port} session ${id} failed (curl exit ${rc})" >&2
    return 1
  fi
  out=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$POST_TIMEOUT" \
    -X POST "http://${host}:${port}/session/${id}/prompt_async" \
    -H "Content-Type: application/json" -d "$payload" 2>/dev/null)
  rc=$?
  if [ "$rc" -eq 0 ] && [ "${out#2}" != "$out" ]; then
    if [ "$JSON" -eq 1 ]; then
      jq -nc --arg s "$id" --arg h "$host" --arg p "$port" \
        '{session:$s,host:$h,port:$p,status:"posted"}'
    else
      echo "Posted to session ${id} on ${host}:${port}"
    fi
    return 0
  fi
  echo "Error: POST to ${host}:${port} session ${id} failed (curl exit ${rc}, HTTP ${out})" >&2
  return 1
}

# abort_session HOST PORT ID - interrupt the session's running turn.
abort_session() {
  local host="$1" port="$2" id="$3" out rc
  out=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$POST_TIMEOUT" \
    -X POST "http://${host}:${port}/session/${id}/abort")
  rc=$?
  if [ "$rc" -eq 0 ] && [ "${out#2}" != "$out" ]; then
    if [ "$JSON" -eq 1 ]; then
      jq -nc --arg s "$id" --arg h "$host" --arg p "$port" \
        '{session:$s,host:$h,port:$p,status:"aborted"}'
    else
      echo "Aborted session ${id} on ${host}:${port}"
    fi
    return 0
  fi
  echo "Error: abort of ${host}:${port} session ${id} failed (curl exit ${rc}, HTTP ${out})" >&2
  return 1
}

# print_list LINES - render sorted "UPDATED<TAB>ID<TAB>DIR<TAB>HOST<TAB>PORT"
# lines as a table or JSON array.
print_list() {
  if [ "$JSON" -eq 1 ]; then
    printf '%s\n' "$1" |
      jq -Rr 'select(length > 0) | split("\t") as $f |
        {session:$f[1],directory:$f[2],updated:($f[0] | tonumber),host:$f[3],port:($f[4] | tonumber)}' |
      jq -s '.'
  else
    printf '%s\n' "$1" | awk -F'\t' '{printf "%s\t%s\t%s\n",$2,$3,$1}'
  fi
}

TARGET_HOST="${HOST:-127.0.0.1}"
TARGET_PORT="${PORT:-4096}"

if [ -z "$HOST" ] && [ -z "$PORT" ]; then
  # mDNS discovery mode.
  SERVERS=$(discover_servers) || exit 1
  if [ -z "$SERVERS" ]; then
    echo "Error: no opencode servers discovered via mDNS" >&2
    exit 1
  fi
  if [ -n "$SESSION_ID" ]; then
    # Find which discovered server hosts the requested session.
    while IFS=$'\t' read -r sh sp; do
      [ -n "$sh" ] || continue
      if session_exists "$sh" "$sp" "$SESSION_ID"; then
        TARGET_HOST="$sh"
        TARGET_PORT="$sp"
        FOUND=1
        break
      fi
    done <<<"$SERVERS"
    if [ -z "${FOUND:-}" ]; then
      echo "Error: session ${SESSION_ID} not found on any discovered server" >&2
      exit 1
    fi
  elif [ -n "$LIST_N" ]; then
    # List mode: gather recent sessions across every discovered server.
    ALL=""
    while IFS=$'\t' read -r sh sp; do
      [ -n "$sh" ] || continue
      ALL="${ALL}
$(candidate_lines "$sh" "$sp" "$LIST_N")"
    done <<<"$SERVERS"
    ALL=$(printf '%s\n' "$ALL" | sed '/^[[:space:]]*$/d' |
      sort -t$'\t' -k1,1 -rn | head -n "$LIST_N")
    if [ -z "$ALL" ]; then
      echo "Error: no sessions found on any discovered server" >&2
      exit 1
    fi
    print_list "$ALL"
    exit 0
  else
    # Most recent session across all discovered servers.
    BEST=""
    while IFS=$'\t' read -r sh sp; do
      [ -n "$sh" ] || continue
      BEST="${BEST}
$(candidate_lines "$sh" "$sp")"
    done <<<"$SERVERS"
    BEST=$(printf '%s\n' "$BEST" | sed '/^[[:space:]]*$/d' |
      sort -t$'\t' -k1,1 -rn | head -1)
    if [ -z "$BEST" ]; then
      echo "Error: no sessions found on any discovered server" >&2
      exit 1
    fi
    TARGET_HOST=$(printf '%s\n' "$BEST" | cut -f4)
    TARGET_PORT=$(printf '%s\n' "$BEST" | cut -f5)
    SESSION_ID=$(printf '%s\n' "$BEST" | cut -f2)
  fi
else
  # Explicit host/port: session must be given or derived from that server.
  if [ -n "$LIST_N" ]; then
    ALL=$(candidate_lines "$TARGET_HOST" "$TARGET_PORT" "$LIST_N" |
      sort -t$'\t' -k1,1 -rn | head -n "$LIST_N")
    if [ -z "$ALL" ]; then
      echo "Error: no sessions found on ${TARGET_HOST}:${TARGET_PORT}" >&2
      exit 1
    fi
    print_list "$ALL"
    exit 0
  fi
  if [ -z "$SESSION_ID" ]; then
    BEST=$(candidate_lines "$TARGET_HOST" "$TARGET_PORT" |
      sort -t$'\t' -k1,1 -rn | head -1)
    if [ -z "$BEST" ]; then
      echo "Error: no sessions found on ${TARGET_HOST}:${TARGET_PORT}" >&2
      exit 1
    fi
    SESSION_ID=$(printf '%s\n' "$BEST" | cut -f2)
  fi
fi

if [ "$ABORT" -eq 1 ]; then
  abort_session "$TARGET_HOST" "$TARGET_PORT" "$SESSION_ID"
  exit $?
fi

post "$TARGET_HOST" "$TARGET_PORT" "$SESSION_ID" "$MESSAGE"
