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

RELEASE_URL="${RELEASE_URL:-https://res.qzhuli.com/plugins/qzhuli_openclaw.zip}"
OPENCLAW_BIN="${OPENCLAW_BIN:-openclaw}"
NPM_BIN="${NPM_BIN:-npm}"
TARGET_DIR="${TARGET_DIR:-}"
TARGET_DIR_FROM_ARG=0
OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}"
ENVIRONMENT="${ENVIRONMENT:-release}" # dev | release
BIND_POLL_INTERVAL_SEC="${BIND_POLL_INTERVAL_SEC:-5}"
BIND_MAX_ATTEMPTS="${BIND_MAX_ATTEMPTS:-120}"
PLUGIN_ID="qzhuli"
CHANNEL_ID="qzhuli"

usage() {
  cat <<'USAGE'
Usage:
  ./install.sh [options]

Options:
  --target-dir <path>         Use existing local plugin directory and skip release download/extract
  --release-url <url>         Release asset URL (.zip/.tgz/.tar.gz)
  --environment <dev|release> QZhuli environment (default: release)
  --help                      Show this help
USAGE
}

log() {
  printf "[QZhuli-install] %s\n" "$*"
}

warn() {
  printf "[QZhuli-install] WARN: %s\n" "$*"
}

err() {
  printf "[QZhuli-install] ERROR: %s\n" "$*" >&2
}

require_cmd() {
  local cmd="$1"
  if ! command -v "$cmd" >/dev/null 2>&1; then
    err "missing command: $cmd"
    exit 1
  fi
}

compute_file_md5() {
  local file_path="$1"
  if command -v md5sum >/dev/null 2>&1; then
    md5sum "$file_path" | awk '{print $1}'
    return 0
  fi
  if command -v md5 >/dev/null 2>&1; then
    md5 -q "$file_path"
    return 0
  fi
  return 1
}

install_plugin_local() {
  local plugin_path="$1"
  if [ ! -f "$plugin_path/openclaw.plugin.json" ]; then
    err "missing openclaw.plugin.json in: $plugin_path"
    exit 1
  fi
  local install_log="$TMP_DIR/openclaw-plugin-install.log"
  if "$OPENCLAW_BIN" plugins install "$plugin_path" 2>&1 | tee "$install_log"; then
    return 0
  fi

  if ! grep -Fq "plugins.allow: plugin not found: $PLUGIN_ID" "$install_log"; then
    err "openclaw plugins install failed"
    exit 1
  fi

  warn "detected plugins.allow validation race; recovering via local extension enable"
  local extension_dir="$HOME/.openclaw/extensions/$PLUGIN_ID"
  if [ ! -f "$extension_dir/openclaw.plugin.json" ]; then
    rm -rf "$extension_dir"
    mkdir -p "$extension_dir"
    cp -R "$plugin_path"/. "$extension_dir"/
    normalize_plugin_manifest "$extension_dir"
  fi
  "$OPENCLAW_BIN" plugins enable "$PLUGIN_ID"
}

normalize_plugin_manifest() {
  local plugin_path="$1"
  local normalize_result=""
  if ! normalize_result="$(PLUGIN_PATH="$plugin_path" TARGET_PLUGIN_ID="$PLUGIN_ID" TARGET_CHANNEL_ID="$CHANNEL_ID" node - <<'NODE'
const fs = require("fs");
const path = require("path");
const pluginPath = process.env.PLUGIN_PATH || "";
const targetPluginId = process.env.TARGET_PLUGIN_ID || "qzhuli";
const targetChannelId = process.env.TARGET_CHANNEL_ID || "qzhuli";
if (!pluginPath) process.exit(2);
const manifestPath = path.join(pluginPath, "openclaw.plugin.json");

let parsed;
try {
  parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
} catch {
  process.exit(2);
}

if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) process.exit(2);
const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
if (!id) process.exit(2);
if (id !== targetPluginId && id !== "imnut") {
  process.stderr.write(`unexpected plugin id in manifest: ${id}\n`);
  process.exit(3);
}

let changed = false;
if (id !== targetPluginId) {
  parsed.id = targetPluginId;
  changed = true;
}

if (!Array.isArray(parsed.channels) || parsed.channels.length !== 1 || parsed.channels[0] !== targetChannelId) {
  parsed.channels = [targetChannelId];
  changed = true;
}

if (changed) {
  fs.writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`);
  process.stdout.write("normalized");
} else {
  process.stdout.write("ok");
}
NODE
  )"; then
    err "failed to normalize plugin manifest in: $plugin_path/openclaw.plugin.json"
    exit 1
  fi
  if [ "$normalize_result" = "normalized" ]; then
    log "normalized legacy plugin manifest to id/channel: $PLUGIN_ID"
  fi
}

migrate_legacy_plugin_config() {
  local target_installed="$1"
  if [ ! -f "$OPENCLAW_CONFIG_PATH" ]; then
    return 0
  fi
  local migrate_result=""
  if ! migrate_result="$(OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" TARGET_PLUGIN_ID="$PLUGIN_ID" TARGET_INSTALLED="$target_installed" node - <<'NODE'
const fs = require("fs");
const configPath = process.env.OPENCLAW_CONFIG_PATH;
const targetPluginId = process.env.TARGET_PLUGIN_ID || "";
const targetInstalled = process.env.TARGET_INSTALLED === "1";
if (!configPath) process.exit(2);
if (!targetPluginId) process.exit(2);
const legacyPluginId = "imnut";

let raw = "";
try {
  raw = fs.readFileSync(configPath, "utf8");
} catch (e) {
  process.stderr.write(`failed to read config ${configPath}: ${e?.message || e}\n`);
  process.exit(2);
}

let cfg;
try {
  cfg = JSON.parse(raw);
} catch (e) {
  process.stderr.write(`invalid JSON in config ${configPath}: ${e?.message || e}\n`);
  process.exit(2);
}

if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
  process.stderr.write(`config root must be object: ${configPath}\n`);
  process.exit(2);
}

const plugins = cfg.plugins;
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) {
  process.stdout.write("noop");
  process.exit(0);
}

let changed = false;

let nextAllow = plugins.allow;
if (Array.isArray(plugins.allow)) {
  const seen = new Set();
  const deduped = [];
  for (const item of plugins.allow) {
    if (typeof item !== "string") {
      deduped.push(item);
      continue;
    }
    if (item === legacyPluginId || item === targetPluginId) {
      changed = true;
      if (!targetInstalled) {
        continue;
      }
      if (seen.has(targetPluginId)) {
        continue;
      }
      seen.add(targetPluginId);
      deduped.push(targetPluginId);
      continue;
    }
    const normalized = item;
    if (seen.has(normalized)) {
      changed = true;
      continue;
    }
    seen.add(normalized);
    deduped.push(normalized);
  }
  nextAllow = deduped;
}

let nextEntries = plugins.entries;
if (plugins.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries)) {
  if (Object.prototype.hasOwnProperty.call(plugins.entries, legacyPluginId)) {
    const existing = Object.prototype.hasOwnProperty.call(plugins.entries, targetPluginId)
      ? plugins.entries[targetPluginId]
      : undefined;
    nextEntries = { ...plugins.entries };
    if (typeof existing === "undefined") {
      nextEntries[targetPluginId] = nextEntries[legacyPluginId];
    }
    delete nextEntries[legacyPluginId];
    changed = true;
  }
}

let nextInstalls = plugins.installs;
if (plugins.installs && typeof plugins.installs === "object" && !Array.isArray(plugins.installs)) {
  if (Object.prototype.hasOwnProperty.call(plugins.installs, legacyPluginId)) {
    const existing = Object.prototype.hasOwnProperty.call(plugins.installs, targetPluginId)
      ? plugins.installs[targetPluginId]
      : undefined;
    nextInstalls = { ...plugins.installs };
    if (typeof existing === "undefined") {
      nextInstalls[targetPluginId] = nextInstalls[legacyPluginId];
    }
    delete nextInstalls[legacyPluginId];
    changed = true;
  }
}

if (!changed) {
  process.stdout.write("noop");
  process.exit(0);
}

cfg.plugins = { ...plugins, allow: nextAllow, entries: nextEntries, installs: nextInstalls };
fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
process.stdout.write("migrated");
NODE
  )"; then
    err "failed to pre-migrate plugin config in $OPENCLAW_CONFIG_PATH"
    exit 1
  fi
  if [ "$migrate_result" = "migrated" ]; then
    log "migrated plugin config aliases to: $PLUGIN_ID"
  fi
}

ensure_plugin_allowed() {
  if [ ! -f "$OPENCLAW_CONFIG_PATH" ]; then
    return 0
  fi
  local allow_result=""
  if ! allow_result="$(OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" TARGET_PLUGIN_ID="$PLUGIN_ID" node - <<'NODE'
const fs = require("fs");
const configPath = process.env.OPENCLAW_CONFIG_PATH;
const targetPluginId = process.env.TARGET_PLUGIN_ID || "qzhuli";
if (!configPath) process.exit(2);
let raw = "";
try {
  raw = fs.readFileSync(configPath, "utf8");
} catch {
  process.exit(2);
}
let cfg;
try {
  cfg = JSON.parse(raw);
} catch {
  process.exit(2);
}
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) process.exit(2);
cfg.plugins = cfg.plugins && typeof cfg.plugins === "object" && !Array.isArray(cfg.plugins) ? cfg.plugins : {};
if (!Array.isArray(cfg.plugins.allow)) {
  process.stdout.write("noop");
  process.exit(0);
}
if (cfg.plugins.allow.includes(targetPluginId)) {
  process.stdout.write("noop");
  process.exit(0);
}
cfg.plugins.allow = [...cfg.plugins.allow, targetPluginId];
fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
process.stdout.write("added");
NODE
  )"; then
    err "failed to update plugins.allow with $PLUGIN_ID in $OPENCLAW_CONFIG_PATH"
    exit 1
  fi
  if [ "$allow_result" = "added" ]; then
    log "added $PLUGIN_ID to plugins.allow"
  fi
}

is_plugin_installed() {
  "$OPENCLAW_BIN" plugins list 2>/dev/null | grep -Eiq "(^|[[:space:]])qzhuli([[:space:]]|$)"
}

expand_user_path() {
  local value="$1"
  if [ "$value" = "~" ]; then
    printf "%s" "$HOME"
    return
  fi
  case "$value" in
    "~/"*)
      printf "%s/%s" "$HOME" "${value#\~/}"
      ;;
    *)
      printf "%s" "$value"
      ;;
  esac
}

render_qr() {
  local payload="$1"
  if [ -n "${QRCODE_LIB_DIR:-}" ] && [ -f "$QRCODE_LIB_DIR/lib/main.js" ]; then
    if [ "$ENVIRONMENT" = "dev" ]; then
      log "render qr via qrcode lib dir: $QRCODE_LIB_DIR"
    fi
    if QR_PAYLOAD="$payload" QR_LIB_ROOT="$QRCODE_LIB_DIR" node - <<'NODE'
const path = require("path");
const payload = process.env.QR_PAYLOAD || "";
const root = process.env.QR_LIB_ROOT || "";
if (!payload || !root) process.exit(1);
let qr;
try {
  qr = require(path.join(root, "lib", "main.js"));
} catch {
  process.exit(1);
}
qr.generate(payload, { small: true });
NODE
    then
      return 0
    fi
    if [ "$ENVIRONMENT" = "dev" ]; then
      warn "failed render via qrcode lib dir, continue fallback"
    fi
  fi
  if command -v qrencode >/dev/null 2>&1; then
    qrencode -t ANSIUTF8 "$payload"
    return 0
  fi
  if command -v "$NPM_BIN" >/dev/null 2>&1; then
    local qr_tmp="$TMP_DIR/qrcode-npm"
    rm -rf "$qr_tmp"
    mkdir -p "$qr_tmp"
    if [ "$ENVIRONMENT" = "dev" ]; then
      log "render qr via npm fallback: installing qrcode into $qr_tmp"
    fi
    if "$NPM_BIN" --prefix "$qr_tmp" --silent install qrcode >/dev/null 2>&1; then
      if [ "$ENVIRONMENT" = "dev" ]; then
        log "render qr via npm qrcode package"
      fi
      if QR_PAYLOAD="$payload" QR_LIB_BASE="$qr_tmp" node - <<'NODE'
const payload = process.env.QR_PAYLOAD || "";
const base = process.env.QR_LIB_BASE || "";
if (!payload || !base) process.exit(1);
try {
  const QRCode = require(`${base}/node_modules/qrcode`);
  QRCode.toString(payload, { type: "terminal", small: true }, (err, out) => {
    if (err) process.exit(1);
    process.stdout.write(out || "");
  });
} catch {
  process.exit(1);
}
NODE
      then
        return 0
      fi
      if [ "$ENVIRONMENT" = "dev" ]; then
        warn "failed render via npm qrcode package"
      fi
    elif [ "$ENVIRONMENT" = "dev" ]; then
      warn "npm install qrcode failed"
    fi
  fi
  return 1
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --target-dir)
      TARGET_DIR="$2"
      TARGET_DIR_FROM_ARG=1
      shift 2
      ;;
    --release-url)
      RELEASE_URL="$2"
      shift 2
      ;;
    --environment)
      ENVIRONMENT="$2"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      err "unknown argument: $1"
      usage
      exit 1
      ;;
  esac
done

if [ "$ENVIRONMENT" != "dev" ] && [ "$ENVIRONMENT" != "release" ]; then
  err "environment must be dev or release"
  exit 1
fi

OPENCLAW_CONFIG_PATH="$(expand_user_path "$OPENCLAW_CONFIG_PATH")"
log "environment: $ENVIRONMENT"

require_cmd "$OPENCLAW_BIN"
require_cmd node
require_cmd curl
require_cmd tar
TMP_DIR="$(mktemp -d)"
cleanup() {
  rm -rf "$TMP_DIR"
}
trap cleanup EXIT

if [ -z "$TARGET_DIR" ]; then
  TARGET_DIR="$TMP_DIR/plugin"
else
  TARGET_DIR="$(expand_user_path "$TARGET_DIR")"
fi

QRCODE_LIB_DIR="$TARGET_DIR/vendor/qrcode-terminal"

if [ "$TARGET_DIR_FROM_ARG" -eq 1 ]; then
  log "using local plugin directory: $TARGET_DIR"
  if [ ! -f "$TARGET_DIR/openclaw.plugin.json" ]; then
    err "missing openclaw.plugin.json in target dir: $TARGET_DIR"
    exit 1
  fi
else
  log "downloading release asset"
  ASSET_PATH="$TMP_DIR/plugin.asset"
  curl -fsSL "$RELEASE_URL" -o "$ASSET_PATH"
  if asset_md5="$(compute_file_md5 "$ASSET_PATH")"; then
    log "downloaded asset md5: $asset_md5"
  else
    warn "cannot compute md5: missing md5sum/md5 command"
  fi

  log "extracting release asset"
  rm -rf "$TARGET_DIR"
  mkdir -p "$TARGET_DIR"
  mkdir -p "$TMP_DIR/extract"
  case "$RELEASE_URL" in
    *.zip)
      require_cmd unzip
      unzip -q "$ASSET_PATH" -d "$TMP_DIR/extract"
      ;;
    *.tgz|*.tar.gz)
      tar -xzf "$ASSET_PATH" -C "$TMP_DIR/extract"
      ;;
    *.tar)
      tar -xf "$ASSET_PATH" -C "$TMP_DIR/extract"
      ;;
    *)
      err "unsupported release asset extension: $RELEASE_URL"
      exit 1
      ;;
  esac

  if [ -f "$TMP_DIR/extract/package.json" ]; then
    cp -R "$TMP_DIR/extract"/. "$TARGET_DIR"/
  else
    EXTRACT_ROOT="$(find "$TMP_DIR/extract" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
    if [ -z "$EXTRACT_ROOT" ] || [ ! -f "$EXTRACT_ROOT/package.json" ]; then
      err "release asset missing plugin root/package.json"
      exit 1
    fi
    cp -R "$EXTRACT_ROOT"/. "$TARGET_DIR"/
  fi
fi

normalize_plugin_manifest "$TARGET_DIR"
preinstalled=0
if is_plugin_installed; then
  preinstalled=1
fi
migrate_legacy_plugin_config "$preinstalled"

if is_plugin_installed; then
  log "qzhuli plugin already installed, skip install and continue to QR bind"
else
  log "registering plugin with OpenClaw"
  install_plugin_local "$TARGET_DIR"
fi
"$OPENCLAW_BIN" plugins enable qzhuli >/dev/null 2>&1 || true
ensure_plugin_allowed

BIND_KEY="$(node -e 'process.stdout.write((globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`).replace(/-/g,""))')"
QR_PAYLOAD="$(node -e 'const key=process.argv[1];process.stdout.write(JSON.stringify({type:"imnut_bind",key,id:1}));' "$BIND_KEY")"
if [ "$ENVIRONMENT" = "dev" ]; then
  log "qrcode lib dir: $QRCODE_LIB_DIR"
  log "qrcode entry path: $QRCODE_LIB_DIR/lib/main.js"
fi
log "打开 QZhuli，扫描下面的二维码完成绑定"
if ! render_qr "$QR_PAYLOAD"; then
  warn "failed to render QR in terminal; copy payload manually:"
  printf "%s\n" "$QR_PAYLOAD"
fi
printf "bind_key: %s\n" "$BIND_KEY"

if [ "$ENVIRONMENT" = "release" ]; then
  BIND_HOST="client.qzhuli.com"
  IMNUT_HOST="im.qzhuli.com"
else
  BIND_HOST="test.client.qzhuli.com"
  IMNUT_HOST="test.im.qzhuli.com"
fi

log "waiting for bind result from app (max attempts: $BIND_MAX_ATTEMPTS)"
BOUND_JSON=""
LAST_BIND_RAW=""
attempt=1
while [ "$attempt" -le "$BIND_MAX_ATTEMPTS" ]; do
  raw="$(curl -fsS "https://${BIND_HOST}/aimachine/check_bind_status?bind_key=${BIND_KEY}" || true)"
  LAST_BIND_RAW="$raw"
  parsed="$(RAW_PAYLOAD="$raw" node - <<'NODE'
const raw = process.env.RAW_PAYLOAD || "";
if (!raw) {
  process.stdout.write("");
  process.exit(0);
}
try {
  const parsed = JSON.parse(raw);
  const data = (parsed && typeof parsed === "object" && parsed.data && typeof parsed.data === "object")
    ? parsed.data
    : parsed;
  const status = Number(data?.status || 0);
  const convId = String(data?.conv_id || data?.conversation_id || "").trim();
  const cid = String(data?.cid || "").trim();
  const token = String(data?.token || data?.bind_token || "").trim();
  if (status === 1 && convId && cid && token) {
    process.stdout.write(JSON.stringify({ convId, cid, token }));
  }
} catch {
  process.stdout.write("");
}
NODE
)"
  if [ -n "$parsed" ]; then
    if [ "$ENVIRONMENT" = "dev" ]; then
      log "qrcode scan result: $parsed"
    fi
    BOUND_JSON="$parsed"
    break
  fi
  attempt=$((attempt + 1))
  sleep "$BIND_POLL_INTERVAL_SEC"
done

if [ -z "$BOUND_JSON" ]; then
  if [ "$ENVIRONMENT" = "dev" ]; then
    warn "qrcode scan result timeout, last bind response: ${LAST_BIND_RAW:-<empty>}"
  fi
  err "bind timeout; re-run this installer and scan QR again"
  exit 1
fi

log "bind success; writing channels.${CHANNEL_ID} config"
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
CONFIG_INPUT_JSON="$BOUND_JSON" OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" IMNUT_HOST="$IMNUT_HOST" CHANNEL_ID="$CHANNEL_ID" node - <<'NODE'
const fs = require("fs");
const configPath = process.env.OPENCLAW_CONFIG_PATH;
const host = process.env.IMNUT_HOST;
const channelId = process.env.CHANNEL_ID || "qzhuli";
const bound = JSON.parse(process.env.CONFIG_INPUT_JSON || "{}");

let cfg = {};
try {
  if (fs.existsSync(configPath)) {
    const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
      cfg = parsed;
    }
  }
} catch {
  cfg = {};
}

cfg.channels = cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels) ? cfg.channels : {};
const existing = cfg.channels[channelId] && typeof cfg.channels[channelId] === "object" && !Array.isArray(cfg.channels[channelId])
  ? cfg.channels[channelId]
  : {};
const scanPayload = `wss://${host}/wss_openclaw?cid=${encodeURIComponent(bound.cid)}&token=${encodeURIComponent(bound.token)}&conv_id=${encodeURIComponent(bound.convId)}`;
cfg.channels[channelId] = {
  ...existing,
  enabled: true,
  baseUrl: `https://${host}`,
  pushPath: existing.pushPath || "/api/v1/conversations/push_message",
  wsUrl: `wss://${host}/wss_openclaw`,
  senderCid: bound.cid,
  wsToken: bound.token,
  inboundConvIds: [bound.convId],
  scanPayload,
};

fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
NODE

"$OPENCLAW_BIN" gateway restart >/dev/null 2>&1 || true
log "done"
log "plugin path: $TARGET_DIR"
log "config written: $OPENCLAW_CONFIG_PATH"
