#!/usr/bin/env bash
set -euo pipefail

# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a
# separately parsed data-only .env.local can influence launch. Never source an
# environment file and never accept a command string from either file.

MODE=launch
case "${1:-}" in
  --stop)
    MODE=stop
    AGENT_NAME=${2:-}
    ;;
  --interaction)
    MODE=interaction
    AGENT_NAME=${2:-}
    ;;
  *) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;;
esac
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}

fail() {
  echo "ERROR: $*" >&2
  exit 64
}

hash_value() {
  printf '%s' "$1" | sha256sum | awk '{print $1}'
}

fail_env() {
  local code="$1"
  local key="$2"
  local value="$3"
  echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2
  exit 64
}

safe_agent_name() {
  [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
}

safe_policy_name() {
  [[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]
}

safe_path() {
  [[ "$1" == /* ]] || return 1
  [[ "$1" != *".."* ]] || return 1
  [[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]]
}

assert_private_regular_file() {
  local file="$1"
  [ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file"
  local mode
  mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file"
  (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file"
}

assert_managed_directory() {
  local directory="$1"
  [ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory"
  local mode
  mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
  (( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
}

assert_private_directory() {
  local directory="$1"
  assert_managed_directory "$directory"
  local mode
  mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
  (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
}

[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required"
safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME"
safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"

FLEET_DIR="$MOSAIC_HOME/fleet"
AGENT_ENV_DIR="$FLEET_DIR/agents"
assert_managed_directory "$MOSAIC_HOME"
assert_managed_directory "$FLEET_DIR"
assert_private_directory "$AGENT_ENV_DIR"

GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated"
LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local"

declare -A GENERATED_VALUES=()
declare -A LOCAL_VALUES=()
declare -A SEEN_KEYS=()

is_sensitive_key() {
  [[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]]
}

is_generated_key() {
  case "$1" in
    MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
    *) return 1 ;;
  esac
}

is_local_key() {
  case "$1" in
    MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;;
    *) return 1 ;;
  esac
}

validate_generated_value() {
  local key="$1"
  local value="$2"
  case "$key" in
    MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
    MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
    MOSAIC_AGENT_RUNTIME)
      case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
      ;;
    MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;;
    MOSAIC_AGENT_REASONING)
      case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac
      ;;
    MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;;
    MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;;
    MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;;
  esac
}

validate_local_value() {
  local key="$1"
  local value="$2"
  if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then
    [[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value"
  else
    safe_path "$value" || fail_env unsafe-path "$key" "$value"
  fi
}

load_environment_file() {
  local file="$1"
  local kind="$2"
  [ -e "$file" ] || {
    [ "$kind" = generated ] && fail_env missing-file '(generated)' "$file"
    return 0
  }
  assert_private_regular_file "$file"
  SEEN_KEYS=()

  local line key value
  while IFS= read -r line || [ -n "$line" ]; do
    [ -z "$line" ] && continue
    if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then
      fail_env malformed-line '(malformed)' "$line"
    fi
    key=${BASH_REMATCH[1]}
    value=${BASH_REMATCH[2]}
    [ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value"
    SEEN_KEYS[$key]=1
    is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value"

    if [ "$kind" = generated ]; then
      is_generated_key "$key" || fail_env unknown-key "$key" "$value"
      validate_generated_value "$key" "$value"
      GENERATED_VALUES[$key]=$value
    else
      is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value"
      is_local_key "$key" || fail_env unknown-key "$key" "$value"
      validate_local_value "$key" "$value"
      LOCAL_VALUES[$key]=$value
    fi
  done < "$file"
}

load_environment_file "$GENERATED_ENV" generated
for required_key in \
  MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
  MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
  [ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
done
load_environment_file "$LOCAL_ENV" local

[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
  fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"

MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run}
MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15}
MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-}
CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-}

if ! command -v tmux >/dev/null 2>&1; then
  echo "ERROR: tmux is required" >&2
  exit 69
fi

_tmux() {
  if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
    tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
  else
    tmux "$@"
  fi
}

assert_owned_tmux_server() {
  local owner_file="$MOSAIC_HOME/fleet/run/holder-owner"
  [ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing"
  local owner_mode
  owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable"
  (( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions"
  local owner
  owner=$(tr -d '\n' < "$owner_file")
  [[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed"
  _tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent"
  local environment expected
  environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable"
  expected=$(printf '%s\n' \
    "HOME=$HOME" \
    'PATH=/usr/bin:/bin' \
    "PWD=$HOME" \
    "MOSAIC_FLEET_OWNER=$owner" \
    'MOSAIC_TMUX_HOLDER=_holder' \
    "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
  [ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
    fail "tmux server ownership or environment validation failed"
}

# Validate exact server ownership before querying, cleaning, or creating any
# managed session. An unmanaged or contaminated named socket is never repaired.
assert_owned_tmux_server

if [ "$MODE" = interaction ]; then
  [ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi"
  [ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \
    fail "operator interaction service requires the pinned model"
  [ "$MOSAIC_AGENT_REASONING" = high ] || \
    fail "operator interaction service requires high reasoning"
  [ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \
    fail "operator interaction service requires the operator-interaction tool policy"
fi

if [ "$MODE" = stop ]; then
  _tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true
  exit 0
fi

if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
  echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
  exit 0
fi

# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
# that home. Derive the pane home from the canonical path when available so an
# inherited pane/session HOME cannot become runtime authority.
PANE_HOME=$HOME
case "$MOSAIC_HOME" in
  */.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
esac

_build_runtime_bin_prefix() {
  local candidates=()
  if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
  if command -v npm >/dev/null 2>&1; then
    local npm_prefix
    npm_prefix=$(npm config get prefix 2>/dev/null) || true
    if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
  fi
  candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")

  local prefix="" dir
  for dir in "${candidates[@]}"; do
    [ -d "$dir" ] || continue
    case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
  done
  printf '%s' "$prefix"
}

MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin

_ensure_claude_workdir_trusted() {
  local workdir="$1"
  local resolved
  resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
  local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
  claude_json="${claude_json:-$HOME/.claude.json}"
  command -v python3 >/dev/null 2>&1 || return 1
  MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
import json, os, sys, tempfile
cj = os.environ["MOSAIC_CJ"]
d = os.environ["MOSAIC_TRUST_DIR"]
try:
    data = json.load(open(cj)) if os.path.exists(cj) else {}
    if not isinstance(data, dict):
        data = {}
except Exception:
    sys.exit(2)
projects = data.setdefault("projects", {})
entry = projects.get(d)
if not isinstance(entry, dict):
    entry = {}
    projects[d] = entry
entry["hasTrustDialogAccepted"] = True
tmp_dir = os.path.dirname(cj) or "."
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
try:
    with os.fdopen(fd, "w") as f:
        json.dump(data, f, indent=2)
    os.replace(tmp, cj)
except Exception:
    try:
        os.unlink(tmp)
    except OSError:
        pass
    sys.exit(3)
PY
}

if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
  _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
    echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
fi

LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi

# The tmux holder owns a named server. Explicitly clear the pane environment
# so server/session variables cannot cross the launch boundary; retain only
# trusted bootstrap, generated, and approved local data as argv assignments.
LAUNCH_ENV=(
  /usr/bin/env
  -i
  "HOME=$PANE_HOME"
  "PATH=$PANE_PATH"
  "MOSAIC_HOME=$MOSAIC_HOME"
  "MOSAIC_AGENT_NAME=$AGENT_NAME"
  "MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
  "MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
  "MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
  "MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING"
  "MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY"
  "MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR"
  "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET"
  "MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR"
)

mkdir -p "$MOSAIC_AGENT_WORKDIR"
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
  "${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"

PANE_PID=""
for _retry in 1 2 3 4 5; do
  PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
  [ -n "$PANE_PID" ] && break
  sleep 0.2
done

_start_heartbeat_sidecar() {
  local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
  local hb_file="${run_dir}/${agent}.hb"
  mkdir -p "$run_dir"
  local sidecar_script
  sidecar_script=$(printf \
    'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \
    "$hb_file" "$pane_pid" "$interval")
  if command -v setsid >/dev/null 2>&1; then
    setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
  else
    bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
  fi
  disown $! 2>/dev/null || true
}

if [ -n "$PANE_PID" ]; then
  _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
    "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
    echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
else
  echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
fi
