#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

PROGRAM=${0##*/}
PACKAGE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
BUNDLE_ROOT=/opt/zhct-deploy
EVIDENCE_ROOT=/var/backups/zhct-q121-122
STATE_ROOT=/var/lib/zhct-q121-122
PRIMARY_IDENTITY_DEFAULT=/root/q121-primary-identity.env
PACKAGE_RUNTIME="$PACKAGE_DIR/framework/common/lib/runtime.sh"
PACKAGE_MYSQL_HOOKS="$PACKAGE_DIR/framework/roles/mysql/hooks.sh"
LOCKED_RUNTIME_SHA256=2d9acb64fca36ff23061cf8da0dfb25c384f21fd00b39e750c4afd3d30350dc0
LEGACY_RUNTIME_SHA256=4c06e4ad325de7241607b06d93c99c2f35f3422bfcbdbeeb43b530f2053f4bb6
LOCKED_MYSQL_HOOKS_SHA256=8dd8ad31318a6111fdaf9e0d346dad12fa5da5e629b2dfd429a2e66a3e5e016a
LEGACY_MYSQL_HOOKS_SHA256=56d68209ab3dc5da3f4ab2c0b679329fc27892749de5b4f65ff9b72e1ccfd8fd
UNITS=(mysql56.service mysql80.service mysql-health56.service mysql-health80.service)
TARGET_PORTS=(3306 3308 9201 9202)

usage() {
  printf '%s\n' \
    "Usage:" \
    "  $PROGRAM --static-validate" \
    "  $PROGRAM --self-test-schema-contract" \
    "  $PROGRAM q121|q122 preflight|execute|verify|rollback|archive"
}

die() {
  printf '%s\n' "ERROR: $*" >&2
  exit 1
}

quote_cmd() {
  printf '%q ' "$@"
}

run_cmd() {
  local rc
  printf 'COMMAND='
  quote_cmd "$@"
  printf '\n'
  set +e
  "$@"
  rc=$?
  set -e
  printf 'COMMAND_RC=%s\n' "$rc"
  return "$rc"
}

require_root() {
  [[ $(id -u) -eq 0 ]] || die "root is required"
}

require_file() {
  [[ -f "$1" ]] || die "required file missing: $1"
}

require_dir() {
  [[ -d "$1" ]] || die "required directory missing: $1"
}

safe_run_id() {
  [[ "$1" =~ ^q12[12]-[0-9]{8}T[0-9]{6}[+-][0-9]{4}$ ]] ||
    die "unsafe run_id: $1"
}

last_run_file() {
  printf '%s/%s.last\n' "$STATE_ROOT" "$TASK"
}

write_last_run() {
  install -d -m 0700 -o root -g root "$STATE_ROOT"
  printf '%s\n' "$RUN_ID" >"$(last_run_file)"
  chmod 0600 "$(last_run_file)"
}

load_last_run() {
  local state
  state=$(last_run_file)
  require_file "$state"
  RUN_ID=$(<"$state")
  safe_run_id "$RUN_ID"
  EVIDENCE_DIR="$EVIDENCE_ROOT/$RUN_ID"
  require_dir "$EVIDENCE_DIR"
}

configure_target() {
  case "$TASK" in
    q121)
      NODE_KEY=mysql01
      EXPECTED_HOSTNAME=sqjcDB01
      EXPECTED_IP=10.2.208.106
      EXPECTED_SERVER_ID_56=56106
      EXPECTED_SERVER_ID_80=80106
      EXPECTED_READ_ONLY_56=0
      EXPECTED_READ_ONLY_80=0
      EXPECTED_SUPER_READ_ONLY_80=0
      Q_ID=Q-121
      ;;
    q122)
      NODE_KEY=mysql02
      EXPECTED_HOSTNAME=sqjcDB02
      EXPECTED_IP=10.2.208.107
      EXPECTED_SERVER_ID_56=56107
      EXPECTED_SERVER_ID_80=80107
      EXPECTED_READ_ONLY_56=1
      EXPECTED_READ_ONLY_80=1
      EXPECTED_SUPER_READ_ONLY_80=1
      Q_ID=Q-122
      ;;
    *) die "unsupported task: $TASK" ;;
  esac
}

start_log() {
  local action=$1
  install -d -m 0700 -o root -g root "$EVIDENCE_DIR"
  LOG_FILE="$EVIDENCE_DIR/${action}.log"
  exec > >(tee -a "$LOG_FILE") 2>&1
  printf 'started_at=%s\n' "$(date --iso-8601=seconds)"
  printf 'task=%s\nnode_key=%s\nhostname_expected=%s\nip_expected=%s\naction=%s\n' \
    "$Q_ID" "$NODE_KEY" "$EXPECTED_HOSTNAME" "$EXPECTED_IP" "$action"
}

record_finish() {
  local rc=$1 action=$2
  {
    printf 'finished_at=%s\n' "$(date --iso-8601=seconds)"
    printf 'action=%s\nexit_code=%s\n' "$action" "$rc"
  } | tee -a "$EVIDENCE_DIR/action-result.env"
}

finish_on_exit() {
  local rc=$?
  trap - EXIT
  record_finish "$rc" "$CURRENT_ACTION"
}

arm_finish_trap() {
  CURRENT_ACTION=$1
  trap finish_on_exit EXIT
}

assert_host_identity() {
  local actual_hostname
  actual_hostname=$(hostname -s)
  [[ "${actual_hostname,,}" == "${EXPECTED_HOSTNAME,,}" ]] ||
    die "hostname mismatch: expected $EXPECTED_HOSTNAME, got $actual_hostname"
  ip -o -4 addr show scope global |
    awk -v expected="$EXPECTED_IP" '
      {split($4,a,"/"); if (a[1] == expected) found=1}
      END {exit !found}
    ' || die "expected IPv4 not present: $EXPECTED_IP"
  printf 'target_identity=PASS\n'
}

assert_framework() {
  local mysql_install_db_help="$EVIDENCE_DIR/mysql-install-db-help.txt"
  local mysql_install_db_help_rc option runtime_sha hooks_sha etc_mode
  command -v runuser >/dev/null 2>&1 || die "runuser is required"
  command -v restorecon >/dev/null 2>&1 || die "restorecon is required"
  require_file "$PACKAGE_RUNTIME"
  require_file "$PACKAGE_MYSQL_HOOKS"
  [[ $(sha256sum "$PACKAGE_RUNTIME" | awk '{print $1}') == "$LOCKED_RUNTIME_SHA256" ]] ||
    die "packaged runtime SHA256 mismatch"
  [[ $(sha256sum "$PACKAGE_MYSQL_HOOKS" | awk '{print $1}') == "$LOCKED_MYSQL_HOOKS_SHA256" ]] ||
    die "packaged MySQL hooks SHA256 mismatch"
  bash -n "$PACKAGE_RUNTIME"
  bash -n "$PACKAGE_MYSQL_HOOKS"
  grep -Fq 'runuser -u mysql -- /opt/mysql/5.6.51/bin/mysqld' "$PACKAGE_MYSQL_HOOKS" ||
    die "packaged MySQL5.6 hook identity gate missing"
  grep -Fq 'runuser -u mysql -- /opt/mysql/8.0.46/bin/mysqld' "$PACKAGE_MYSQL_HOOKS" ||
    die "packaged MySQL8.0 hook identity gate missing"
  require_file "$BUNDLE_ROOT/common/scripts/post-clone.sh"
  require_file "$BUNDLE_ROOT/common/scripts/activate-role.sh"
  require_file "$BUNDLE_ROOT/common/lib/runtime.sh"
  require_file "$BUNDLE_ROOT/roles/mysql/hooks.sh"
  require_file "$BUNDLE_ROOT/roles/mysql/manifests/nodes.tsv"
  require_file "$BUNDLE_ROOT/roles/mysql/manifests/secrets.required.tsv"
  require_file "$BUNDLE_ROOT/roles/mysql/templates/nodes/$NODE_KEY/my56.cnf"
  require_file "$BUNDLE_ROOT/roles/mysql/templates/nodes/$NODE_KEY/my80.cnf"
  require_file "/etc/zhct/templates/mysql/nodes/$NODE_KEY/my80.cnf"
  require_file /opt/mysql/5.6.51/scripts/mysql_install_db
  require_file /opt/mysql/5.6.51/bin/mysqld
  require_file /opt/mysql/5.6.51/bin/mysql
  require_file /opt/mysql/5.6.51/bin/mysqladmin
  require_file /opt/mysql/8.0.46/bin/mysqld
  require_file /opt/mysql/8.0.46/bin/mysql
  require_file /opt/mysql/8.0.46/bin/mysqladmin
  awk -F'\t' -v node="$NODE_KEY" -v ip="$EXPECTED_IP" -v host="$EXPECTED_HOSTNAME" '
    NR > 1 && $1 == node && $2 == ip && tolower($3) == tolower(host) {ok=1}
    END {exit !ok}
  ' "$BUNDLE_ROOT/roles/mysql/manifests/nodes.tsv" ||
    die "node manifest mismatch"
  # Keep the allowed MySQL 5.6 help rc=1 inside an if-condition. execute mode
  # has an inherited ERR trap, so set +e alone would still invoke rollback.
  if /opt/mysql/5.6.51/scripts/mysql_install_db --help >"$mysql_install_db_help" 2>&1; then
    mysql_install_db_help_rc=0
  else
    mysql_install_db_help_rc=$?
  fi
  printf 'mysql_install_db_help_exit=%s\n' "$mysql_install_db_help_rc"
  [[ "$mysql_install_db_help_rc" -eq 0 || "$mysql_install_db_help_rc" -eq 1 ]] ||
    die "mysql_install_db --help returned unexpected exit: $mysql_install_db_help_rc"
  grep -Fq 'Usage:' "$mysql_install_db_help" ||
    die "mysql_install_db help usage marker missing"
  for option in --basedir --datadir --user --skip-name-resolve; do
    grep -Fq -- "$option" "$mysql_install_db_help" ||
      die "mysql_install_db required option missing: $option"
  done
  runtime_sha=$(sha256sum "$BUNDLE_ROOT/common/lib/runtime.sh" | awk '{print $1}')
  hooks_sha=$(sha256sum "$BUNDLE_ROOT/roles/mysql/hooks.sh" | awk '{print $1}')
  case "$runtime_sha" in
    "$LOCKED_RUNTIME_SHA256") printf 'remote_runtime_state=LOCKED\n' ;;
    "$LEGACY_RUNTIME_SHA256") printf 'remote_runtime_state=LEGACY_UPDATE_REQUIRED\n' ;;
    *) die "unexpected remote runtime SHA256: $runtime_sha" ;;
  esac
  case "$hooks_sha" in
    "$LOCKED_MYSQL_HOOKS_SHA256") printf 'remote_mysql_hooks_state=LOCKED\n' ;;
    "$LEGACY_MYSQL_HOOKS_SHA256") printf 'remote_mysql_hooks_state=LEGACY_UPDATE_REQUIRED\n' ;;
    *) die "unexpected remote MySQL hooks SHA256: $hooks_sha" ;;
  esac
  [[ -d /etc && ! -L /etc && $(stat -c '%U:%G' /etc) == root:root ]] ||
    die "/etc identity mismatch"
  etc_mode=$(stat -c '%a' /etc)
  case "$etc_mode" in
    755)
      runuser -u mysql -- test -r /etc/my56.cnf ||
        die "mysql cannot read /etc/my56.cnf"
      runuser -u mysql -- test -r /etc/my80.cnf ||
        die "mysql cannot read /etc/my80.cnf"
      printf 'mysql_config_access=PASS\n'
      ;;
    750)
      printf 'mysql_config_access=LEGACY_ETC_MODE_REPAIR_REQUIRED\n'
      ;;
    *) die "unexpected /etc mode: $etc_mode" ;;
  esac
  printf 'framework_gate=PASS\n'
}

install_locked_framework() {
  local runtime="$BUNDLE_ROOT/common/lib/runtime.sh"
  local hooks="$BUNDLE_ROOT/roles/mysql/hooks.sh"
  local runtime_sha hooks_sha
  install -d -m 0700 -o root -g root "$EVIDENCE_DIR/framework-before"
  install -m 0600 -o root -g root "$runtime" "$EVIDENCE_DIR/framework-before/runtime.sh"
  install -m 0600 -o root -g root "$hooks" "$EVIDENCE_DIR/framework-before/mysql-hooks.sh"
  stat -c 'mode=%a owner=%U:%G label=%C path=%n' /etc "$runtime" "$hooks" \
    >"$EVIDENCE_DIR/framework-before/metadata.txt"
  sha256sum "$runtime" "$hooks" >"$EVIDENCE_DIR/framework-before/sha256.txt"

  runtime_sha=$(sha256sum "$runtime" | awk '{print $1}')
  if [[ "$runtime_sha" == "$LEGACY_RUNTIME_SHA256" ]]; then
    install -m 0644 -o root -g root "$PACKAGE_RUNTIME" "$runtime"
    restorecon -v "$runtime"
    printf 'runtime_update=APPLIED\n'
  else
    [[ "$runtime_sha" == "$LOCKED_RUNTIME_SHA256" ]] ||
      die "refuse unexpected runtime before install"
    printf 'runtime_update=ALREADY_LOCKED\n'
  fi

  hooks_sha=$(sha256sum "$hooks" | awk '{print $1}')
  if [[ "$hooks_sha" == "$LEGACY_MYSQL_HOOKS_SHA256" ]]; then
    install -m 0750 -o root -g root "$PACKAGE_MYSQL_HOOKS" "$hooks"
    restorecon -v "$hooks"
    printf 'mysql_hooks_update=APPLIED\n'
  else
    [[ "$hooks_sha" == "$LOCKED_MYSQL_HOOKS_SHA256" ]] ||
      die "refuse unexpected MySQL hooks before install"
    printf 'mysql_hooks_update=ALREADY_LOCKED\n'
  fi

  chmod 0755 /etc
  restorecon -v /etc
  [[ $(sha256sum "$runtime" | awk '{print $1}') == "$LOCKED_RUNTIME_SHA256" ]] ||
    die "runtime lock verification failed"
  [[ $(sha256sum "$hooks" | awk '{print $1}') == "$LOCKED_MYSQL_HOOKS_SHA256" ]] ||
    die "MySQL hooks lock verification failed"
  [[ $(stat -c '%a %U:%G' /etc) == '755 root:root' ]] ||
    die "/etc repair verification failed"
  runuser -u mysql -- test -r /etc/my56.cnf ||
    die "mysql cannot read my56.cnf after framework install"
  runuser -u mysql -- test -r /etc/my80.cnf ||
    die "mysql cannot read my80.cnf after framework install"
  stat -c 'mode=%a owner=%U:%G label=%C path=%n' /etc "$runtime" "$hooks" \
    >"$EVIDENCE_DIR/framework-after-metadata.txt"
  sha256sum "$runtime" "$hooks" >"$EVIDENCE_DIR/framework-after.sha256"
  printf 'locked_framework_install=PASS\n'
}

unit_snapshot() {
  local out=$1 unit
  : >"$out"
  for unit in "${UNITS[@]}"; do
    printf '%s\tenabled=%s\tactive=%s\n' \
      "$unit" \
      "$(systemctl is-enabled "$unit" 2>&1 || true)" \
      "$(systemctl is-active "$unit" 2>&1 || true)" >>"$out"
  done
}

assert_units_inactive() {
  local unit
  for unit in "${UNITS[@]}"; do
    ! systemctl is-active --quiet "$unit" || die "unit is active before execute: $unit"
  done
  ! pgrep -x mysqld >/dev/null || die "mysqld process exists before execute"
}

capture_listeners() {
  ss -lntp >"$1"
}

assert_target_ports_absent() {
  local listeners=$1 port
  for port in "${TARGET_PORTS[@]}" 33060; do
    ! grep -Eq ":${port}([[:space:]]|$)" "$listeners" ||
      die "target port already listening before execute: $port"
  done
}

assert_empty_skeleton() {
  local base=$1 rel
  require_dir "$base"
  while IFS= read -r rel; do
    case "$rel" in
      binlog|relay) [[ -d "$base/$rel" ]] || die "not a directory: $base/$rel" ;;
      *) die "unexpected pre-initialize path: $base/$rel" ;;
    esac
  done < <(find "$base" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
  [[ -d "$base/binlog" && -d "$base/relay" ]] ||
    die "required empty skeleton missing below $base"
  [[ -z $(find "$base/binlog" "$base/relay" -mindepth 1 -print -quit) ]] ||
    die "skeleton is not empty: $base"
}

config_value() {
  local file=$1 key=$2
  awk -F= -v wanted="$key" '
    function trim(s) {gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s}
    /^[[:space:]]*[#;]/ {next}
    NF >= 2 && trim($1) == wanted {print trim(substr($0,index($0,"=")+1))}
  ' "$file"
}

assert_single_config_value() {
  local file=$1 key=$2 expected=$3 actual count
  actual=$(config_value "$file" "$key")
  count=$(printf '%s\n' "$actual" | awk 'NF {n++} END {print n+0}')
  [[ "$count" -eq 1 && "$actual" == "$expected" ]] ||
    die "config mismatch: $file $key expected=$expected actual=${actual:-ABSENT}"
}

assert_node_configs() {
  local f56=/etc/my56.cnf f80=/etc/my80.cnf
  require_file "$f56"
  require_file "$f80"
  assert_single_config_value "$f56" basedir /opt/mysql/5.6.51
  assert_single_config_value "$f56" datadir /data/mysql56
  assert_single_config_value "$f56" port 3306
  assert_single_config_value "$f56" bind-address "$EXPECTED_IP"
  assert_single_config_value "$f56" server-id "$EXPECTED_SERVER_ID_56"
  assert_single_config_value "$f56" read-only "$([[ "$EXPECTED_READ_ONLY_56" == 1 ]] && printf ON || printf OFF)"
  assert_single_config_value "$f80" basedir /opt/mysql/8.0.46
  assert_single_config_value "$f80" datadir /data/mysql80
  assert_single_config_value "$f80" port 3308
  assert_single_config_value "$f80" bind-address "$EXPECTED_IP"
  assert_single_config_value "$f80" server-id "$EXPECTED_SERVER_ID_80"
  assert_single_config_value "$f80" read-only "$([[ "$EXPECTED_READ_ONLY_80" == 1 ]] && printf ON || printf OFF)"
  assert_single_config_value "$f80" super-read-only "$([[ "$EXPECTED_SUPER_READ_ONLY_80" == 1 ]] && printf ON || printf OFF)"
  assert_single_config_value "$f80" mysqlx OFF
  printf 'node_config_gate=PASS\n'
}

install_mysqlx_off() {
  local file=$1 temp
  require_file "$file"
  if [[ "$(config_value "$file" mysqlx)" == OFF ]]; then
    return 0
  fi
  [[ -z "$(config_value "$file" mysqlx)" ]] ||
    die "refuse to replace existing mysqlx setting in $file"
  temp=$(mktemp)
  awk '
    BEGIN {added=0}
    /^\[mysqld\][[:space:]]*$/ && !added {print; print "mysqlx=OFF"; added=1; next}
    {print}
    END {if (!added) exit 42}
  ' "$file" >"$temp" || { rm -f "$temp"; die "failed to add mysqlx=OFF to $file"; }
  install -m "$(stat -c '%a' "$file")" -o "$(stat -c '%U' "$file")" \
    -g "$(stat -c '%G' "$file")" "$temp" "$file"
  rm -f "$temp"
}

backup_and_fix_mysqlx() {
  local live=/etc/my80.cnf template="/etc/zhct/templates/mysql/nodes/$NODE_KEY/my80.cnf"
  install -d -m 0700 -o root -g root "$EVIDENCE_DIR/config-before"
  cp -a "$live" "$EVIDENCE_DIR/config-before/my80.cnf"
  cp -a "$template" "$EVIDENCE_DIR/config-before/my80-template.cnf"
  install_mysqlx_off "$live"
  install_mysqlx_off "$template"
  sha256sum "$live" "$template" >"$EVIDENCE_DIR/mysql80-config-after.sha256"
  printf 'mysqlx_off_gate=PASS\n'
}

restore_configs() {
  local template="/etc/zhct/templates/mysql/nodes/$NODE_KEY/my80.cnf"
  if [[ -f "$EVIDENCE_DIR/config-before/my80.cnf" ]]; then
    cp -a "$EVIDENCE_DIR/config-before/my80.cnf" /etc/my80.cnf
  fi
  if [[ -f "$EVIDENCE_DIR/config-before/my80-template.cnf" ]]; then
    cp -a "$EVIDENCE_DIR/config-before/my80-template.cnf" "$template"
  fi
}

secret_metadata() {
  local manifest="$BUNDLE_ROOT/roles/mysql/manifests/secrets.required.tsv"
  local out=$1 path mode owner consumer task
  : >"$out"
  while IFS=$'\t' read -r path mode owner consumer task; do
    [[ "$path" == path || -z "$path" ]] && continue
    if [[ -e "$path" ]]; then
      printf '%s\texists=yes\tmode=%s\towner=%s:%s\tconsumer=%s\tprovision_task=%s\n' \
        "$path" "$(stat -c '%a' "$path")" "$(stat -c '%U' "$path")" \
        "$(stat -c '%G' "$path")" "$consumer" "$task" >>"$out"
    else
      printf '%s\texists=no\tmode=NA\towner=NA\tconsumer=%s\tprovision_task=%s\n' \
        "$path" "$consumer" "$task" >>"$out"
    fi
  done <"$manifest"
}

validate_primary_identity_file() {
  local file=${PRIMARY_IDENTITY_FILE:-$PRIMARY_IDENTITY_DEFAULT}
  require_file "$file"
  [[ $(stat -c '%U:%G' "$file") == root:root ]] || die "primary identity owner must be root:root"
  case "$(stat -c '%a' "$file")" in 400|600) ;; *) die "primary identity mode must be 0400 or 0600" ;; esac
  awk -F= '
    $1=="mysql56_server_id" && $2 ~ /^[0-9]+$/ {a++}
    $1=="mysql56_server_uuid" && $2 ~ /^[0-9a-fA-F-]{36}$/ {b++}
    $1=="mysql80_server_id" && $2 ~ /^[0-9]+$/ {c++}
    $1=="mysql80_server_uuid" && $2 ~ /^[0-9a-fA-F-]{36}$/ {d++}
    END {exit !(NR==4 && a==1 && b==1 && c==1 && d==1)}
  ' "$file" || die "invalid Q-121 primary identity file"
  PRIMARY_IDENTITY_FILE=$file
}

preflight_checks() {
  assert_host_identity
  assert_framework
  unit_snapshot "$EVIDENCE_DIR/units-preflight.tsv"
  capture_listeners "$EVIDENCE_DIR/listeners-preflight.txt"
  assert_units_inactive
  assert_target_ports_absent "$EVIDENCE_DIR/listeners-preflight.txt"
  assert_empty_skeleton /data/mysql56
  assert_empty_skeleton /data/mysql80
  df -Pk / /data >"$EVIDENCE_DIR/filesystems-preflight.txt"
  [[ $(df -Pk /data | awk 'NR==2 {print $4}') -ge 8388608 ]] ||
    die "less than 8 GiB free on /data filesystem"
  secret_metadata "$EVIDENCE_DIR/secret-metadata-preflight.tsv"
  if [[ "$TASK" == q122 ]]; then
    validate_primary_identity_file
    cp -a "$PRIMARY_IDENTITY_FILE" "$EVIDENCE_DIR/q121-primary-identity.env"
  fi
  printf 'preflight=PASS\n'
}

stop_units() {
  systemctl disable --now "${UNITS[@]}" || true
}

move_empty_skeletons() {
  install -d -m 0700 -o root -g root "$EVIDENCE_DIR/preinit"
  mv /data/mysql56 "$EVIDENCE_DIR/preinit/mysql56"
  mv /data/mysql80 "$EVIDENCE_DIR/preinit/mysql80"
  install -d -m 0750 -o mysql -g mysql /data/mysql56 /data/mysql80
  : >"$EVIDENCE_DIR/data-moved.marker"
}

initialize_datadirs() {
  run_cmd /opt/mysql/5.6.51/scripts/mysql_install_db \
    --basedir=/opt/mysql/5.6.51 --datadir=/data/mysql56 --user=mysql \
    --skip-name-resolve
  if [[ -e /data/mysql56/test || -L /data/mysql56/test ]]; then
    [[ -d /data/mysql56/test && ! -L /data/mysql56/test ]] ||
      die "unexpected MySQL5.6 generated test path type"
    install -d -m 0700 -o root -g root "$EVIDENCE_DIR/install-defaults"
    mv /data/mysql56/test "$EVIDENCE_DIR/install-defaults/mysql56-test-schema"
    printf 'mysql56_generated_test_schema=QUARANTINED_INSTALL_DEFAULT\n'
  else
    printf 'mysql56_generated_test_schema=ABSENT\n'
  fi
  [[ ! -e /data/mysql56/test && ! -L /data/mysql56/test ]] ||
    die "MySQL5.6 generated test schema remains in datadir"
  run_cmd /opt/mysql/8.0.46/bin/mysqld --no-defaults --initialize-insecure \
    --basedir=/opt/mysql/8.0.46 --datadir=/data/mysql80 --user=mysql
  install -d -m 0750 -o mysql -g mysql \
    /data/mysql56/binlog /data/mysql56/relay /data/mysql80/binlog /data/mysql80/relay
  chown -R mysql:mysql /data/mysql56 /data/mysql80
  find /data/mysql56 /data/mysql80 -type d -exec chmod 0750 {} +
  [[ -f /data/mysql56/mysql/user.frm ]] || die "MySQL5.6 initialize marker missing"
  [[ -f /data/mysql80/auto.cnf ]] || die "MySQL8.0 initialize marker missing"
}

rollback_data() {
  local failed="$EVIDENCE_DIR/failed-data"
  stop_units
  if [[ -f "$EVIDENCE_DIR/data-moved.marker" ]]; then
    install -d -m 0700 -o root -g root "$failed"
    if [[ -e /data/mysql56 && ! -e "$failed/mysql56" ]]; then mv /data/mysql56 "$failed/mysql56"; fi
    if [[ -e /data/mysql80 && ! -e "$failed/mysql80" ]]; then mv /data/mysql80 "$failed/mysql80"; fi
    if [[ -d "$EVIDENCE_DIR/preinit/mysql56" && ! -e /data/mysql56 ]]; then
      mv "$EVIDENCE_DIR/preinit/mysql56" /data/mysql56
    fi
    if [[ -d "$EVIDENCE_DIR/preinit/mysql80" && ! -e /data/mysql80 ]]; then
      mv "$EVIDENCE_DIR/preinit/mysql80" /data/mysql80
    fi
  fi
  restore_configs
  unit_snapshot "$EVIDENCE_DIR/units-after-rollback.tsv"
  capture_listeners "$EVIDENCE_DIR/listeners-after-rollback.txt"
}

wait_mysql() {
  local admin=$1 socket=$2
  local i
  for i in {1..90}; do
    if "$admin" --protocol=socket --socket="$socket" -uroot ping >/dev/null 2>&1; then
      return 0
    fi
    sleep 1
  done
  return 1
}

mysql_query() {
  local client=$1 socket=$2 sql=$3 output=$4
  printf 'SQL_READ_ONLY=%s\n' "$sql"
  "$client" --protocol=socket --socket="$socket" -uroot \
    --batch --skip-column-names -e "$sql" >"$output"
  printf 'SQL_RC=0\n'
}

schema_class() {
  local version=$1 schema=$2
  case "$version:$schema" in
    56:information_schema|56:mysql|56:performance_schema)
      printf 'SYSTEM\n'
      ;;
    56:binlog|56:relay)
      printf 'MYSQL56_INFRASTRUCTURE\n'
      ;;
    80:information_schema|80:mysql|80:performance_schema|80:sys)
      printf 'SYSTEM\n'
      ;;
    *)
      printf 'UNKNOWN\n'
      ;;
  esac
}

assert_schema_allowlist() {
  local file=$1 version=$2 schema class infrastructure_dir
  while IFS= read -r schema; do
    class=$(schema_class "$version" "$schema")
    case "$class" in
      SYSTEM) ;;
      MYSQL56_INFRASTRUCTURE)
        infrastructure_dir="/data/mysql56/$schema"
        [[ -d "$infrastructure_dir" && ! -L "$infrastructure_dir" ]] ||
          die "MySQL5.6 infrastructure schema path is invalid: $infrastructure_dir"
        [[ $(stat -c '%U:%G' "$infrastructure_dir") == mysql:mysql ]] ||
          die "MySQL5.6 infrastructure schema owner is invalid: $infrastructure_dir"
        if find "$infrastructure_dir" -maxdepth 1 -type f \
          \( -name '*.frm' -o -name 'db.opt' \) -print -quit | grep -q .; then
          die "MySQL5.6 infrastructure directory contains schema metadata: $infrastructure_dir"
        fi
        printf 'mysql56_infrastructure_namespace=%s\n' "$schema"
        ;;
      UNKNOWN) die "unexpected schema on MySQL$version: $schema" ;;
      *) die "internal schema classification error: $class" ;;
    esac
  done <"$file"
}

schema_contract_self_test() {
  [[ $(schema_class 56 information_schema) == SYSTEM ]]
  [[ $(schema_class 56 binlog) == MYSQL56_INFRASTRUCTURE ]]
  [[ $(schema_class 56 relay) == MYSQL56_INFRASTRUCTURE ]]
  [[ $(schema_class 56 business) == UNKNOWN ]]
  [[ $(schema_class 80 sys) == SYSTEM ]]
  [[ $(schema_class 80 binlog) == UNKNOWN ]]
  [[ $(schema_class 80 business) == UNKNOWN ]]
  printf 'Q121_122_SCHEMA_CONTRACT_SELF_TEST_OK\n'
}

verify_listeners() {
  local file=$1 port
  capture_listeners "$file"
  for port in "${TARGET_PORTS[@]}"; do
    grep -Eq "${EXPECTED_IP}:${port}([[:space:]]|$)" "$file" ||
      die "expected listener missing: $EXPECTED_IP:$port"
  done
  ! grep -Eq ':33060([[:space:]]|$)' "$file" || die "forbidden MySQL X listener detected"
}

verify_units() {
  local out=$1 unit
  unit_snapshot "$out"
  for unit in "${UNITS[@]}"; do
    systemctl is-enabled --quiet "$unit" || die "unit not enabled: $unit"
    systemctl is-active --quiet "$unit" || die "unit not active: $unit"
  done
}

verify_runtime() {
  local f56="$EVIDENCE_DIR/mysql56-identity.tsv"
  local f80="$EVIDENCE_DIR/mysql80-identity.tsv"
  local schemas56="$EVIDENCE_DIR/mysql56-schemas.txt"
  local schemas80="$EVIDENCE_DIR/mysql80-schemas.txt"
  local repl56="$EVIDENCE_DIR/mysql56-replication-status.txt"
  local repl80="$EVIDENCE_DIR/mysql80-replication-status.txt"
  local sid56 uuid56 ro56 sid80 uuid80 ro80 sro80

  assert_host_identity
  assert_node_configs
  wait_mysql /opt/mysql/5.6.51/bin/mysqladmin /run/mysql56/mysql.sock ||
    die "MySQL5.6 ping timeout"
  wait_mysql /opt/mysql/8.0.46/bin/mysqladmin /run/mysql80/mysql.sock ||
    die "MySQL8.0 ping timeout"
  verify_units "$EVIDENCE_DIR/units-verify.tsv"
  verify_listeners "$EVIDENCE_DIR/listeners-verify.txt"

  mysql_query /opt/mysql/5.6.51/bin/mysql /run/mysql56/mysql.sock \
    'SELECT @@server_id,@@server_uuid,@@read_only' "$f56"
  mysql_query /opt/mysql/8.0.46/bin/mysql /run/mysql80/mysql.sock \
    'SELECT @@server_id,@@server_uuid,@@read_only,@@super_read_only' "$f80"
  mysql_query /opt/mysql/5.6.51/bin/mysql /run/mysql56/mysql.sock \
    'SHOW DATABASES' "$schemas56"
  mysql_query /opt/mysql/8.0.46/bin/mysql /run/mysql80/mysql.sock \
    'SHOW DATABASES' "$schemas80"
  mysql_query /opt/mysql/5.6.51/bin/mysql /run/mysql56/mysql.sock \
    'SHOW SLAVE STATUS' "$repl56"
  mysql_query /opt/mysql/8.0.46/bin/mysql /run/mysql80/mysql.sock \
    'SHOW REPLICA STATUS' "$repl80"

  read -r sid56 uuid56 ro56 <"$f56"
  read -r sid80 uuid80 ro80 sro80 <"$f80"
  [[ "$sid56" == "$EXPECTED_SERVER_ID_56" && "$ro56" == "$EXPECTED_READ_ONLY_56" ]] ||
    die "MySQL5.6 server_id/read_only mismatch"
  [[ "$sid80" == "$EXPECTED_SERVER_ID_80" && "$ro80" == "$EXPECTED_READ_ONLY_80" &&
     "$sro80" == "$EXPECTED_SUPER_READ_ONLY_80" ]] ||
    die "MySQL8.0 server_id/read_only mismatch"
  [[ "$uuid56" =~ ^[0-9a-fA-F-]{36}$ && "$uuid80" =~ ^[0-9a-fA-F-]{36}$ &&
     "$uuid56" != "$uuid80" ]] || die "local server_uuid values are invalid or duplicated"
  assert_schema_allowlist "$schemas56" 56
  assert_schema_allowlist "$schemas80" 80
  [[ ! -s "$repl56" && ! -s "$repl80" ]] ||
    die "replication state exists before Q-221"

  if [[ "$TASK" == q121 ]]; then
    {
      printf 'mysql56_server_id=%s\n' "$sid56"
      printf 'mysql56_server_uuid=%s\n' "$uuid56"
      printf 'mysql80_server_id=%s\n' "$sid80"
      printf 'mysql80_server_uuid=%s\n' "$uuid80"
    } >"$EVIDENCE_DIR/primary-identity.env"
    chmod 0600 "$EVIDENCE_DIR/primary-identity.env"
  else
    validate_primary_identity_file
    local p56sid p56uuid p80sid p80uuid
    p56sid=$(awk -F= '$1=="mysql56_server_id"{print $2}' "$PRIMARY_IDENTITY_FILE")
    p56uuid=$(awk -F= '$1=="mysql56_server_uuid"{print $2}' "$PRIMARY_IDENTITY_FILE")
    p80sid=$(awk -F= '$1=="mysql80_server_id"{print $2}' "$PRIMARY_IDENTITY_FILE")
    p80uuid=$(awk -F= '$1=="mysql80_server_uuid"{print $2}' "$PRIMARY_IDENTITY_FILE")
    [[ "$p56sid" != "$sid56" && "$p80sid" != "$sid80" ]] ||
      die "server_id collision with Q-121"
    [[ "$p56uuid" != "$uuid56" && "$p56uuid" != "$uuid80" &&
       "$p80uuid" != "$uuid56" && "$p80uuid" != "$uuid80" &&
       "$p56uuid" != "$p80uuid" ]] || die "server_uuid collision across Q-121/Q-122"
  fi

  secret_metadata "$EVIDENCE_DIR/secret-metadata-verify.tsv"
  printf 'server_identity=PASS\nempty_schema_allowlist=PASS\n'
  printf 'replication=NOT_PERFORMED_Q221_GATE\n'
  printf 'business_sql=NOT_PERFORMED_Q261_GATE\n'
}

archive_evidence() {
  local archive_base="/root/sanquan-mysql-${TASK}-evidence-${RUN_ID}"
  local archive="${archive_base}.tar.gz"
  if [[ -e "$archive" ]]; then
    archive="${archive_base}-$(date '+%Y%m%dT%H%M%S%z').tar.gz"
  fi
  tar --create --gzip --file="$archive" --directory="$EVIDENCE_ROOT" "$RUN_ID"
  chmod 0600 "$archive"
  sha256sum "$archive" | tee "$EVIDENCE_DIR/archive.sha256"
  stat -c 'archive_bytes=%s' "$archive" | tee -a "$EVIDENCE_DIR/archive.sha256"
  if [[ "$TASK" == q121 && -f "$EVIDENCE_DIR/primary-identity.env" ]]; then
    local identity_base="/root/sanquan-mysql-q121-primary-identity-${RUN_ID}"
    local identity="${identity_base}.env"
    if [[ -e "$identity" ]] && ! cmp -s "$identity" "$EVIDENCE_DIR/primary-identity.env"; then
      identity="${identity_base}-$(date '+%Y%m%dT%H%M%S%z').env"
    fi
    install -m 0600 -o root -g root "$EVIDENCE_DIR/primary-identity.env" "$identity"
    sha256sum "$identity" | tee "$EVIDENCE_DIR/primary-identity-export.sha256"
    printf 'primary_identity_export=%s\n' "$identity"
  fi
  printf 'archive=%s\n' "$archive"
}

do_post_clone_if_needed() {
  if [[ "$TASK" == q121 ]]; then
    RUN_ID="${RUN_ID}-postclone" \
      bash "$BUNDLE_ROOT/common/scripts/post-clone.sh" \
      --role mysql --bundle-root "$BUNDLE_ROOT" --node "$NODE_KEY" --expected-ip "$EXPECTED_IP"
  else
    require_file /etc/zhct/node.env
    grep -Fxq 'ROLE=mysql' /etc/zhct/node.env || die "node.env role mismatch"
    grep -Fxq 'NODE_KEY=mysql02' /etc/zhct/node.env || die "node.env node mismatch"
    grep -Fxq 'NODE_IP=10.2.208.107' /etc/zhct/node.env || die "node.env IP mismatch"
  fi
}

do_preflight_action() {
  RUN_ID="${TASK}-$(date '+%Y%m%dT%H%M%S%z')"
  safe_run_id "$RUN_ID"
  EVIDENCE_DIR="$EVIDENCE_ROOT/$RUN_ID"
  [[ ! -e "$EVIDENCE_DIR" ]] || die "evidence directory already exists: $EVIDENCE_DIR"
  start_log preflight
  arm_finish_trap preflight
  preflight_checks
  trap - EXIT
  record_finish 0 preflight
}

execute_failure() {
  local rc=$?
  trap - ERR INT TERM EXIT
  set +e
  printf 'execute_failure_rc=%s\n' "$rc"
  rollback_data
  {
    printf 'result=FAIL_ROLLED_BACK\n'
    printf 'exit_code=%s\n' "$rc"
    printf 'replication=NOT_PERFORMED_Q221_GATE\n'
    printf 'business_sql=NOT_PERFORMED_Q261_GATE\n'
  } >"$EVIDENCE_DIR/result.env"
  record_finish "$rc" execute
  archive_evidence || true
  exit "$rc"
}

do_execute_action() {
  RUN_ID="${TASK}-$(date '+%Y%m%dT%H%M%S%z')"
  safe_run_id "$RUN_ID"
  EVIDENCE_DIR="$EVIDENCE_ROOT/$RUN_ID"
  [[ ! -e "$EVIDENCE_DIR" ]] || die "evidence directory already exists: $EVIDENCE_DIR"
  start_log execute
  write_last_run
  trap execute_failure ERR INT TERM EXIT
  preflight_checks
  install_locked_framework
  do_post_clone_if_needed
  backup_and_fix_mysqlx
  assert_node_configs
  stop_units
  move_empty_skeletons
  initialize_datadirs
  RUN_ID="${RUN_ID}-activate" \
    bash "$BUNDLE_ROOT/common/scripts/activate-role.sh" \
    --role mysql --bundle-root "$BUNDLE_ROOT"
  verify_runtime
  {
    printf 'result=PASS\n'
    printf 'task=%s\nrun_id=%s\nnode_key=%s\nhostname=%s\nip=%s\n' \
      "$Q_ID" "$RUN_ID" "$NODE_KEY" "$EXPECTED_HOSTNAME" "$EXPECTED_IP"
    printf 'replication=NOT_PERFORMED_Q221_GATE\n'
    printf 'business_sql=NOT_PERFORMED_Q261_GATE\n'
    printf 'rollback=AVAILABLE_WITH_THIS_RUN_ID\n'
  } >"$EVIDENCE_DIR/result.env"
  trap - ERR INT TERM EXIT
  record_finish 0 execute
  archive_evidence
  printf '%s_MYSQL_INITIALIZE_ACTIVATE_VERIFY_ARCHIVE_OK\n' "${TASK^^}"
}

do_verify_action() {
  load_last_run
  start_log verify
  arm_finish_trap verify
  verify_runtime
  trap - EXIT
  record_finish 0 verify
}

do_rollback_action() {
  load_last_run
  start_log rollback
  arm_finish_trap rollback
  rollback_data
  {
    printf 'result=ROLLED_BACK_TO_PREINIT_EMPTY_SKELETON\n'
    printf 'run_id=%s\n' "$RUN_ID"
    printf 'replication=NOT_PERFORMED_Q221_GATE\n'
    printf 'business_sql=NOT_PERFORMED_Q261_GATE\n'
  } >"$EVIDENCE_DIR/rollback-result.env"
  trap - EXIT
  record_finish 0 rollback
  printf '%s_ROLLBACK_OK\n' "${TASK^^}"
}

do_archive_action() {
  load_last_run
  start_log archive
  arm_finish_trap archive
  archive_evidence
  trap - EXIT
  record_finish 0 archive
  printf '%s_ARCHIVE_OK\n' "${TASK^^}"
}

static_validate() {
  bash -n "$0"
  require_file "$PACKAGE_DIR/README.md"
  local forbidden unsupported_option
  forbidden='CHANGE[[:space:]]+MASTER|START[[:space:]]+SLAVE|CHANGE[[:space:]]+REPLICATION[[:space:]]+SOURCE|START[[:space:]]+REPLICA|CREATE[[:space:]]+DATABASE|SOURCE[[:space:]]+.*[.]sql'
  if grep -Eiq "$forbidden" "$0"; then
    die "script contains a forbidden replication or business-SQL statement"
  fi
  grep -Fq 'replication=NOT_PERFORMED_Q221_GATE' "$0"
  grep -Fq 'business_sql=NOT_PERFORMED_Q261_GATE' "$0"
  grep -Fq 'SHOW SLAVE STATUS' "$0"
  grep -Fq 'SHOW REPLICA STATUS' "$0"
  grep -Fq 'mysqlx OFF' "$0"
  unsupported_option='--skip-test'
  unsupported_option+='-db'
  ! grep -Fq -- "$unsupported_option" "$0" ||
    die "script contains unsupported MySQL5.6 option"
  grep -Fq 'set +e alone would still invoke rollback' "$0"
  grep -Fq 'mysql56_generated_test_schema=QUARANTINED_INSTALL_DEFAULT' "$0"
  grep -Fq 'locked_framework_install=PASS' "$0"
  grep -Fq 'LEGACY_ETC_MODE_REPAIR_REQUIRED' "$0"
  grep -Fq 'mysql56_infrastructure_namespace=' "$0"
  grep -Fq "name '*.frm'" "$0"
  printf 'Q121_122_STATIC_VALIDATE_OK\n'
}

if [[ ${1:-} == --static-validate ]]; then
  static_validate
  exit 0
fi

if [[ ${1:-} == --self-test-schema-contract ]]; then
  schema_contract_self_test
  exit 0
fi

[[ $# -eq 2 ]] || { usage; exit 2; }
TASK=$1
ACTION=$2
configure_target
require_root

case "$ACTION" in
  preflight) do_preflight_action ;;
  execute) do_execute_action ;;
  verify) do_verify_action ;;
  rollback) do_rollback_action ;;
  archive) do_archive_action ;;
  *) usage; exit 2 ;;
esac
