#!/bin/bash
# Moonling complete local-data cleanup registry (Issue #328).
#
# This is the sole registry for the App dangerous-zone flow and the standalone
# uninstaller. Keep all removable targets in the arrays below. In particular,
# do not add a second list of paths inside either Application Support root.

set -u
export LC_ALL=C

RESULT_VERSION=1
TEST_MODE="${MOONLING_REMOVAL_TEST_MODE:-}"

die() {
  /bin/echo "卸载月狸：$1" >&2
  exit 64
}

validate_home() {
  local configured_home="${HOME:-}"
  case "$configured_home" in
    ""|/|*..*|[^/]* ) die "HOME 必须是安全的绝对用户目录。" ;;
  esac

  enforce_home_channel "$configured_home"

  [[ -d "$configured_home" ]] || die "HOME 目录不存在。"
  HOME="$(cd "$configured_home" && /bin/pwd -P)" || die "无法解析 HOME。"
  case "$HOME" in
    ""|/|*..*|[^/]* ) die "HOME 必须是安全的绝对用户目录。" ;;
  esac
  # Check the canonical path too: a temporary symlink must not escape into a
  # real user directory, and a production symlink must not bypass the test-only
  # temporary-HOME boundary.
  enforce_home_channel "$HOME"
  export HOME
}

is_temporary_home() {
  local candidate="$1"
  [[ "$candidate" == /tmp || "$candidate" == /tmp/* || "$candidate" == /private/tmp || "$candidate" == /private/tmp/* || "$candidate" == /var/folders || "$candidate" == /var/folders/* || "$candidate" == /private/var/folders || "$candidate" == /private/var/folders/* ]]
}

enforce_home_channel() {
  local candidate="$1"
  if [[ "$TEST_MODE" == "1" ]]; then
    is_temporary_home "$candidate" || die "受控测试必须使用临时 HOME。"
  elif is_temporary_home "$candidate"; then
    die "临时 HOME 仅可用于受控测试。"
  fi
}

validate_test_executable() {
  local executable="$1"
  local executable_dir
  local canonical_executable
  case "$executable" in
    ""|/|*..*|[^/]* ) die "测试工具必须是安全的绝对可执行路径。" ;;
  esac
  [[ -f "$executable" && ! -L "$executable" && -x "$executable" ]] || die "测试工具必须是普通可执行文件。"
  executable_dir="$(cd "$(/usr/bin/dirname "$executable")" && /bin/pwd -P)" || die "无法解析测试工具。"
  canonical_executable="$executable_dir/$(/usr/bin/basename "$executable")"
  [[ "$canonical_executable" == "$HOME"/* ]] || die "测试工具必须位于临时 HOME 内。"
}

configure_tools() {
  local configured_defaults="${MOONLING_REMOVAL_DEFAULTS:-}"
  local configured_security="${MOONLING_REMOVAL_SECURITY:-}"

  if [[ "$TEST_MODE" == "1" ]]; then
    [[ -n "$configured_defaults" && -n "$configured_security" ]] \
      || die "受控测试必须提供 defaults 和 security 替身。"
    DEFAULTS_BIN="$configured_defaults"
    SECURITY_BIN="$configured_security"
    validate_test_executable "$DEFAULTS_BIN"
    validate_test_executable "$SECURITY_BIN"
  else
    [[ -z "$configured_defaults" && -z "$configured_security" ]] \
      || die "工具覆盖只允许用于受控测试。"
    DEFAULTS_BIN=/usr/bin/defaults
    SECURITY_BIN=/usr/bin/security
  fi
}

validate_home
configure_tools

# Filesystem roots are intentionally whole roots. They include databases and
# WAL/SHM files, queues, JSON state, file-backed credentials, bridge sockets,
# historical dev-host artifacts, and ChromeExtension copies.
SUPPORT_GA="$HOME/Library/Application Support/Focus Reader"
SUPPORT_BETA="$HOME/Library/Application Support/Focus Reader Beta"
CACHE_GA="$HOME/Library/Caches/Focus Reader"
CACHE_BETA="$HOME/Library/Caches/Focus Reader Beta"
CHROME_MANIFEST="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.focusreader.chrome_bridge.json"

PREFERENCE_GA="com.wanli.focus-reader"
PREFERENCE_BETA="com.wanli.focus-reader.beta"

TARGET_IDS=(
  "support-ga"
  "support-beta"
  "cache-ga"
  "cache-beta"
  "preferences-ga"
  "preferences-beta"
  "chrome-native-host"
  "keychain-beta-invite"
  "keychain-stripe-license"
  "keychain-device-identity"
  "keychain-anthropic-label"
  "keychain-beta-invite-label"
  "keychain-beta-service-label"
)
TARGET_TITLES=(
  "正式版应用支持数据"
  "Beta 应用支持数据"
  "正式版缓存"
  "Beta 缓存"
  "正式版偏好设置"
  "Beta 偏好设置"
  "Chrome 原生消息清单"
  "历史 Beta 邀请凭据"
  "历史 Stripe 许可证凭据"
  "历史设备身份凭据"
  "历史 Anthropic 凭据标签"
  "历史 Beta 邀请标签"
  "历史 Beta 服务标签"
)
TARGET_DETAILS=(
  "~/Library/Application Support/Focus Reader"
  "~/Library/Application Support/Focus Reader Beta"
  "~/Library/Caches/Focus Reader"
  "~/Library/Caches/Focus Reader Beta"
  "com.wanli.focus-reader"
  "com.wanli.focus-reader.beta"
  "com.focusreader.chrome_bridge.json"
  "com.velez.focus-reader.beta-invite / public-beta（大小不可读取）"
  "com.velez.focus-reader.stripe-license / stripe-license（大小不可读取）"
  "com.velez.focus-reader.device-identity / open-beta-device（大小不可读取）"
  "FocusReader Anthropic API Key（大小不可读取）"
  "FocusReader Beta 0.1 Invite（大小不可读取）"
  "com.velez.focus-reader.beta-invite（大小不可读取）"
)
TARGET_KINDS=(
  "path"
  "path"
  "path"
  "path"
  "preferences"
  "preferences"
  "path"
  "service-account"
  "service-account"
  "service-account"
  "label"
  "label"
  "label"
)
TARGET_VALUES=(
  "$SUPPORT_GA"
  "$SUPPORT_BETA"
  "$CACHE_GA"
  "$CACHE_BETA"
  "$PREFERENCE_GA"
  "$PREFERENCE_BETA"
  "$CHROME_MANIFEST"
  "com.velez.focus-reader.beta-invite|public-beta"
  "com.velez.focus-reader.stripe-license|stripe-license"
  "com.velez.focus-reader.device-identity|open-beta-device"
  "FocusReader Anthropic API Key"
  "FocusReader Beta 0.1 Invite"
  "com.velez.focus-reader.beta-invite"
)

# Lifecycle state only: each marker lives inside its already-registered
# Application Support root and is removed by that root's final deletion.
REMOVAL_SENTINEL_NAME=".moonling-removal-in-progress"
REMOVAL_SENTINEL_GRACE_SECONDS="0.25"

is_allowed_path_target() {
  local candidate="$1"
  local index
  case "$candidate" in
    ""|/|*..*|[^/]* ) return 1 ;;
  esac
  for index in "${!TARGET_IDS[@]}"; do
    [[ "${TARGET_KINDS[$index]}" == path && "$candidate" == "${TARGET_VALUES[$index]}" ]] && return 0
  done
  return 1
}

target_index_for_id() {
  local wanted_id="$1"
  local index
  for index in "${!TARGET_IDS[@]}"; do
    if [[ "${TARGET_IDS[$index]}" == "$wanted_id" ]]; then
      /bin/echo "$index"
      return 0
    fi
  done
  return 1
}

is_support_root_target() {
  local candidate="$1"
  [[ "$candidate" == "$SUPPORT_GA" || "$candidate" == "$SUPPORT_BETA" ]]
}

support_removal_sentinel() {
  /bin/echo "$1/$REMOVAL_SENTINEL_NAME"
}

create_support_removal_sentinel() {
  local support_root="$1"
  local sentinel

  is_support_root_target "$support_root" || { /bin/echo failed; return; }

  # Never traverse an existing link or non-directory. They remain exact root
  # targets and are removed only during the final Support-root phase.
  if [[ -L "$support_root" || ( -e "$support_root" && ! -d "$support_root" ) ]]; then
    /bin/echo skipped
    return
  fi

  if [[ ! -d "$support_root" ]]; then
    /bin/mkdir -p -- "$support_root" >/dev/null 2>&1 || { /bin/echo failed; return; }
  fi
  [[ -d "$support_root" && ! -L "$support_root" ]] || { /bin/echo failed; return; }

  sentinel="$(support_removal_sentinel "$support_root")"
  # An existing marker already requests host quiescence. Do not follow or
  # rewrite a symlink at the marker pathname.
  if [[ -e "$sentinel" || -L "$sentinel" ]]; then
    /bin/echo ready
    return
  fi

  /bin/mkdir -- "$sentinel" >/dev/null 2>&1 && /bin/echo ready || /bin/echo failed
}

preference_plist_path() {
  /bin/echo "$HOME/Library/Preferences/$1.plist"
}

path_size_bytes() {
  local path="$1"
  local kibibytes
  if [[ ! -e "$path" && ! -L "$path" ]]; then
    /bin/echo "0"
    return 0
  fi
  kibibytes="$(/usr/bin/du -sk "$path" 2>/dev/null | /usr/bin/awk '{print $1}')" || return 1
  case "$kibibytes" in
    ""|*[!0-9]* ) return 1 ;;
  esac
  /bin/echo "$((kibibytes * 1024))"
}

emit_row() {
  local separator="$1"
  local index="$2"
  local byte_count="$3"
  local status="$4"
  /usr/bin/printf '%s{"id":"%s","title":"%s","detail":"%s","byteCount":%s,"status":"%s"}' \
    "$separator" "${TARGET_IDS[$index]}" "${TARGET_TITLES[$index]}" \
    "${TARGET_DETAILS[$index]}" "$byte_count" "$status"
}

emit_result() {
  local mode="$1"
  local success="$2"
  local total_bytes="$3"
  local index
  local separator=""
  /usr/bin/printf '{"version":%s,"mode":"%s","success":%s,"totalByteCount":%s,"categories":[' \
    "$RESULT_VERSION" "$mode" "$success" "$total_bytes"
  for index in "${!TARGET_IDS[@]}"; do
    emit_row "$separator" "$index" "${RESULT_BYTES[$index]}" "${RESULT_STATUSES[$index]}"
    separator=","
  done
  /usr/bin/printf ']}\n'
}

collect_inventory() {
  local index
  local kind
  local value
  local path
  local bytes
  local total_bytes=0
  local success=true

  RESULT_BYTES=()
  RESULT_STATUSES=()
  for index in "${!TARGET_IDS[@]}"; do
    kind="${TARGET_KINDS[$index]}"
    value="${TARGET_VALUES[$index]}"
    case "$kind" in
      path)
        if ! is_allowed_path_target "$value"; then
          RESULT_BYTES[$index]=null
          RESULT_STATUSES[$index]=failed
          success=false
        elif bytes="$(path_size_bytes "$value")"; then
          RESULT_BYTES[$index]="$bytes"
          if [[ -e "$value" || -L "$value" ]]; then
            RESULT_STATUSES[$index]=present
          else
            RESULT_STATUSES[$index]=missing
          fi
          total_bytes=$((total_bytes + bytes))
        else
          RESULT_BYTES[$index]=null
          RESULT_STATUSES[$index]=failed
          success=false
        fi
        ;;
      preferences)
        path="$(preference_plist_path "$value")"
        if bytes="$(path_size_bytes "$path")"; then
          RESULT_BYTES[$index]="$bytes"
          if [[ -e "$path" || -L "$path" ]]; then
            RESULT_STATUSES[$index]=present
          else
            RESULT_STATUSES[$index]=missing
          fi
          total_bytes=$((total_bytes + bytes))
        else
          RESULT_BYTES[$index]=null
          RESULT_STATUSES[$index]=failed
          success=false
        fi
        ;;
      service-account|label)
        # Inventory deliberately does not invoke security: probing a historical
        # Keychain item can itself trigger an authorization prompt.
        RESULT_BYTES[$index]=null
        RESULT_STATUSES[$index]=size_unavailable
        ;;
    esac
  done
  INVENTORY_SUCCESS="$success"
  INVENTORY_TOTAL_BYTES="$total_bytes"
  [[ "$success" == true ]]
}

run_inventory() {
  local status=0
  collect_inventory || status=$?
  emit_result inventory "$INVENTORY_SUCCESS" "$INVENTORY_TOTAL_BYTES"
  return "$status"
}

run_human_inventory() {
  local status=0
  local index
  collect_inventory || status=$?
  /bin/echo "月狸将清理以下本机项目："
  for index in "${!TARGET_IDS[@]}"; do
    if [[ "${RESULT_BYTES[$index]}" == null ]]; then
      /usr/bin/printf '• %s：%s（大小不可读取）\n' "${TARGET_TITLES[$index]}" "${TARGET_DETAILS[$index]}"
    else
      /usr/bin/printf '• %s：%s（%s bytes）\n' \
        "${TARGET_TITLES[$index]}" "${TARGET_DETAILS[$index]}" "${RESULT_BYTES[$index]}"
    fi
  done
  /usr/bin/printf '合计文件体积：%s bytes\n' "$INVENTORY_TOTAL_BYTES"
  return "$status"
}

purge_path_target() {
  local target="$1"
  if ! is_allowed_path_target "$target"; then
    /bin/echo failed
    return
  fi
  if [[ ! -e "$target" && ! -L "$target" ]]; then
    /bin/echo missing
    return
  fi
  /bin/rm -rf -- "$target" >/dev/null 2>&1
  if [[ -e "$target" || -L "$target" ]]; then
    /bin/echo failed
  else
    /bin/echo removed
  fi
}

purge_preference_domain() {
  local domain="$1"
  local plist_path
  plist_path="$(preference_plist_path "$domain")"
  if "$DEFAULTS_BIN" read "$domain" >/dev/null 2>&1; then
    if "$DEFAULTS_BIN" delete "$domain" >/dev/null 2>&1; then
      /bin/echo removed
    else
      /bin/echo failed
    fi
  elif [[ ! -e "$plist_path" && ! -L "$plist_path" ]]; then
    /bin/echo missing
  else
    # A physical plist with an unreadable domain is not an idempotent absence.
    /bin/echo failed
  fi
}

purge_keychain_service_account() {
  local service="$1"
  local account="$2"
  local exit_status
  "$SECURITY_BIN" delete-generic-password -s "$service" -a "$account" >/dev/null 2>&1
  exit_status=$?
  if [[ "$exit_status" == 0 ]]; then
    /bin/echo removed
  elif [[ "$exit_status" == 44 ]]; then
    # errSecItemNotFound (-25300) is truncated by the shell to exit status 44.
    /bin/echo missing
  else
    /bin/echo failed
  fi
}

purge_keychain_label() {
  local label="$1"
  local exit_status
  local removed_count=0
  local maximum_matches=32
  while [[ "$removed_count" -lt "$maximum_matches" ]]; do
    "$SECURITY_BIN" delete-generic-password -l "$label" >/dev/null 2>&1
    exit_status=$?
    if [[ "$exit_status" == 0 ]]; then
      removed_count=$((removed_count + 1))
    elif [[ "$exit_status" == 44 ]]; then
      if [[ "$removed_count" == 0 ]]; then
        /bin/echo missing
      else
        /bin/echo removed
      fi
      return
    else
      /bin/echo failed
      return
    fi
  done
  # A 32nd match is valid. Probe exactly once more for the required not-found
  # terminator; repeated success is a malformed tool/result and fails closed.
  "$SECURITY_BIN" delete-generic-password -l "$label" >/dev/null 2>&1
  exit_status=$?
  if [[ "$exit_status" == 44 ]]; then
    /bin/echo removed
  else
    /bin/echo failed
  fi
}

initialize_purge_results() {
  local index
  RESULT_BYTES=()
  RESULT_STATUSES=()
  for index in "${!TARGET_IDS[@]}"; do
    case "${TARGET_KINDS[$index]}" in
      service-account|label) RESULT_BYTES[$index]=null ;;
      *) RESULT_BYTES[$index]=0 ;;
    esac
    # A fail-closed early return reports every later category as incomplete.
    RESULT_STATUSES[$index]=failed
  done
}

purge_target_at_index() {
  local index="$1"
  local kind="${TARGET_KINDS[$index]}"
  local value="${TARGET_VALUES[$index]}"
  local service
  local account
  local status

  case "$kind" in
    path)
      status="$(purge_path_target "$value")"
      RESULT_BYTES[$index]=0
      ;;
    preferences)
      status="$(purge_preference_domain "$value")"
      RESULT_BYTES[$index]=0
      ;;
    service-account)
      service="${value%%|*}"
      account="${value#*|}"
      status="$(purge_keychain_service_account "$service" "$account")"
      RESULT_BYTES[$index]=null
      ;;
    label)
      status="$(purge_keychain_label "$value")"
      RESULT_BYTES[$index]=null
      ;;
    *)
      status=failed
      RESULT_BYTES[$index]=null
      ;;
  esac
  RESULT_STATUSES[$index]="$status"
}

final_verify_purge_paths() {
  local index
  local kind
  local value
  local plist_path

  for index in "${!TARGET_IDS[@]}"; do
    kind="${TARGET_KINDS[$index]}"
    value="${TARGET_VALUES[$index]}"
    case "$kind" in
      path)
        if [[ -e "$value" || -L "$value" ]]; then
          RESULT_STATUSES[$index]=failed
        fi
        ;;
      preferences)
        plist_path="$(preference_plist_path "$value")"
        if [[ -e "$plist_path" || -L "$plist_path" ]] \
          || "$DEFAULTS_BIN" read "$value" >/dev/null 2>&1; then
          RESULT_STATUSES[$index]=failed
        fi
        ;;
    esac
  done
}

purge_results_succeeded() {
  local index
  for index in "${!TARGET_IDS[@]}"; do
    [[ "${RESULT_STATUSES[$index]}" != failed ]] || return 1
  done
  return 0
}

run_purge() {
  local index
  local manifest_index
  local support_ga_index
  local support_beta_index
  local sentinel_status

  initialize_purge_results
  manifest_index="$(target_index_for_id chrome-native-host)" || { emit_result purge false 0; return 1; }
  support_ga_index="$(target_index_for_id support-ga)" || { emit_result purge false 0; return 1; }
  support_beta_index="$(target_index_for_id support-beta)" || { emit_result purge false 0; return 1; }

  # Removing the exact manifest first prevents a reconnect from launching a
  # current host while its channel is being quiesced. If this fails, nothing
  # may delete either Support root.
  purge_target_at_index "$manifest_index"
  if [[ "${RESULT_STATUSES[$manifest_index]}" == failed ]]; then
    emit_result purge false 0
    return 1
  fi

  sentinel_status="$(create_support_removal_sentinel "${TARGET_VALUES[$support_ga_index]}")"
  if [[ "$sentinel_status" == failed ]]; then
    emit_result purge false 0
    return 1
  fi
  sentinel_status="$(create_support_removal_sentinel "${TARGET_VALUES[$support_beta_index]}")"
  if [[ "$sentinel_status" == failed ]]; then
    emit_result purge false 0
    return 1
  fi

  # Bash 3.2-compatible, fixed and bounded grace period for current hosts to
  # observe their root-local marker and exit without broad process matching.
  # Historical hosts cannot be safely matched or terminated here; manifest-first
  # prevents a new launch and Support-last removes their socket pathname only.
  /bin/sleep "$REMOVAL_SENTINEL_GRACE_SECONDS"

  # Keep Support roots for last. They contain the sentinels, sockets and every
  # other lifecycle file covered by the root registry entry.
  for index in "${!TARGET_IDS[@]}"; do
    [[ "$index" == "$manifest_index" || "$index" == "$support_ga_index" || "$index" == "$support_beta_index" ]] && continue
    purge_target_at_index "$index"
  done
  purge_target_at_index "$support_ga_index"
  purge_target_at_index "$support_beta_index"

  final_verify_purge_paths
  if purge_results_succeeded; then
    emit_result purge true 0
    return 0
  fi
  emit_result purge false 0
  return 1
}

usage() {
  /bin/echo "用法：卸载月狸.command [inventory --json | purge [--yes]]" >&2
}

if [[ $# -eq 0 ]]; then
  run_human_inventory || exit $?
  /bin/echo "外部导出、Chrome 个人资料和已安装扩展不会受影响。"
  /usr/bin/printf '输入 YES 继续彻底清除：'
  confirmation=""
  read -r confirmation
  [[ "$confirmation" == YES ]] || { /bin/echo "已取消。"; exit 0; }
  run_purge
  exit $?
fi

command_name="$1"
case "$command_name" in
  inventory)
    [[ $# -eq 1 || ( $# -eq 2 && "$2" == "--json" ) ]] || { usage; exit 64; }
    if [[ "${2:-}" == "--json" ]]; then
      run_inventory
    else
      run_human_inventory
    fi
    ;;
  purge)
    [[ $# -eq 1 || ( $# -eq 2 && "$2" == "--yes" ) ]] || { usage; exit 64; }
    if [[ "${2:-}" != "--yes" ]]; then
      run_human_inventory || exit $?
      /bin/echo "这会彻底清除月狸登记的本机数据，外部导出和 Chrome 个人资料不会受影响。"
      /usr/bin/printf '输入 YES 继续：'
      confirmation=""
      read -r confirmation
      [[ "$confirmation" == YES ]] || { /bin/echo "已取消。"; exit 0; }
    fi
    run_purge
    ;;
  *)
    usage
    exit 64
    ;;
esac
